Files
accounted/app/invite/[token]/page.tsx
T
8f1b1fb5cd feat(ui): company monogram in user menu + mobile web touch polish (#1531)
* feat(ui): replace generic building icon with company monogram in user menu

The company row and switcher flyout in the sidebar user menu showed
lucide Building2 for every company. Render the company's initial in a
small rounded square instead (square = company, circle = person), so
each company gets a mark of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mobile): touch behavior polish for the mobile web experience

- kill -webkit-tap-highlight-color flash; touch-action: manipulation on
  interactive elements (no double-tap-to-zoom wait); user-select: none
  on buttons (long-press no longer enters text selection)
- overscroll-behavior-y: contain on html/body: pull-to-refresh no longer
  hijacks list scrolling, inner scrollers stop chaining to the document
  (contain, not none, so iOS rubber-banding survives)
- 16px font-size floor for form fields on coarse pointers: iOS Safari
  stops zooming into focused inputs; desktop keeps text-sm
- min-h-screen -> min-h-dvh everywhere: correct height under collapsing
  mobile browser chrome, identical on desktop
- active: variants mirror hover: on Button: Tailwind 4 gates hover:
  behind (hover: hover), so touch devices previously got zero pointer
  feedback
- theme-color now tracks the app: default was a leftover blue #304D83;
  SSR emits white and ThemeColorSync mirrors the computed --background
  into the meta tag across dark mode and palette switches

Hover-stuck-after-tap and viewport-fit/safe-area were already covered
(Tailwind 4 hover gating; existing viewportFit: cover + safe-area
utilities).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log monogram and overscroll decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:41:45 +02:00

394 lines
16 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Loader2, Building2, AlertCircle } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service'
import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'
const branding = getBranding()
/**
* How long the pre-auth invite cookie lives, in seconds.
*
* This hop has to survive an email round-trip: an invitee who clicks "create
* account" at 17:00 confirms the signup mail the next morning, and only then
* lands back on a surface that can redeem the token. A one-hour cookie made
* that acceptance impossible while the invitation itself was still `pending`
* in the database, with no way for the invitee to recover: the cookie is the
* only copy the browser holds.
*
* The value is the invite TTL, `INVITE_TTL_DAYS` in lib/auth/invite-tokens.ts,
* which is what actually bounds the token: `getInviteExpiry()` stamps
* `company_invitations.expires_at` 7 days out at issue time and POST
* /api/team/accept rejects anything past it with 410. `INVITE_TTL_DAYS` is a
* module-private const in a server-only module (it imports Node's `crypto`),
* so the number is restated here rather than imported;
* app/invite/[token]/__tests__/invite-cookie.test.ts reads the real TTL back
* out of `getInviteExpiry()` and fails if the two drift.
*
* Widening the cookie does not widen authority. Every acceptance attempt is
* re-authorized server-side by `requireAuth()` plus an email equality check
* against the invitation, and the `status` transition is single-use, so a
* surviving cookie confers nothing on its own. If the invitation expires
* before the cookie does (the invitee sat on the mail for six days), the next
* attempt gets a 410, which `consumeInviteCookie()` classifies as `spent` and
* clears. Never set this beyond the invite TTL: a cookie outliving the
* server-side bound would be lifetime the server does not honour.
*/
const INVITE_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
/**
* The single construction site for the invite cookie. Three hops write it
* (register, login, sign-out-and-retry) and each used to carry its own copy of
* the string. They happened to still agree, but three literals is how a
* lifetime drifts; one builder is what makes the max-age and the flags
* provably identical across all three.
*
* Flags are unchanged from the original: path-scoped to the whole site so any
* auth surface can read it, `samesite=lax` so it survives the top-level
* navigation back from the confirmation mail without riding along on
* cross-site subrequests, and `secure` whenever the page is on https. It is
* deliberately not `httponly`: it is written from `document.cookie` and read
* back by client-side auth surfaces.
*/
function buildInviteCookie(token: string, secureFlag: string): string {
return `${INVITE_COOKIE_NAME}=${token}; path=/; max-age=${INVITE_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secureFlag}`
}
interface InviteInfo {
type: 'company'
companyName?: string
email: string
expired: boolean
alreadyHasAccount: boolean
}
export default function InvitePage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const t = useTranslations('invite')
const token = params.token as string
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [invite, setInvite] = useState<InviteInfo | null>(null)
// Email of the currently signed-in user (if any). Used to short-circuit
// the login redirect for users who are already authenticated.
const [currentUserEmail, setCurrentUserEmail] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
async function loadInvite() {
try {
// Load invite info and current session in parallel: the page needs
// both to decide which CTA to render.
const supabase = createClient()
const [inviteRes, sessionRes] = await Promise.all([
fetch(`/api/team/accept?token=${encodeURIComponent(token)}`),
supabase.auth.getUser(),
])
const data = await inviteRes.json()
if (!inviteRes.ok) {
setError(data.error || t('invalid_invite'))
return
}
setInvite(data.data)
setCurrentUserEmail(sessionRes.data.user?.email ?? null)
} catch {
setError(t('load_failed'))
} finally {
setIsLoading(false)
}
}
loadInvite()
}, [token, t])
const secureCookieFlag = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; secure' : ''
// True when the signed-in user's email matches the invite: in that case
// we can accept the invite with a single click, no re-login required.
const isLoggedInAsInvitee =
!!currentUserEmail &&
!!invite &&
currentUserEmail.toLowerCase() === invite.email.toLowerCase()
// True when someone is signed in but with a different email than the
// invite is for. They need to sign out first.
const isLoggedInAsOther =
!!currentUserEmail && !!invite && !isLoggedInAsInvitee
const handleAccept = () => {
// Store invite token in cookie before redirecting to register
document.cookie = buildInviteCookie(token, secureCookieFlag)
router.push(`/register?invite=${encodeURIComponent(token)}`)
}
const handleAcceptExistingUser = () => {
// Store invite token in cookie before redirecting to login
document.cookie = buildInviteCookie(token, secureCookieFlag)
router.push('/login')
}
// Already signed in as the invitee: accept directly, no login detour.
// POST /api/team/accept handles the membership insert + sets the active
// company; we then full-reload to '/' so middleware picks up the new
// company context and the switcher shows it.
const handleJoinNow = async () => {
setIsJoining(true)
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: t('join_failed_title'),
description: body.error || t('unexpected_error'),
variant: 'destructive',
})
setIsJoining(false)
return
}
toast({
title: t('welcome_title'),
description: invite?.companyName
? t('joined_named', { companyName: invite.companyName })
: t('joined_generic'),
})
// Full reload so the middleware re-resolves company context from the
// updated user_preferences.active_company_id.
window.location.href = '/'
} catch (err) {
console.error('[invite] join failed:', err)
toast({
title: t('join_failed_title'),
description: t('unexpected_error'),
variant: 'destructive',
})
setIsJoining(false)
}
}
const handleSignOutAndRetry = async () => {
const supabase = createClient()
await supabase.auth.signOut()
// Keep the invite cookie alive so the next login/register picks it up.
document.cookie = buildInviteCookie(token, secureCookieFlag)
if (invite?.alreadyHasAccount) {
router.push('/login')
} else {
router.push(`/register?invite=${encodeURIComponent(token)}`)
}
}
if (isLoading) {
return (
<div className="min-h-dvh flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div className="min-h-dvh flex flex-col bg-background">
<header className="relative bg-[#141414] text-white overflow-hidden">
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
<span className="font-display text-base tracking-tight" style={{ fontWeight: 700 }}>
{branding.appName.toLowerCase()}
</span>
</div>
<div className="animate-fade-in">
<h1 className="font-display text-2xl md:text-3xl tracking-tight leading-[1.1]">
{error ? t('header_invalid') : t('header_invited')}
</h1>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div className="animate-slide-up">
{error ? (
<Card className="p-6">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{error}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('contact_inviter')}
</p>
<Link
href="/login"
className="text-sm text-primary hover:underline mt-3 inline-block"
>
{t('go_to_login')}
</Link>
</div>
</div>
</Card>
) : invite?.expired ? (
<Card className="p-6">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">{t('expired_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('expired_description')}
</p>
</div>
</div>
</Card>
) : isLoggedInAsInvitee ? (
// Already signed in as the invitee: one-click join.
// Prioritized over alreadyHasAccount to avoid the broken flow
// where a false-negative from the email check would send a
// logged-in user to /register, which middleware bounces to /.
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted/50">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t('invited_to_company')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t.rich('logged_in_as', {
email: currentUserEmail ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</div>
</Card>
<Button
size="lg"
className="w-full"
onClick={handleJoinNow}
disabled={isJoining}
>
{isJoining ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('joining')}
</>
) : (
<>{t('join_named', { companyName: invite.companyName ?? '' })}</>
)}
</Button>
</div>
) : isLoggedInAsOther ? (
// Signed in as a different user: ask them to sign out first.
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted/50">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t.rich('wrong_account', {
invitedEmail: invite.email,
currentEmail: currentUserEmail ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t('signout_then_login')}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleSignOutAndRetry}>
{t('signout_and_switch')}
</Button>
</div>
) : invite?.alreadyHasAccount ? (
// Not signed in: email has an existing account, bounce to login.
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted/50">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t('invited_to_company')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t.rich('existing_account', {
email: invite.email,
appName: branding.appName.toLowerCase(),
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleAcceptExistingUser}>
{t('login_and_join')}
</Button>
</div>
) : invite ? (
// Not signed in, no existing account: register.
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted/50">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">{invite.companyName}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t('invited_to_company')}
</p>
</div>
</div>
</Card>
<Button size="lg" className="w-full" onClick={handleAccept}>
{t('create_account_and_join')}
</Button>
<p className="text-center text-xs text-muted-foreground">
{t('terms_notice')}
</p>
</div>
) : null}
</div>
</div>
</main>
</div>
)
}