Files
accounted/app/(auth)/register/page.tsx
T
Jakob WennbergandClaude Opus 4.7 b9a2ce522b chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment (#400)
* chore: post-bankid redirect, recapt feedback, TIC SPAR enrichment

- BankID login + register now redirect to /select-company so the picker
  shows freshly enriched CompanyRoles from the current session.
- New lib/support/submit-feedback util prefers window.recapt feedback
  widget when present, falls back to /api/support/contact. SupportLink
  uses it and hides itself in sandbox companies via new isSandbox flag
  on CompanyContext (+ useCompanyOptional hook).
- TIC enrichment re-requests SPAR alongside CompanyRoles now that both
  types are enabled on the tenant; enrichment shape logged PII-free
  (booleans/counts only). Tests cover the SPAR+CompanyRoles path.
- Skatteverket api-client: 15s AbortSignal timeout on outbound requests.
- Swedish compliance review CI: bump REVIEW_MODEL to claude-opus-4-7.

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

* chore: keep compliance review model on sonnet-4-6

Reverts the opus-4-7 bump from the previous commit per request.

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

* fix(tic): don't persist SPAR PII to extension_data

The previous commit started requesting SPAR alongside CompanyRoles and
wrote the full enrichment payload (incl. personnummer, full name, home
address, birth date, gender) verbatim to extension_data.value — a plain
JSON column. Personnummer is already hashed + encrypted in
bankid_identities, so the extension_data row was an unencrypted PII
duplicate exposed to anyone with read access to the table.

No consumer (middleware, /select-company, createCompanyFromTicRole)
reads any SPAR field today; they only read companyRoles. Persist a
sanitized blob of { companyRoles, enrichedAtUtc } instead. SPAR is
still requested from TIC (and its shape logged PII-free) so enrichment
completes; if address pre-fill ships later, those fields should be
encrypted before storage.

Also drop the dead `null` branch from SubmitFeedbackResult.channel —
every code path returns 'recapt' or 'email'.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:43:06 +02:00

523 lines
18 KiB
TypeScript

'use client'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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'
import { getBranding } from '@/lib/branding/service'
const branding = getBranding()
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<RegisterPageContent />
</Suspense>
)
}
function RegisterPageContent() {
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
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.
useEffect(() => {
const inviteToken = searchParams.get('invite')
if (!inviteToken) return
fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.data?.email) {
setInviteEmail(data.data.email)
setEmail(data.data.email)
}
})
.catch(() => {})
}, [searchParams])
const [bankIdUnavailable, setBankIdUnavailable] = useState(false)
const handleBankIdComplete = (result: BankIdResult) => {
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
return
}
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 if (json.error === 'account_exists') {
toast({
title: 'Kontot finns redan',
description: 'Ett konto med den har e-postadressen finns redan. Logga in och koppla BankID i installningarna.',
variant: 'destructive',
})
router.push('/login')
} 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('/select-company')
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)
&& /[A-Z]/.test(pw)
&& /[0-9]/.test(pw)
&& /[^a-zA-Z0-9]/.test(pw)
}
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('email') as string) || email
const passwordValue = (formData.get('password') as string) || password
const confirmValue = (formData.get('confirm_password') as string) || confirmPassword
if (!isStrongPassword(passwordValue)) {
toast({
title: 'Lösenordet är för svagt',
description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.',
variant: 'destructive',
})
setIsLoading(false)
return
}
if (passwordValue !== confirmValue) {
toast({
title: 'Lösenorden matchar inte',
description: 'Kontrollera att du skrev samma lösenord i båda fälten.',
variant: 'destructive',
})
setIsLoading(false)
return
}
try {
console.log('[register] attempting signUp', {
email: emailValue,
hasPassword: !!passwordValue,
passwordLength: passwordValue.length,
redirectTo: `${window.location.origin}/auth/callback`,
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
})
const { data, error } = await supabase.auth.signUp({
email: emailValue,
password: passwordValue,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
})
if (error) {
console.error('[register] signUp error', {
message: error.message,
code: error.code,
status: error.status,
name: error.name,
stack: error.stack,
cause: error.cause,
fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)),
})
toast({
title: 'Registrering misslyckades',
description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
return
}
console.log('[register] signUp response', {
userId: data.user?.id,
email: data.user?.email,
isAnonymous: data.user?.is_anonymous,
identities: data.user?.identities?.length,
hasSession: !!data.session,
confirmationSentAt: data.user?.confirmation_sent_at,
provider: data.user?.app_metadata?.provider,
})
// If auto-confirmed (local dev), process invite immediately and redirect
if (data.session) {
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
console.log('[register] invite accepted after auto-confirm — redirecting')
window.location.href = '/'
return
}
// Log the error response so we can diagnose invite failures
const errBody = await res.json().catch(() => ({}))
console.error('[register] invite acceptance returned non-ok', {
status: res.status,
error: errBody.error,
})
} catch (err) {
console.error('[register] invite acceptance failed:', err)
}
}
// Auto-confirmed but no invite or invite failed — go to onboarding
// (invite cookie is preserved so the onboarding fallback can retry)
window.location.href = '/'
return
}
setEmail(emailValue)
setIsRegistered(true)
} catch (error) {
console.error('[register] unexpected exception', {
error,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
type: typeof error,
constructor: error?.constructor?.name,
})
toast({
title: 'Registrering misslyckades',
description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
if (isRegistered) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Bekräfta din e-post</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en bekräftelselänk till{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Klicka på länken i e-posten för att aktivera ditt konto.
Länken är giltig i 24 timmar.
</p>
</div>
<Button variant="ghost" className="w-full text-muted-foreground" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till inloggning
</Link>
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<Image
src={branding.logoPath}
alt={branding.appName}
width={240}
height={240}
className="mx-auto mb-2"
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Skapa ett konto för att komma igång
</p>
</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>
</>
)}
{bankIdUnavailable && !bankIdUser && (
<div className="mb-5 rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare.
</p>
</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>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
{inviteEmail && (
<p className="text-xs text-muted-foreground">
Inbjudan skickades till denna adress.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken, Aa1!"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">Bekräfta lösenord</Label>
<Input
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</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>
</form>
)}
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
Har du redan ett konto?{' '}
<Link
href="/login"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary transition-colors"
>
Logga in
</Link>
</p>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
Genom att skapa konto godkänner du våra{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
</a>
.
</p>
</div>
</div>
)
}