fix(invites): accept invite on BankID signup, recover missed invites on onboarding surfaces (#1157)
An invited user who registered via BankID was funneled into creating a company instead of joining the one they were invited to: the register page's BankID path never processed the gnubok-invite-token cookie (unlike the login, MFA-verify, and auth-callback paths). Observed in production 2026-07-24. - register: BankID signup now accepts the pending invite before routing to /select-company, mirroring the login page's BankID path. - lib/company/pending-invites: acceptPendingInviteByToken retries a missed acceptance from the cookie (pending + unexpired + email match, same rules as POST /api/team/accept); hasPendingInviteForEmail detects a stranded invitee whose cookie is gone. - /onboarding and /select-company retry acceptance from the cookie and redirect to the dashboard on success, making the auth callback's long-promised fallback real; with no cookie but a pending invitation, both surfaces show a 'join via the link in the invitation email' hint instead of silently asking the invitee to create a company. - No new accept path without the token: the hint deliberately points back to the mailed link, so mailbox possession stays required and no company name is leaked to unverified emails. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d3c37dac08
commit
15300aa8e2
@@ -363,3 +363,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-24] BankID signup email guard = createUser email_exists error mapping, not a profiles.email pre-check: profiles is not a reliable mirror of auth.users (anonymize tombstones scrub it), and the error-based check has no TOCTOU window. Deleted-account emails stay blocked for re-signup by design (tombstone keeps auth.users.email).
|
||||
[2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle).
|
||||
[2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing).
|
||||
[2026-07-24] Invite recovery on onboarding surfaces = cookie retry + hint, NOT accept-by-email: a BankID signup's email is confirmed via a client-delivered magiclink (no mailbox proof), so auto-joining on email match would let anyone who registers the invitee's address claim the membership. The cookie/token path keeps mailbox possession required; cookie-less invitees get pointed back to the mailed link, with no company name leaked.
|
||||
|
||||
@@ -200,8 +200,9 @@ export async function GET(request: NextRequest) {
|
||||
for (const { name, value, options } of pendingCookies) {
|
||||
response.cookies.set({ name, value, ...options })
|
||||
}
|
||||
// Keep the invite cookie alive so the onboarding page fallback can
|
||||
// retry acceptance (only clear it when successfully processed above).
|
||||
// Keep the invite cookie alive so the /onboarding and /select-company
|
||||
// pages can retry acceptance via acceptPendingInviteByToken (only clear
|
||||
// it when successfully processed above).
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,34 @@ function RegisterPageContent() {
|
||||
return
|
||||
}
|
||||
|
||||
// Invited signup: accept the pending invite before routing to the
|
||||
// picker, same as the login page's BankID path. Without this, an
|
||||
// invitee who registers with BankID lands on /select-company with no
|
||||
// membership and gets funneled into creating a company instead of
|
||||
// joining the one they were invited to.
|
||||
const inviteCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
|
||||
const inviteToken = inviteCookieMatch?.[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'
|
||||
window.location.href = '/'
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
// Clear cookie even on failure to avoid retrying stale tokens
|
||||
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
|
||||
}
|
||||
|
||||
router.push('/select-company')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
|
||||
import {
|
||||
acceptPendingInviteByToken,
|
||||
hasPendingInviteForEmail,
|
||||
} from '@/lib/company/pending-invites'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
import { mapEntityType as mapTicEntityType } from '@/lib/company-lookup/entity-type-map'
|
||||
@@ -53,6 +58,20 @@ export default async function OnboardingPage({
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Invite recovery: an invitee normally never reaches this page (the client
|
||||
// flows or the auth callback attach them to the company first), so landing
|
||||
// here with a live invite cookie means acceptance was missed. Retry it and
|
||||
// skip onboarding entirely on success; if only a pending invitation exists
|
||||
// (cookie lost, e.g. confirmation opened on another device), surface a hint
|
||||
// instead of silently asking the invitee to create a company.
|
||||
const inviteToken = (await cookies()).get('gnubok-invite-token')?.value
|
||||
if (inviteToken && (await acceptPendingInviteByToken(user, inviteToken))) {
|
||||
redirect('/')
|
||||
}
|
||||
const hasPendingInvite = user.email
|
||||
? await hasPendingInviteForEmail(user.email)
|
||||
: false
|
||||
|
||||
const { data: teamMembership } = await supabase
|
||||
.from('team_members')
|
||||
.select('team_id')
|
||||
@@ -101,6 +120,9 @@ export default async function OnboardingPage({
|
||||
initialOrgNumber={initialOrgNumber}
|
||||
initialEntityType={initialEntityType}
|
||||
initialLegalName={initialLegalName}
|
||||
// A deep-linked orgnr is a deliberate create-this-company pick from the
|
||||
// BankID list: don't distract that flow with the invite hint.
|
||||
hasPendingInvite={hasPendingInvite && !initialOrgNumber}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
acceptPendingInviteByToken,
|
||||
hasPendingInviteForEmail,
|
||||
} from '@/lib/company/pending-invites'
|
||||
import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types'
|
||||
import BankIdCompanyPicker, {
|
||||
type MemberCompany,
|
||||
@@ -18,8 +23,16 @@ export default async function SelectCompanyPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// All four lookups key only on user.id, one parallel batch instead of
|
||||
// four serial round-trips on the post-BankID-login landing page.
|
||||
// Invite recovery, same as /onboarding: BankID users land here, so a missed
|
||||
// invite acceptance (e.g. a register flow that dropped the cookie handling)
|
||||
// gets retried before the picker funnels the invitee into creating a company.
|
||||
const inviteToken = (await cookies()).get('gnubok-invite-token')?.value
|
||||
if (inviteToken && (await acceptPendingInviteByToken(user, inviteToken))) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
// All lookups key only on user.id/email, one parallel batch instead of
|
||||
// serial round-trips on the post-BankID-login landing page.
|
||||
const [
|
||||
// Existing Accounted memberships.
|
||||
{ data: memberships },
|
||||
@@ -30,6 +43,9 @@ export default async function SelectCompanyPage() {
|
||||
// user-keyed in `bankid_enrichment` because it lands before company
|
||||
// selection, see fetchAndStoreEnrichment in the tic extension.
|
||||
{ data: enrichmentRow },
|
||||
// Pending invitation for this email with no cookie to accept it from:
|
||||
// rendered as a "check your invite email" hint in the picker.
|
||||
hasPendingInvite,
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('company_members')
|
||||
@@ -57,6 +73,7 @@ export default async function SelectCompanyPage() {
|
||||
.select('company_roles, created_at, updated_at')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle(),
|
||||
user.email ? hasPendingInviteForEmail(user.email) : Promise.resolve(false),
|
||||
])
|
||||
|
||||
type CompanyRow = {
|
||||
@@ -172,6 +189,7 @@ export default async function SelectCompanyPage() {
|
||||
memberCompanies={memberCompanies}
|
||||
ticCompanies={ticCompanies}
|
||||
enrichmentStale={enrichmentStale}
|
||||
hasPendingInvite={hasPendingInvite}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ interface BankIdCompanyPickerProps {
|
||||
memberCompanies: MemberCompany[]
|
||||
ticCompanies: TicPickerCompany[]
|
||||
enrichmentStale: boolean
|
||||
/** A pending invitation exists for this email but no invite token is at
|
||||
* hand: point the user back to the link in the invitation email. */
|
||||
hasPendingInvite?: boolean
|
||||
}
|
||||
|
||||
type SetupState = { kind: 'idle' } | { kind: 'opening'; companyId: string }
|
||||
@@ -78,6 +81,7 @@ export default function BankIdCompanyPicker({
|
||||
memberCompanies,
|
||||
ticCompanies,
|
||||
enrichmentStale,
|
||||
hasPendingInvite = false,
|
||||
}: BankIdCompanyPickerProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -149,6 +153,14 @@ export default function BankIdCompanyPicker({
|
||||
<p className="text-muted-foreground text-sm mt-1.5">{t('subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{hasPendingInvite && (
|
||||
<div className="mb-6 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t('pending_invite_note')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enrichmentStale && (
|
||||
<div className="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||
<div className="flex items-start gap-2.5">
|
||||
|
||||
@@ -61,6 +61,10 @@ interface OnboardingJourneyProps {
|
||||
initialOrgNumber?: string
|
||||
initialEntityType?: EntityType
|
||||
initialLegalName?: string
|
||||
/** A pending invitation exists for this user's email: an invitee has
|
||||
* likely landed here by mistake (lost invite cookie), so the first
|
||||
* question carries a "join via the link in the email" hint. */
|
||||
hasPendingInvite?: boolean
|
||||
}
|
||||
|
||||
export default function OnboardingJourney({
|
||||
@@ -69,6 +73,7 @@ export default function OnboardingJourney({
|
||||
initialOrgNumber,
|
||||
initialEntityType,
|
||||
initialLegalName,
|
||||
hasPendingInvite = false,
|
||||
}: OnboardingJourneyProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations('onboarding')
|
||||
@@ -298,6 +303,7 @@ export default function OnboardingJourney({
|
||||
return (
|
||||
<Question
|
||||
title={t('journey_orgnr_title')}
|
||||
sub={hasPendingInvite ? t('journey_pending_invite_note') : undefined}
|
||||
attn={
|
||||
state.serverError === 'org_number_invalid'
|
||||
? t('journey_err_org_invalid')
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => serviceSupabase,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/invite-tokens', () => ({
|
||||
hashInviteToken: (t: string) => `hash-${t}`,
|
||||
}))
|
||||
|
||||
import { acceptPendingInviteByToken, hasPendingInviteForEmail } from '../pending-invites'
|
||||
|
||||
const user = { id: 'user-1', email: 'invitee@test.se' }
|
||||
|
||||
const futureIso = () => new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
const pastIso = () => new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const pendingInvite = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'inv-1',
|
||||
company_id: 'co-1',
|
||||
email: 'invitee@test.se',
|
||||
role: 'admin',
|
||||
status: 'pending',
|
||||
expires_at: futureIso(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
})
|
||||
|
||||
describe('acceptPendingInviteByToken', () => {
|
||||
it('accepts a valid pending invite', async () => {
|
||||
enqueue({ data: pendingInvite() }) // invitation lookup
|
||||
enqueue({}) // company_members insert
|
||||
enqueue({}) // user_preferences upsert
|
||||
enqueue({}) // invitation status update
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('treats an existing membership (23505) as fulfilled', async () => {
|
||||
enqueue({ data: pendingInvite() })
|
||||
enqueue({ error: { code: '23505', message: 'duplicate' } })
|
||||
enqueue({})
|
||||
enqueue({})
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the invitation is not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-pending invitation', async () => {
|
||||
enqueue({ data: pendingInvite({ status: 'accepted' }) })
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an expired invitation', async () => {
|
||||
enqueue({ data: pendingInvite({ expires_at: pastIso() }) })
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when the email does not match', async () => {
|
||||
enqueue({ data: pendingInvite({ email: 'other@test.se' }) })
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('matches emails case-insensitively', async () => {
|
||||
enqueue({ data: pendingInvite({ email: 'Invitee@Test.se' }) })
|
||||
enqueue({})
|
||||
enqueue({})
|
||||
enqueue({})
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the user has no email', async () => {
|
||||
await expect(acceptPendingInviteByToken({ id: 'user-1' }, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on a non-duplicate membership insert error', async () => {
|
||||
enqueue({ data: pendingInvite() })
|
||||
enqueue({ error: { code: '42501', message: 'denied' } })
|
||||
await expect(acceptPendingInviteByToken(user, 'tok')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPendingInviteForEmail', () => {
|
||||
it('returns true when a pending invite row exists', async () => {
|
||||
enqueue({ data: [{ id: 'inv-1' }] })
|
||||
await expect(hasPendingInviteForEmail('Invitee@Test.se')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when there are no rows', async () => {
|
||||
enqueue({ data: [] })
|
||||
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the query errors', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
await expect(hasPendingInviteForEmail('invitee@test.se')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
|
||||
/**
|
||||
* Invite-recovery helpers for the onboarding surfaces.
|
||||
*
|
||||
* An invited user is supposed to be attached to the company by one of the
|
||||
* client flows (invite page one-click join, login/register/mfa cookie
|
||||
* handling) or by the auth callback. When all of those miss (confirmation
|
||||
* link opened on another device, cookie expired, transient failure), the
|
||||
* user lands on /onboarding or /select-company as an apparent first-timer.
|
||||
* These helpers let those pages heal the miss instead of funneling a
|
||||
* confused invitee into creating a company.
|
||||
*/
|
||||
|
||||
interface AuthUserLike {
|
||||
id: string
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry invite acceptance from a raw `gnubok-invite-token` cookie value.
|
||||
* Mirrors the acceptance in POST /api/team/accept: requires a pending,
|
||||
* unexpired invitation whose email matches the authenticated user.
|
||||
* Returns true when the user is now a member of the invited company
|
||||
* (including the already-a-member case, which it settles by marking the
|
||||
* invitation accepted). Never throws; failures return false and leave the
|
||||
* normal onboarding flow to render.
|
||||
*/
|
||||
export async function acceptPendingInviteByToken(
|
||||
user: AuthUserLike,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!user.email) return false
|
||||
|
||||
try {
|
||||
const serviceClient = createServiceClient()
|
||||
const tokenHash = hashInviteToken(token)
|
||||
|
||||
const { data: invite } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.select('id, company_id, email, role, status, expires_at')
|
||||
.eq('token_hash', tokenHash)
|
||||
.single()
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite.status !== 'pending' ||
|
||||
new Date(invite.expires_at) < new Date() ||
|
||||
user.email.toLowerCase() !== invite.email.toLowerCase()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { error: memberError } = await serviceClient.from('company_members').insert({
|
||||
company_id: invite.company_id,
|
||||
user_id: user.id,
|
||||
role: invite.role,
|
||||
source: 'direct',
|
||||
})
|
||||
|
||||
// 23505 = already a member: the invitation is fulfilled, settle it below.
|
||||
if (memberError && memberError.code !== '23505') {
|
||||
console.error('[pending-invites] membership insert failed', memberError)
|
||||
return false
|
||||
}
|
||||
|
||||
// Non-fatal on failure: middleware falls back to the membership.
|
||||
const { error: prefError } = await serviceClient
|
||||
.from('user_preferences')
|
||||
.upsert(
|
||||
{ user_id: user.id, active_company_id: invite.company_id },
|
||||
{ onConflict: 'user_id' },
|
||||
)
|
||||
if (prefError) {
|
||||
console.error('[pending-invites] failed to set active company', prefError)
|
||||
}
|
||||
|
||||
await serviceClient
|
||||
.from('company_invitations')
|
||||
.update({ status: 'accepted' })
|
||||
.eq('id', invite.id)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[pending-invites] acceptance retry failed', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a pending, unexpired invitation exists for this email.
|
||||
* Invitation emails are lowercased at creation (invite route Zod schema),
|
||||
* so the lowercase equality match is exact. Used to tell an invitee who
|
||||
* arrived without the invite token ("go open the link in the email")
|
||||
* apart from a genuine first-time user. Never throws.
|
||||
*/
|
||||
export async function hasPendingInviteForEmail(email: string): Promise<boolean> {
|
||||
try {
|
||||
const serviceClient = createServiceClient()
|
||||
const { data } = await serviceClient
|
||||
.from('company_invitations')
|
||||
.select('id')
|
||||
.eq('email', email.trim().toLowerCase())
|
||||
.eq('status', 'pending')
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.limit(1)
|
||||
|
||||
return (data ?? []).length > 0
|
||||
} catch (err) {
|
||||
console.error('[pending-invites] pending lookup failed', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1313,7 @@
|
||||
"journey_ans_monthly": "Monthly",
|
||||
"journey_ans_yearly": "Yearly",
|
||||
"journey_dup_note": "You already have {name} in Accounted.",
|
||||
"journey_pending_invite_note": "You have a pending invitation to a company. Open the link in the invitation email to join instead.",
|
||||
"journey_fact_vat": "VAT registered",
|
||||
"journey_fact_ceased": "Deregistered",
|
||||
"journey_cancel_add": "Back to Accounted"
|
||||
@@ -1325,6 +1326,7 @@
|
||||
"greeting_evening": "Good evening",
|
||||
"subtitle": "Choose a company to open or add a new one.",
|
||||
"enrichment_stale": "The information from BankID is more than a week old. Sign in with BankID again to refresh the list.",
|
||||
"pending_invite_note": "You have a pending invitation to a company. Open the link in the invitation email to join.",
|
||||
"section_your_companies": "Your companies in {appName}",
|
||||
"already_in_app": "Already in {appName}",
|
||||
"setup_manually": "Set up manually",
|
||||
|
||||
@@ -1313,6 +1313,7 @@
|
||||
"journey_ans_monthly": "Månadsvis",
|
||||
"journey_ans_yearly": "Årsvis",
|
||||
"journey_dup_note": "Du har redan {name} i Accounted.",
|
||||
"journey_pending_invite_note": "Du har en väntande inbjudan till ett företag. Öppna länken i inbjudningsmejlet för att gå med istället.",
|
||||
"journey_fact_vat": "Momsregistrerat",
|
||||
"journey_fact_ceased": "Avregistrerat",
|
||||
"journey_cancel_add": "Tillbaka till Accounted"
|
||||
@@ -1325,6 +1326,7 @@
|
||||
"greeting_evening": "God kväll",
|
||||
"subtitle": "Välj ett företag att öppna eller lägg till ett nytt.",
|
||||
"enrichment_stale": "Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.",
|
||||
"pending_invite_note": "Du har en väntande inbjudan till ett företag. Öppna länken i inbjudningsmejlet för att gå med.",
|
||||
"section_your_companies": "Dina företag i {appName}",
|
||||
"already_in_app": "Finns redan i {appName}",
|
||||
"setup_manually": "Sätts upp manuellt",
|
||||
|
||||
Reference in New Issue
Block a user