Seven radii were in circulation (4/5/6/8/12/16px + pill) with no rule for which went where; one toolbar row on /transactions mixed four shape languages. This locks a 4-tier ladder (design.md convention 16): - pill: interactive toolbar controls (buttons, chips, pickers, segmented controls, toolbar search, count nubs) - rounded-xl (12px): overlay tier: page panel, dialogs, slide-overs - rounded-lg (8px): cards, form fields, popover/menu content, boxes - rounded-sm (4px): nested leaves (menu items, checkboxes, kbd/code nubs) Changes: - New SegmentedControl primitive (pill-in-pill tablist, h-8) replaces the hand-rolled bg-muted/70 tablist copied across 11 files - New ToolbarSearch primitive (pill, h-8) adopted on 9 page toolbars; dialog/picker searches keep the rounded-lg Input - dialog.tsx 8px -> 12px, matching SettingsModal/slide-over/CommandPalette - ContextPicker chips at the shared h-8 toolbar height - ~300 rounded-md / bare rounded call sites remapped by role; auth icon tiles and the mobile nav sheet come down from 16px to 12px - rounded-md, bare rounded, rounded-2xl and rounded-[Npx] are dead vocabulary, enforced by a new off-ladder-radius check in check:guards Verified: lint 0 errors, 14422 unit tests pass, check:guards green, tsc clean on all changed files, sandbox screenshots of transactions/ bookkeeping/granskning toolbars and the Ny verifikation dialog. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, Suspense } from 'react'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
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, KeyRound } from 'lucide-react'
|
|
import { INVITE_PROBLEM_MESSAGE_KEYS } from '@/lib/auth/consume-invite-cookie'
|
|
import { handoffPendingInvite } from './invite-handoff'
|
|
|
|
/**
|
|
* Three entry modes:
|
|
*
|
|
* - 'set-password': a recovery session already exists (legacy email links
|
|
* land via /auth/callback, or a token was just verified below).
|
|
* - 'confirm-link': the email link points here with ?token_hash=. The
|
|
* token is verified ONLY on an explicit button click: corporate mail
|
|
* scanners (Microsoft Defender SafeLinks) render pages and follow
|
|
* links but do not click buttons, so the one-time token survives
|
|
* scanning. Never verify in an effect; that re-opens the burn.
|
|
* - 'enter-code': no session, no token_hash. The email also carries a
|
|
* one-time code the user can type together with their email address.
|
|
* The code length follows the project's Email OTP Length setting
|
|
* (this project uses 8, gotrue allows 6-10), so the input must never
|
|
* cap at 6: a maxLength shorter than the real code silently truncates
|
|
* what the user types and every verify fails.
|
|
*/
|
|
type Mode = 'loading' | 'set-password' | 'confirm-link' | 'enter-code'
|
|
|
|
function ResetPasswordInner() {
|
|
const t = useTranslations('reset_password')
|
|
const tInvite = useTranslations('invite')
|
|
const searchParams = useSearchParams()
|
|
const tokenHash = searchParams.get('token_hash')
|
|
const [mode, setMode] = useState<Mode>('loading')
|
|
const [email, setEmail] = useState('')
|
|
const [code, setCode] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const { toast } = useToast()
|
|
const router = useRouter()
|
|
const supabase = createClient()
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
|
if (cancelled) return
|
|
if (session) setMode('set-password')
|
|
else if (tokenHash) setMode('confirm-link')
|
|
else setMode('enter-code')
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const verifyFailed = (description: string) => {
|
|
toast({
|
|
title: t('verify_failed_title'),
|
|
description,
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
|
|
const handleConfirmLink = async () => {
|
|
if (!tokenHash) return
|
|
setIsLoading(true)
|
|
const { error } = await supabase.auth.verifyOtp({
|
|
token_hash: tokenHash,
|
|
type: 'recovery',
|
|
})
|
|
setIsLoading(false)
|
|
if (error) {
|
|
// Burned or expired link: fall back to typing the code (a fresh
|
|
// request may be needed, the hint says so).
|
|
setMode('enter-code')
|
|
verifyFailed(t('link_invalid_description'))
|
|
return
|
|
}
|
|
setMode('set-password')
|
|
}
|
|
|
|
const handleVerifyCode = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault()
|
|
setIsLoading(true)
|
|
const { error } = await supabase.auth.verifyOtp({
|
|
email: email.trim(),
|
|
token: code.trim(),
|
|
type: 'recovery',
|
|
})
|
|
setIsLoading(false)
|
|
if (error) {
|
|
verifyFailed(t('code_invalid_description'))
|
|
return
|
|
}
|
|
setMode('set-password')
|
|
}
|
|
|
|
const handleResetPassword = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault()
|
|
setIsLoading(true)
|
|
|
|
const strong = password.length >= 8
|
|
&& /[a-z]/.test(password)
|
|
&& /[A-Z]/.test(password)
|
|
&& /[0-9]/.test(password)
|
|
&& /[^a-zA-Z0-9]/.test(password)
|
|
|
|
if (!strong) {
|
|
toast({
|
|
title: t('weak_title'),
|
|
description: t('weak_description'),
|
|
variant: 'destructive',
|
|
})
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
toast({
|
|
title: t('mismatch_title'),
|
|
description: t('mismatch_description'),
|
|
variant: 'destructive',
|
|
})
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Routed through the API so the has_password flag flips in lock-step
|
|
// with the password update. This is the unlock path for BankID-only
|
|
// users who enrolled MFA and got locked out: the recovery session
|
|
// bypasses AAL2, the API flips has_password, and the lockout banner
|
|
// disappears the next time they log in.
|
|
const res = await fetch('/api/account/password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
toast({
|
|
title: t('save_failed_title'),
|
|
description: body.error || t('save_failed_description'),
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
|
|
toast({
|
|
title: t('saved_title'),
|
|
description: t('saved_description'),
|
|
})
|
|
|
|
// Only now, with the reset finished and the session proven by the 2xx
|
|
// above (that route runs requireAuth()), pick up a pending invitation.
|
|
// This is the recovery flow's copy of what login, register and
|
|
// /mfa/verify already do, and it is the only chance an invitee who
|
|
// already has a company of their own gets: the server-side retry on
|
|
// /onboarding never sees them. See ./invite-handoff.ts.
|
|
const inviteDestination = await handoffPendingInvite({
|
|
getUser: async () => (await supabase.auth.getUser()).data.user,
|
|
getAssuranceLevel: async () =>
|
|
(await supabase.auth.mfa.getAuthenticatorAssuranceLevel()).data,
|
|
reportProblem: (problem) => {
|
|
const keys = INVITE_PROBLEM_MESSAGE_KEYS[problem]
|
|
toast({
|
|
title: tInvite(keys.title),
|
|
description: tInvite(keys.body),
|
|
variant: 'destructive',
|
|
})
|
|
},
|
|
})
|
|
|
|
if (inviteDestination) {
|
|
// Hard navigation: either the membership and the active company just
|
|
// changed server-side, or the destination re-runs acceptance on the
|
|
// server. Both leave the client router's cached render stale.
|
|
window.location.href = inviteDestination
|
|
return
|
|
}
|
|
|
|
router.push('/')
|
|
router.refresh()
|
|
} catch {
|
|
toast({
|
|
title: t('save_failed_title'),
|
|
description: t('save_failed_description'),
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const subtitle =
|
|
mode === 'confirm-link'
|
|
? t('confirm_subtitle')
|
|
: mode === 'enter-code'
|
|
? t('code_subtitle')
|
|
: t('subtitle')
|
|
|
|
return (
|
|
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
|
|
<div className="w-full max-w-sm animate-slide-up">
|
|
<div className="text-center mb-10">
|
|
<div className="flex justify-center mb-4">
|
|
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
|
|
<KeyRound className="h-7 w-7 text-primary" />
|
|
</div>
|
|
</div>
|
|
<h1 className="text-2xl tracking-tight">{t('title')}</h1>
|
|
<p className="text-muted-foreground text-sm mt-2">{subtitle}</p>
|
|
</div>
|
|
|
|
<div className="rounded-lg border bg-card p-6">
|
|
{mode === 'loading' && (
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'confirm-link' && (
|
|
<div className="space-y-5">
|
|
<Button
|
|
type="button"
|
|
className="w-full h-11"
|
|
onClick={handleConfirmLink}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{t('confirm_verifying')}
|
|
</>
|
|
) : (
|
|
t('confirm_button')
|
|
)}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{t('confirm_hint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'enter-code' && (
|
|
<form onSubmit={handleVerifyCode} className="space-y-5">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t('email_label')}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
disabled={isLoading}
|
|
className="h-11"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="code">{t('code_label')}</Label>
|
|
<Input
|
|
id="code"
|
|
type="text"
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
placeholder={t('code_placeholder')}
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
required
|
|
minLength={6}
|
|
maxLength={10}
|
|
disabled={isLoading}
|
|
className="h-11 tracking-widest"
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full h-11" disabled={isLoading}>
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{t('code_verifying')}
|
|
</>
|
|
) : (
|
|
t('code_button')
|
|
)}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{t('code_hint')}
|
|
</p>
|
|
</form>
|
|
)}
|
|
|
|
{mode === 'set-password' && (
|
|
<form onSubmit={handleResetPassword} className="space-y-5">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t('new_password_label')}</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder={t('new_password_placeholder')}
|
|
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">{t('confirm_password_label')}</Label>
|
|
<Input
|
|
id="confirm_password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
placeholder={t('confirm_password_placeholder')}
|
|
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" />
|
|
{t('submitting')}
|
|
</>
|
|
) : (
|
|
t('submit')
|
|
)}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ResetPasswordPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<ResetPasswordInner />
|
|
</Suspense>
|
|
)
|
|
}
|