Recapt shuts down in four days, taking product analytics and session replay with it. This adds PostHog Cloud EU alongside it; the Recapt removal follows separately so events can be confirmed landing first. Wiring choices that are not the tutorial defaults: - Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding PostHog hosts to the CSP. connect-src 'self' and script-src 'self' already cover it, tracking blockers have no third-party host to match, and the Recapt allowlist entries in next.config.ts get replaced by nothing at all when they go. Needs skipTrailingSlashRedirect, since PostHog sends trailing-slash API requests; verified that trailing-slash URLs on normal routes still resolve 200 rather than 404. - /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE next.config rewrites, so without this updateSession() treats an ingestion POST as an unknown protected path and 307s it to /login. Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200 from PostHog. This fails silently otherwise, because asset loads keep working through the rewrite while no events arrive. - persistence: 'memory' so nothing is written to the device and no cookie-consent banner is required. Everything post-login is unaffected: AnalyticsIdentify re-identifies on each dashboard load. - session_recording.maskTextSelector: '*'. PostHog masks inputs but not text by default, and this app renders org numbers (which for an enskild firma ARE the owner's personnummer), customer names and balances as ordinary text. Replays show where a user gets stuck, never what their books say. buildGroupProperties() also refuses to send org_number at all, with a test pinning it. - Error tracking registers through the existing lib/observability sink rather than bypassing it, so every error-level createLogger() line is captured already redacted. instrumentation.ts onRequestError covers what escapes uncaught. Analytics is hosted-only: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted runs with zero third-party runtime code. Recapt got that outcome only by accident, via a missing sentinel; here it is explicit and tested. vitest.config.ts aliases 'server-only' to a stub: it is a build-time guard whose real entry point always throws, which broke 48 test files the moment a server-only module entered the graph. request-context.ts was already carrying the same latent trap. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
267 lines
9.0 KiB
TypeScript
267 lines
9.0 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import { useLocale, useTranslations } from 'next-intl'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Sun, Moon, Monitor, LogOut, ExternalLink } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
|
import { InstallAppSection } from '@/components/settings/InstallAppSection'
|
|
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
|
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
|
import {
|
|
SettingsGroup,
|
|
SettingsInput,
|
|
SettingsRow,
|
|
SettingsRowEnd,
|
|
SettingsSectionHeader,
|
|
SettingsSeg,
|
|
} from '@/components/settings/SettingsRows'
|
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
import { useSettings } from '@/components/settings/useSettings'
|
|
import { clearRecaptIdentity } from '@/lib/recapt'
|
|
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
|
|
|
|
export function AccountSettingsContent() {
|
|
const router = useRouter()
|
|
const supabase = createClient()
|
|
const { theme, setTheme } = useTheme()
|
|
const [mounted, setMounted] = useState(false)
|
|
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
|
const { settings } = useSettings()
|
|
const { toast } = useToast()
|
|
const activeLocale = useLocale() as Locale
|
|
const tCommon = useTranslations('common')
|
|
const tSettings = useTranslations('settings')
|
|
const tNav = useTranslations('settings_nav')
|
|
const tIntro = useTranslations('settings_intro')
|
|
const [savingLocale, setSavingLocale] = useState(false)
|
|
const [fullName, setFullName] = useState('')
|
|
const [initialName, setInitialName] = useState('')
|
|
const [nameLoading, setNameLoading] = useState(true)
|
|
const [savingName, setSavingName] = useState(false)
|
|
|
|
useEffect(() => { setMounted(true) }, [])
|
|
|
|
// Pre-fill the name field from profiles.full_name. Self-contained client
|
|
// fetch: mirrors BankIdSettings.
|
|
useEffect(() => {
|
|
let active = true
|
|
;(async () => {
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) { if (active) setNameLoading(false); return }
|
|
const { data } = await supabase
|
|
.from('profiles')
|
|
.select('full_name')
|
|
.eq('id', user.id)
|
|
.maybeSingle()
|
|
if (!active) return
|
|
setFullName(data?.full_name ?? '')
|
|
setInitialName(data?.full_name ?? '')
|
|
setNameLoading(false)
|
|
})()
|
|
return () => { active = false }
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
async function handleSaveName() {
|
|
const trimmed = fullName.trim()
|
|
if (!trimmed || trimmed === initialName || savingName) return
|
|
setSavingName(true)
|
|
try {
|
|
const res = await fetch('/api/user/profile', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ full_name: trimmed }),
|
|
})
|
|
if (!res.ok) throw new Error('Could not save')
|
|
setFullName(trimmed)
|
|
setInitialName(trimmed)
|
|
toast({ title: tSettings('name_saved') })
|
|
router.refresh()
|
|
} catch {
|
|
toast({ title: tSettings('name_save_failed'), variant: 'destructive' })
|
|
} finally {
|
|
setSavingName(false)
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
clearRecaptIdentity()
|
|
resetAnalyticsIdentity()
|
|
await supabase.auth.signOut()
|
|
router.push('/login')
|
|
}
|
|
|
|
async function handleLocaleChange(next: Locale) {
|
|
if (next === activeLocale || savingLocale) return
|
|
setSavingLocale(true)
|
|
try {
|
|
const res = await fetch('/api/user/locale', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ locale: next }),
|
|
})
|
|
if (!res.ok) throw new Error('Could not save')
|
|
toast({ title: tSettings('language_saved') })
|
|
router.refresh()
|
|
} catch {
|
|
toast({
|
|
title: tSettings('language_save_failed'),
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setSavingLocale(false)
|
|
}
|
|
}
|
|
|
|
const localeLabels: Record<Locale, string> = {
|
|
sv: tCommon('language_swedish'),
|
|
en: tCommon('language_english'),
|
|
}
|
|
|
|
const nameUnchanged = !fullName.trim() || fullName.trim() === initialName
|
|
|
|
return (
|
|
<div>
|
|
<SettingsSectionHeader title={tNav('account')} intro={tIntro('account')} />
|
|
|
|
{/* Profile: name, appearance, language, install-as-app */}
|
|
<SettingsGroup label={tSettings('group_profile')}>
|
|
<SettingsRow
|
|
label={tSettings('name_label')}
|
|
htmlFor="full_name"
|
|
help={tSettings('name_description')}
|
|
align="baseline"
|
|
>
|
|
<SettingsInput
|
|
id="full_name"
|
|
value={fullName}
|
|
onChange={(e) => setFullName(e.target.value)}
|
|
placeholder={tSettings('name_placeholder')}
|
|
disabled={nameLoading || savingName}
|
|
maxLength={100}
|
|
/>
|
|
<SettingsRowEnd>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveName}
|
|
disabled={nameLoading || savingName || nameUnchanged}
|
|
>
|
|
{savingName ? tCommon('saving') : tCommon('save')}
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
|
|
<SettingsRow label={tSettings('section_appearance')}>
|
|
{mounted && (
|
|
<SettingsSeg
|
|
value={theme ?? 'system'}
|
|
onChange={setTheme}
|
|
aria-label={tSettings('section_appearance')}
|
|
options={[
|
|
{
|
|
value: 'light',
|
|
label: (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Sun className="h-3.5 w-3.5" />
|
|
{tCommon('theme_light')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
value: 'dark',
|
|
label: (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Moon className="h-3.5 w-3.5" />
|
|
{tCommon('theme_dark')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
value: 'system',
|
|
label: (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<Monitor className="h-3.5 w-3.5" />
|
|
{tCommon('theme_system')}
|
|
</span>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</SettingsRow>
|
|
|
|
<SettingsRow
|
|
label={tSettings('section_language')}
|
|
help={tSettings('language_description')}
|
|
>
|
|
<SettingsSeg
|
|
value={activeLocale}
|
|
onChange={(next) => void handleLocaleChange(next)}
|
|
disabled={savingLocale}
|
|
aria-label={tSettings('section_language')}
|
|
options={SUPPORTED_LOCALES.map((value) => ({
|
|
value,
|
|
label: localeLabels[value],
|
|
}))}
|
|
/>
|
|
</SettingsRow>
|
|
|
|
{/* Install as app: renders nothing when already running installed */}
|
|
<InstallAppSection />
|
|
</SettingsGroup>
|
|
|
|
{/* Security: BankID, password, 2FA (renders its own group) */}
|
|
<SecuritySettings />
|
|
|
|
{/* Calendar feed (extension-gated) */}
|
|
{hasCalendarExtension && <CalendarFeedSettings />}
|
|
|
|
{/* Privacy & agreements: surface the otherwise-unlinked DPA + privacy policy */}
|
|
<SettingsGroup label={tSettings('legal_title')}>
|
|
<SettingsRow label={tSettings('legal_privacy')}>
|
|
<SettingsRowEnd>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href="/privacy" target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
|
{tCommon('open')}
|
|
</Link>
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
<SettingsRow label={tSettings('legal_dpa')}>
|
|
<SettingsRowEnd>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href="/dpa" target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
|
{tCommon('open')}
|
|
</Link>
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
</SettingsGroup>
|
|
|
|
{/* Sign out */}
|
|
<SettingsGroup>
|
|
<SettingsRow label={tCommon('logout')} help={tCommon('logout_description')}>
|
|
<SettingsRowEnd>
|
|
<Button variant="outline" size="sm" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-3.5 w-3.5" />
|
|
{tCommon('logout')}
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
</SettingsGroup>
|
|
|
|
{/* Delete account: only for non-sandbox */}
|
|
{!settings?.is_sandbox && <AccountDangerZone />}
|
|
</div>
|
|
)
|
|
}
|