feat: add user locale preference to user_preferences table (#555)

* feat: add user locale preference to user_preferences table

- Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences.
- Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed.
- Triggered a schema reload notification for the changes.

chore: declare CSS module support in TypeScript

- Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files.

* feat: add Swish as an invoice payment method in company settings
This commit is contained in:
Mattsson
2026-05-21 21:33:22 +02:00
committed by GitHub
parent cc351158f8
commit e4488a900b
144 changed files with 12304 additions and 3853 deletions
+24 -7
View File
@@ -1,6 +1,7 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
import { shouldEnforceMfa } from '@/lib/auth/mfa'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
import { userHasPassword } from '@/lib/auth/has-password'
export async function updateSession(request: NextRequest) {
@@ -135,7 +136,7 @@ export async function updateSession(request: NextRequest) {
// MFA required but user has no factor enrolled yet → force enrollment
// Skip for users with no companies (still setting up)
const companyIdForMfa = await resolveCompanyForMiddleware(supabase, user.id, request)
const { companyId: companyIdForMfa } = await resolveCompanyForMiddleware(supabase, user.id, request)
if (companyIdForMfa) {
const { data: factors } = await supabase.auth.mfa.listFactors()
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
@@ -152,7 +153,7 @@ export async function updateSession(request: NextRequest) {
// Company context resolution
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
const { companyId, locale: dbLocale } = await resolveCompanyForMiddleware(supabase, user.id, request)
// If the cookie pointed at a company we can no longer resolve (e.g.
// archived), clear it so the browser stops sending it.
@@ -160,6 +161,20 @@ export async function updateSession(request: NextRequest) {
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
}
// Sync the locale cookie from user_preferences. This keeps next-intl's
// request config (which reads the cookie) consistent with the DB value
// without forcing every RSC render to query the database itself.
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
const effectiveLocale = isLocale(dbLocale) ? dbLocale : DEFAULT_LOCALE
if (cookieLocale !== effectiveLocale) {
supabaseResponse.cookies.set(LOCALE_COOKIE, effectiveLocale, {
path: '/',
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
})
}
// Routes that stay accessible when the user has no active company.
// Needed so a user who archived their last company can still delete
// their account without being trapped on /onboarding forever.
@@ -230,14 +245,16 @@ async function resolveCompanyForMiddleware(
supabase: ReturnType<typeof createServerClient>,
userId: string,
_request: NextRequest
): Promise<string | null> {
): Promise<{ companyId: string | null; locale: string | null }> {
// 1. user_preferences (authoritative)
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.select('active_company_id, locale')
.eq('user_id', userId)
.maybeSingle()
const locale = (prefs?.locale as string | undefined) ?? null
if (prefs?.active_company_id) {
const { data: membership } = await supabase
.from('company_members')
@@ -247,7 +264,7 @@ async function resolveCompanyForMiddleware(
.is('companies.archived_at', null)
.maybeSingle()
if (membership) return membership.company_id
if (membership) return { companyId: membership.company_id, locale }
}
// 2. Fallback: first non-archived membership by created_at
@@ -260,7 +277,7 @@ async function resolveCompanyForMiddleware(
.limit(1)
.maybeSingle()
if (!firstCompany) return null
if (!firstCompany) return { companyId: null, locale }
// Write the fallback back to user_preferences so future RLS lookups
// see the same active company without needing this fallback scan.
@@ -271,5 +288,5 @@ async function resolveCompanyForMiddleware(
{ onConflict: 'user_id' }
)
return firstCompany.company_id
return { companyId: firstCompany.company_id, locale }
}