From e8928b7885fd42cd88123d51aa215cc0cef5cf7b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:08:52 +0200 Subject: [PATCH] feat: enable company member invitations (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enable company member invitations Activate the invite form in company settings, show pending invitations, fix the existing-user invite flow, and process invite tokens after login. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — MFA invite flow and secure cookie flag Process invite token after MFA verification so MFA-enabled users joining via invite are not silently dropped. Add secure flag on the invite cookie when served over HTTPS. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(auth)/login/page.tsx | 47 ++++++++++++++++++ app/(auth)/mfa/verify/page.tsx | 23 +++++++++ app/invite/[token]/page.tsx | 49 ++++++++++++------- components/settings/CompanyMembersSection.tsx | 44 +++++++++++------ 4 files changed, 130 insertions(+), 33 deletions(-) diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 2f11bacd..5b373dad 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -74,6 +74,29 @@ export default function LoginPage() { return } + // Check for pending invite token + const bankIdCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) + const bankIdInviteToken = bankIdCookieMatch?.[1] + + if (bankIdInviteToken) { + try { + const res = await fetch('/api/team/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: bankIdInviteToken }), + }) + + if (res.ok) { + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + window.location.href = '/' + return + } + } catch (err) { + console.error('[login] invite acceptance failed:', err) + } + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + } + router.push('/') router.refresh() } catch (error) { @@ -120,6 +143,30 @@ export default function LoginPage() { return } + // Check for pending invite token + const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) + const inviteToken = cookieMatch?.[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('[login] invite acceptance failed:', err) + } + // Clear cookie even on failure to avoid retrying stale tokens + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + } + router.push('/') router.refresh() } catch (error) { diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 1e4789d6..876ef442 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -100,6 +100,29 @@ export default function MfaVerifyPage() { return } + // Check for pending invite token + const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) + const inviteToken = cookieMatch?.[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('[mfa/verify] invite acceptance failed:', err) + } + document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + } + router.push('/') router.refresh() } catch { diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 9ef51fb9..cbe1f219 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -46,12 +46,20 @@ export default function InvitePage() { loadInvite() }, [token]) + const secureCookieFlag = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; secure' : '' + const handleAccept = () => { // Store invite token in cookie before redirecting to register - document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax` + document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` router.push(`/register?invite=${encodeURIComponent(token)}`) } + const handleAcceptExistingUser = () => { + // Store invite token in cookie before redirecting to login + document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` + router.push('/login') + } + if (isLoading) { return (
@@ -124,24 +132,29 @@ export default function InvitePage() {
) : invite?.alreadyHasAccount ? ( - -
- -
-

E-postadressen har redan ett konto

-

- {invite.email} är redan registrerad på gnubok. - Kontot måste tas bort innan du kan acceptera inbjudan. -

- - Gå till inloggning - +
+ +
+
+ +
+
+

{invite.companyName}

+

+ Du har bjudits in som medlem till detta företag. +

+

+ {invite.email} har redan ett konto på gnubok. + Logga in för att gå med. +

+
-
- + + + +
) : invite ? (
diff --git a/components/settings/CompanyMembersSection.tsx b/components/settings/CompanyMembersSection.tsx index 157959ed..b430d972 100644 --- a/components/settings/CompanyMembersSection.tsx +++ b/components/settings/CompanyMembersSection.tsx @@ -158,14 +158,9 @@ export function CompanyMembersSection() { return (
- {/* Invite form — disabled, coming soon */} + {/* Invite form */} {canInvite && ( - -
- - Kommer snart - -
+ Bjud in till {company?.name} @@ -173,21 +168,40 @@ export function CompanyMembersSection() { -
+
setInviteEmail(e.target.value)} + disabled={isSending} + required />
- -
+
)} @@ -253,8 +267,8 @@ export function CompanyMembersSection() {
- {/* Pending invitations — hidden while invites are disabled */} - {false && invitations.length > 0 && ( + {/* Pending invitations */} + {invitations.length > 0 && ( Väntande inbjudningar