From 2d43254af2b92f30517a0c8eed02060727468f60 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 17 May 2026 00:02:13 +0330 Subject: [PATCH] bugfix: invitation link copy option disapearing fixed. route problem for unauthorizrd users opening invitation link fixed. --- .../organization/organization.service.ts | 7 +- backend/src/modules/staff/staff.controller.ts | 13 + backend/src/modules/staff/staff.service.ts | 59 ++++- .../app/(dashboard)/organizations/page.tsx | 225 +++++------------- .../settings/organizations/page.tsx | 2 +- frontend/src/app/(dashboard)/staff/page.tsx | 120 +++++++--- .../app/(public)/select-organization/page.tsx | 2 +- .../invitations/organizationInviteLinks.ts | 41 ++++ .../organizations/InvitationHistoryDialog.tsx | 146 ++++++++++++ .../OrganizationSelectorContent.tsx | 0 frontend/src/lib/api/client.ts | 17 +- frontend/src/lib/api/staff.ts | 15 ++ .../hooks/useOrganizationInviteLinkCopy.ts | 107 +++++++++ frontend/src/proxy.ts | 12 +- 14 files changed, 561 insertions(+), 205 deletions(-) create mode 100644 frontend/src/components/invitations/organizationInviteLinks.ts create mode 100644 frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx rename frontend/src/components/ui/{organization => organizations}/OrganizationSelectorContent.tsx (100%) create mode 100644 frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index ce8d967..c48bdc1 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -288,8 +288,11 @@ export class OrganizationService { if (!invitation) { throw new NotFoundException('Invitation not found'); } - if (invitation.acceptedAt || invitation.revokedAt) { - throw new BadRequestException('Only pending invitations can provide a link'); + if (invitation.acceptedAt) { + throw new BadRequestException('This invitation has already been accepted'); + } + if (invitation.revokedAt) { + throw new BadRequestException('This invitation is no longer valid'); } const plainToken = this.generateInviteToken(); diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index df4ba8d..e4732ea 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -55,6 +55,19 @@ export class StaffController { return this.staffService.invite(req.user.id, organizationId, dto); } + @Post('members/:membershipId/invitation-link') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Regenerate and return invite link for a pending staff member', + }) + getInvitationLink( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.getInvitationLink(req.user.id, organizationId, membershipId); + } + @Patch('members/:membershipId') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Update staff member name and/or permissions' }) diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 92a1fd5..2dcd3b2 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -224,6 +224,62 @@ export class StaffService { }; } + async getInvitationLink(userId: string, organizationId: string, membershipId: string) { + const actor = await this.getActorMembership(userId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new ForbiddenException('You cannot invite or manage staff'); + } + + const membership = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + include: { + user: { select: { email: true } }, + invitations: { orderBy: { createdAt: 'desc' }, take: 1 }, + }, + }); + + if (!membership) { + throw new NotFoundException('Member not found'); + } + if (membership.isOwner) { + throw new BadRequestException('Owner does not use an invitation link'); + } + if (membership.isActive) { + throw new BadRequestException('This member has already accepted their invitation'); + } + + const invitation = membership.invitations[0]; + if (!invitation) { + throw new BadRequestException('No invitation found for this member'); + } + if (invitation.acceptedAt) { + throw new BadRequestException('This invitation has already been accepted'); + } + if (invitation.revokedAt) { + throw new BadRequestException('This invitation is no longer valid'); + } + + const plainToken = this.generateInviteToken(); + const tokenHash = this.hashInviteToken(plainToken); + await this.prisma.staffInvitation.update({ + where: { id: invitation.id }, + data: { + tokenHash, + expiresAt: this.getInviteExpiryDate(), + }, + }); + + return { + success: true, + data: { + membershipId: membership.id, + invitationId: invitation.id, + email: membership.user.email, + invitationUrl: this.buildInviteUrl(plainToken), + }, + }; + } + async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); const org = invitation.membership.organization; @@ -383,7 +439,8 @@ export class StaffService { if (m.isOwner || m.isActive) return 'ACTIVE'; const invitation = m.invitations[0]; if (!invitation) return 'EXPIRED'; - if (invitation.acceptedAt || invitation.revokedAt) return 'ACTIVE'; + if (invitation.acceptedAt) return 'ACTIVE'; + if (invitation.revokedAt) return 'EXPIRED'; return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED'; } diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/(dashboard)/organizations/page.tsx index 0d8390a..0e5b2f0 100644 --- a/frontend/src/app/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/organizations/page.tsx @@ -1,14 +1,16 @@ -'use client'; +'use client'; import { useEffect, useState } from 'react'; -import { Check, Copy, Link2, Trash2, X } from 'lucide-react'; +import { Check, Link2, Trash2, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { useOrganizationInviteLinkCopy } from '@/lib/hooks/useOrganizationInviteLinkCopy'; import { organizationApi, type CounterpartItemDto, type CounterpartSearchResultDto, type OrganizationInvitationHistoryItemDto, } from '@/lib/api/organization'; +import { InvitationHistoryDialog } from '@/components/ui/organizations/InvitationHistoryDialog'; import { Button } from '@/components/ui/common/Button'; import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge'; import { Input } from '@/components/ui/common/Input'; @@ -16,33 +18,6 @@ import { SearchBar } from '@/components/ui/common/SearchBar'; import { Table } from '@/components/ui/common/Table'; import type { ApiError } from '@/types/api'; -type StoredInviteLink = { - invitationId: string; - ownerEmail: string; - invitationUrl: string; -}; - -function inviteLinksStorageKey(orgId: string): string { - return `counterpartInviteLinks:${orgId}`; -} - -function readStoredInviteLinks(orgId: string): Record { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - -function writeStoredInviteLinks(orgId: string, links: Record) { - if (typeof window === 'undefined') return; - window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); -} - function formatOrganizationStatusLabel(status: string): string { if (!status) return status; const lower = status.toLowerCase(); @@ -56,13 +31,6 @@ function formatLinkStatusLabel(status: CounterpartItemDto['status']): string { return formatOrganizationStatusLabel(status); } -function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { - if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation Accepted'; - if (status === 'REJECTED') return 'Invitation rejected'; - return formatOrganizationStatusLabel(status); -} - function formatApiMessage(err: unknown): string { if (!err || typeof err !== 'object') return 'Something went wrong'; const m = (err as ApiError).message; @@ -73,7 +41,7 @@ function formatApiMessage(err: unknown): string { function formatTableDate(value: string): string { const d = new Date(value); - if (Number.isNaN(d.getTime())) return '—'; + if (Number.isNaN(d.getTime())) return '—'; return d.toLocaleDateString(); } @@ -96,12 +64,20 @@ export default function OrganizationsPage() { const [manualOrganizationName, setManualOrganizationName] = useState(''); const [manualOwnerEmail, setManualOwnerEmail] = useState(''); const [inviteLoading, setInviteLoading] = useState(false); - const [copiedId, setCopiedId] = useState(null); - const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); const [showInviteForm, setShowInviteForm] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyItems, setHistoryItems] = useState([]); + const [historyCopyError, setHistoryCopyError] = useState(''); + const [historyCopySuccess, setHistoryCopySuccess] = useState(''); + + const { + copiedId, + copyingInvitationId, + storeInviteLink, + copyInvitationLink, + pruneAcceptedLinks, + } = useOrganizationInviteLinkCopy(currentOrganization?.id); const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; @@ -121,11 +97,6 @@ export default function OrganizationsPage() { } } - useEffect(() => { - if (!currentOrganization?.id) return; - setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); - }, [currentOrganization?.id]); - useEffect(() => { void loadList(); }, []); @@ -185,18 +156,7 @@ export default function OrganizationsPage() { organizationName: manualOrganizationName.trim(), ownerEmail: manualOwnerEmail.trim(), }); - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [res.data.invitationId]: { - invitationId: res.data.invitationId, - ownerEmail: manualOwnerEmail.trim().toLowerCase(), - invitationUrl: res.data.invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } + storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); setManualOrganizationName(''); setManualOwnerEmail(''); @@ -212,13 +172,21 @@ export default function OrganizationsPage() { } } + async function loadInvitationHistory() { + const res = await organizationApi.listInvitations(); + setHistoryItems(res.data.items); + pruneAcceptedLinks(res.data.items); + return res.data.items; + } + async function openInvitationHistory() { setHistoryOpen(true); setHistoryLoading(true); + setHistoryCopyError(''); + setHistoryCopySuccess(''); setError(''); try { - const res = await organizationApi.listInvitations(); - setHistoryItems(res.data.items); + await loadInvitationHistory(); } catch (e) { setError(formatApiMessage(e)); } finally { @@ -226,6 +194,22 @@ export default function OrganizationsPage() { } } + async function handleHistoryCopy(invitation: OrganizationInvitationHistoryItemDto) { + setHistoryCopyError(''); + setHistoryCopySuccess(''); + try { + await copyInvitationLink(invitation, { + onRegenerated: async () => { + await loadInvitationHistory(); + }, + }); + setHistoryCopySuccess('Invitation link copied to clipboard.'); + setTimeout(() => setHistoryCopySuccess(''), 3000); + } catch (e) { + setHistoryCopyError(formatApiMessage(e)); + } + } + async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') { setRequestLinkRowId(linkId); setError(''); @@ -254,35 +238,6 @@ export default function OrganizationsPage() { } } - async function copyInvitationLink(invitationId: string) { - setError(''); - try { - let invitationUrl = pendingInviteLinks[invitationId]?.invitationUrl; - if (!invitationUrl) { - const res = await organizationApi.getInvitationLink(invitationId); - invitationUrl = res.data.invitationUrl; - if (currentOrganization?.id) { - const nextLinks = { - ...pendingInviteLinks, - [invitationId]: { - invitationId, - ownerEmail: - historyItems.find((item) => item.id === invitationId)?.ownerEmail?.toLowerCase() ?? '', - invitationUrl, - }, - }; - setPendingInviteLinks(nextLinks); - writeStoredInviteLinks(currentOrganization.id, nextLinks); - } - } - await navigator.clipboard.writeText(invitationUrl); - setCopiedId(invitationId); - setTimeout(() => setCopiedId(null), 1500); - } catch { - setError('Could not copy invitation link'); - } - } - function clearSearchView() { setMode('existing'); setQuery(''); @@ -516,87 +471,21 @@ export default function OrganizationsPage() { } /> - {historyOpen && ( -
-
-
-

Invitation History

- -
- - {historyLoading ? ( -

Loading invitation history...

- ) : historyItems.length === 0 ? ( -

No invitations yet.

- ) : ( - - - - - - - - } - body={ - <> - {historyItems.map((inv) => ( - - - - - - - - ))} - - } - /> - )} - - - )} + { + setHistoryOpen(false); + setHistoryCopyError(''); + setHistoryCopySuccess(''); + }} + loading={historyLoading} + items={historyItems} + copiedId={copiedId} + copyingInvitationId={copyingInvitationId} + onCopy={(invitation) => void handleHistoryCopy(invitation)} + copyError={historyCopyError} + copySuccess={historyCopySuccess} + /> ); } diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/(dashboard)/settings/organizations/page.tsx index dd5ad9c..ccbdb3b 100644 --- a/frontend/src/app/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/(dashboard)/settings/organizations/page.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; +import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { return ( diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 71adcf0..afd483d 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -61,6 +61,10 @@ function formatApiMessage(err: unknown): string { return 'Something went wrong'; } +function canShareStaffInviteLink(member: StaffMemberDto): boolean { + return !member.isOwner && member.invitationStatus !== 'ACTIVE'; +} + function PermissionGrid({ state, onChange, @@ -140,6 +144,7 @@ export default function StaffPage() { const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [inviteLoading, setInviteLoading] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); + const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState(null); const [lastInviteInfo, setLastInviteInfo] = useState<{ membershipId: string; name: string; @@ -221,6 +226,42 @@ export default function StaffPage() { return () => clearTimeout(t); }, [success]); + async function copyStaffInviteLink(member: StaffMemberDto) { + if (!canShareStaffInviteLink(member)) return; + + setCopyingInviteMembershipId(member.id); + setError(''); + try { + let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; + if (!invitationUrl || member.invitationStatus === 'EXPIRED') { + const res = await staffApi.getInvitationLink(member.id); + invitationUrl = res.data.invitationUrl; + if (currentOrganization?.id) { + const nextLinks = { + ...pendingInviteLinks, + [member.id]: { + membershipId: member.id, + email: member.email, + invitationUrl, + }, + }; + setPendingInviteLinks(nextLinks); + writeStoredInviteLinks(currentOrganization.id, nextLinks); + } + } + await navigator.clipboard.writeText(invitationUrl); + setCopiedInviteMembershipId(member.id); + setTimeout(() => setCopiedInviteMembershipId(null), 1500); + if (member.invitationStatus === 'EXPIRED') { + await load(); + } + } catch (e) { + setError(formatApiMessage(e)); + } finally { + setCopyingInviteMembershipId(null); + } + } + async function submitInvite() { setInviteLoading(true); setError(''); @@ -388,34 +429,60 @@ export default function StaffPage() { ? ' Invitation is pending until they open the link, set a password, and log in.' : ' Invitation was accepted immediately.'}

- {lastInviteInfo.invitationUrl && ( + {lastInviteInfo.invitationStatus === 'PENDING' && (

Invite link

-
- + {lastInviteInfo.invitationUrl && ( + {lastInviteInfo.invitationUrl} - -
+ })(); + }} + > + {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} +

- Share this link manually via SMS or email. They must set password first. + Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.

)} @@ -470,21 +537,14 @@ export default function StaffPage() {
- Organization - - Owner email - - Date - - Status - - Action -
{inv.organizationName}{inv.ownerEmail} - {formatTableDate(inv.createdAt)} - - - {formatInvitationStatusLabel(inv.status)} - - - {inv.status === 'PENDING' ? ( - - ) : ( - - )} -
{!m.isOwner && (
- {m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && ( + {canShareStaffInviteLink(m) && ( +
+ + {copyError && ( +
+ {copyError} +
+ )} + + {copySuccess && ( +
+ {copySuccess} +
+ )} + + {loading ? ( +

Loading invitation history...

+ ) : items.length === 0 ? ( +

No invitations yet.

+ ) : ( + + + + + + + + } + body={ + <> + {items.map((inv) => ( + + + + + + + + ))} + + } + /> + )} + + + ); +} diff --git a/frontend/src/components/ui/organization/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx similarity index 100% rename from frontend/src/components/ui/organization/OrganizationSelectorContent.tsx rename to frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 47319d7..409d9b8 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -15,6 +15,17 @@ export const apiClient = axios.create({ timeout: 10000, }); +/** Invitation preview/accept must work with no cookies (public API, no JWT). */ +function isPublicInvitationRequest(url: string | undefined): boolean { + if (!url) return false; + return ( + url.includes('/staff/invitations/preview') || + url.includes('/staff/invitations/accept') || + url.includes('/organizations/invitations/preview') || + url.includes('/organizations/invitations/accept') + ); +} + // ❌ REMOVE request interceptor completely (no Authorization header) // ✅ Response interceptor @@ -23,7 +34,11 @@ apiClient.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as CustomAxiosRequestConfig; - if (error.response?.status === 401 && !originalRequest._retry) { + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isPublicInvitationRequest(originalRequest.url) + ) { originalRequest._retry = true; try { diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index 25bfee6..1852b65 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -63,6 +63,21 @@ export const staffApi = { return response.data; }, + getInvitationLink: async ( + membershipId: string, + ): Promise<{ + success: boolean; + data: { + membershipId: string; + invitationId: string; + email: string; + invitationUrl: string; + }; + }> => { + const response = await apiClient.post(`/staff/members/${membershipId}/invitation-link`); + return response.data; + }, + previewInvite: async (token: string): Promise => { const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`); return response.data; diff --git a/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts b/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts new file mode 100644 index 0000000..2bed789 --- /dev/null +++ b/frontend/src/lib/hooks/useOrganizationInviteLinkCopy.ts @@ -0,0 +1,107 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + organizationApi, + type OrganizationInvitationHistoryItemDto, +} from '@/lib/api/organization'; +import { + canShareOrganizationInviteLink, + readOrganizationInviteLinks, + type StoredOrganizationInviteLink, + writeOrganizationInviteLinks, +} from '@/components/invitations/organizationInviteLinks'; + +export function useOrganizationInviteLinkCopy(organizationId: string | undefined) { + const [pendingInviteLinks, setPendingInviteLinks] = useState< + Record + >({}); + const [copiedId, setCopiedId] = useState(null); + const [copyingInvitationId, setCopyingInvitationId] = useState(null); + + useEffect(() => { + if (!organizationId) return; + setPendingInviteLinks(readOrganizationInviteLinks(organizationId)); + }, [organizationId]); + + const storeInviteLink = useCallback( + (invitationId: string, ownerEmail: string, invitationUrl: string) => { + if (!organizationId) return; + setPendingInviteLinks((prev) => { + const next = { + ...prev, + [invitationId]: { + invitationId, + ownerEmail: ownerEmail.trim().toLowerCase(), + invitationUrl, + }, + }; + writeOrganizationInviteLinks(organizationId, next); + return next; + }); + }, + [organizationId], + ); + + const pruneAcceptedLinks = useCallback( + (items: OrganizationInvitationHistoryItemDto[]) => { + if (!organizationId) return; + const acceptedIds = new Set( + items.filter((item) => item.acceptedAt || item.status === 'ACTIVE').map((item) => item.id), + ); + + setPendingInviteLinks((prev) => { + let changed = false; + const next = { ...prev }; + for (const id of Object.keys(next)) { + if (acceptedIds.has(id)) { + delete next[id]; + changed = true; + } + } + if (changed) { + writeOrganizationInviteLinks(organizationId, next); + } + return changed ? next : prev; + }); + }, + [organizationId], + ); + + const copyInvitationLink = useCallback( + async ( + invitation: OrganizationInvitationHistoryItemDto, + options?: { onRegenerated?: () => void | Promise }, + ): Promise => { + if (!canShareOrganizationInviteLink(invitation)) return null; + + setCopyingInvitationId(invitation.id); + try { + let invitationUrl = pendingInviteLinks[invitation.id]?.invitationUrl; + if (!invitationUrl || invitation.status === 'EXPIRED') { + const res = await organizationApi.getInvitationLink(invitation.id); + invitationUrl = res.data.invitationUrl; + storeInviteLink(invitation.id, invitation.ownerEmail, invitationUrl); + await options?.onRegenerated?.(); + } + + await navigator.clipboard.writeText(invitationUrl); + setCopiedId(invitation.id); + setTimeout(() => setCopiedId(null), 1500); + return invitationUrl; + } finally { + setCopyingInvitationId(null); + } + }, + [pendingInviteLinks, storeInviteLink], + ); + + return { + pendingInviteLinks, + copiedId, + copyingInvitationId, + storeInviteLink, + copyInvitationLink, + pruneAcceptedLinks, + }; +} diff --git a/frontend/src/proxy.ts b/frontend/src/proxy.ts index 4186564..e70bcd9 100644 --- a/frontend/src/proxy.ts +++ b/frontend/src/proxy.ts @@ -1,7 +1,17 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password']; +/** Routes that must work without an existing session (first-time invitees). */ +const publicRoutes = [ + '/', + '/login', + '/register', + '/terms', + '/privacy', + '/forgot-password', + '/accept-invite', + '/accept-organization-invite', +]; export function proxy(request: NextRequest) { const { pathname } = request.nextUrl;
+ Organization + + Owner email + + Date + + Status + + Action +
{inv.organizationName}{inv.ownerEmail} + {formatTableDate(inv.createdAt)} + + + {formatInvitationStatusLabel(inv.status)} + + + {canShareOrganizationInviteLink(inv) ? ( + + ) : ( + + )} +