diff --git a/.project/prds/031-all-member-mention/prd.md b/.project/prds/031-all-member-mention/prd.md index 7620693..1545d30 100644 --- a/.project/prds/031-all-member-mention/prd.md +++ b/.project/prds/031-all-member-mention/prd.md @@ -11,6 +11,9 @@ references: - type: prd url: .project/prds/017-notifications-platform-foundation-and-unread-badge/prd.md description: "Notification event model, mention notification producers, and claimed-member delivery rules" + - type: pr + url: https://github.com/fircleco/fircle/pull/54 + description: "Implementation pull request - feat: add support for @all member mention" --- > **Instructions for AI Agents:** diff --git a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md new file mode 100644 index 0000000..92ad500 --- /dev/null +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -0,0 +1,220 @@ +--- +title: "Reusable Family Onboarding Invite Link with Reset Rotation" +status: completed +references: + - type: doc + url: .project/brief.md + description: "Project brief" + - type: prd + url: .project/prds/007-invite-only-registration-flow/prd.md + description: "Baseline invite lifecycle, acceptance, and admin invite management" + - type: prd + url: .project/prds/028-invite-email-delivery-status-and-retry/prd.md + description: "Current invite email delivery contracts and retry behavior" + - type: pr + url: https://github.com/fircleco/fircle/pull/55 + description: "Implementation pull request - feat(invite): introduce family invite link" +--- + +> **Instructions for AI Agents:** +> - Mark each task checkbox (`- [x]`) immediately upon completion. +> - Update the `status` field in the frontmatter to reflect the current state: +> - `in-progress` — when work begins on any phase. +> - `completed` — when all tasks and acceptance criteria are done. +> - `on-hold` — if work is blocked or paused. +> - Do not skip tasks or mark them complete without implementing the work. + +# Reusable Family Onboarding Invite Link with Reset Rotation + +## Description + +Fircle currently treats join invites as single-use: after one successful acceptance, the invite transitions to claimed and is no longer valid. In practice, family admins often need one stable onboarding link that can be shared with multiple incoming members over time. + +This PRD introduces a reusable onboarding invite link for join invites only. The link can be used by multiple people, and admins can reset (rotate) the link at any time. Resetting creates a new active code and invalidates the previous one. + +This feature is additive and preserves existing behavior for: +- Single-use join invites (still supported in parallel) +- Claim links for unclaimed member profiles (unchanged) + +In scope: +- One active reusable join invite link per family +- Admin/owner controls to view, copy, and reset the reusable link +- Invite acceptance updates so reusable links remain valid across successful joins +- Rotation behavior that revokes old reusable links immediately +- Reset-only lifecycle for reusable links (no automatic expiry) +- Safety guardrails for long-lived links (usage visibility + easy rotation) + +Out of scope: +- Changing claim-link architecture or member-claim flows +- Removing legacy single-use invite creation +- Multi-channel onboarding beyond invite URL sharing/email +- Per-link analytics dashboard beyond minimal usage metadata + +### Design Decisions + +- Join invites only: Reusable behavior applies only to family join invites, not claim links. +- Exactly one active reusable link per family: Reset is authoritative and revokes previous active reusable links. +- Keep current single-use flow: Admins can still create one-off invites when needed. +- Grouped action UX: Single-use "Create Invite" and reusable-link management are exposed through a shadcn `ButtonGroup`, with the second grouped control acting as a dropdown menu trigger. +- Naming convention: The dropdown option label is "Family Link", its description text is "Invite via Family Invite Link", and the reusable section title is "Family Invite Link". +- Reusable card persistence: The reusable-link card always displays the current/last created reusable link and its latest lifecycle state, even when it is no longer active. +- First-run migration-safe state: Existing instances that predate this feature must show a clear empty state indicating no reusable link exists yet, with a CTA to create the first reusable link. +- Reset-only lifecycle: Reusable links remain valid until explicitly reset or revoked by an admin/owner. +- Rotation over mutation: Reset creates a new invite record/code and revokes prior reusable record(s), preserving audit trail. +- Open invite binding for reusable link: Reusable links are open (not email-bound) to avoid accidental recipient lock-in and reduce operational friction. +- No claimed terminal state for reusable link usage: Successful acceptance should not invalidate reusable links. +- Guardrail 1 (operability): Reusable card should prominently expose one-click reset at all times. +- Guardrail 2 (visibility): Reusable card should show `lastUsedAt` and `useCount` to make unusual usage detectable. +- Guardrail 3 (hygiene): Reusable card should include a non-blocking rotation reminder for older links (for example, older than 90 days). +- Invite history behavior split: Revoked reusable invite artifacts are excluded from invite history, while successful registrations completed through reusable links are shown in history as onboarding events. + +### User Stories + +- As an owner/admin, I want one reusable invite link for my family, so that I can onboard multiple people without generating a new link each time. +- As an owner/admin, I want to reset the reusable link instantly, so that old shared links stop working if leaked or outdated. +- As an invited person, I want the link to work as long as it remains active (not reset/revoked), so that onboarding is predictable. +- As an owner/admin, I want the existing single-use invite option to remain available, so that I can still issue targeted one-off invites. +- As an owner/admin, I want single-use creation to stay the most prominent action while reusable-link management is available as a secondary menu action, so that default behavior encourages intentional one-person invites. +- As an owner/admin, I want the reusable card to show the latest reusable link state even if no active link exists, so that I always know what the current reusable-link status is. +- As an owner/admin, I want invite history to focus on meaningful onboarding outcomes from reusable links rather than revoked reusable link artifacts, so that history remains useful. +- As a maintainer, I want clear lifecycle handling between reusable and single-use invites, so that invite semantics remain understandable and testable. + +## Implementation Plan + +### Phase 1: Reusable Invite Domain and Persistence + +**Goal:** Extend invite data model to represent reusable join links and rotation lineage without breaking current flows. + +#### Tasks + +- [x] Update Invite model in `prisma/schema.prisma` with reusable-link support fields: +- [x] Add `isReusable` boolean (default false) +- [x] Add `rotatedFromInviteId` nullable self-reference (recommended) +- [x] Add `useCount` integer default 0 (recommended) +- [x] Add `lastUsedAt` nullable timestamp (recommended) +- [x] Add optional `createdAt`-based rotation reminder support (for example UI threshold at 90 days) +- [x] Add indexes to support fast lookup of active reusable invite by family (`familyId`, `isReusable`, `status`) +- [x] Create and apply migration for new fields/indexes +- [x] Confirm existing invite and claim queries remain compatible + +### Phase 2: Shared Validation and Lifecycle Helpers + +**Goal:** Add explicit reusable-invite-aware domain logic while preserving current single-use logic. + +#### Tasks + +- [x] Extend lifecycle/usability helpers in `src/lib/invite.ts` to account for reusable invites +- [x] Ensure lifecycle state mapping for reusable links blocks only on revoked/invalid, not prior successful use +- [x] Keep claim invite helper behavior unchanged +- [x] Extend schema inputs in `src/lib/invite-schemas.ts` with reusable link fetch/reset inputs +- [x] Add or adjust response parsing schemas at UI boundaries for reusable metadata + +### Phase 3: Invite Router API for Reusable Link and Rotation + +**Goal:** Provide secure, family-scoped APIs to fetch/reset reusable links and accept them repeatedly. + +#### Tasks + +- [x] Add protected query `getActiveReusableInvite` in `src/server/api/routers/invite.ts` +- [x] Validate owner/admin role in target family for reusable-link operations +- [x] Add protected mutation `resetReusableInvite` in `src/server/api/routers/invite.ts` +- [x] In reset flow, atomically revoke old active reusable invite(s), create new reusable invite with new code, and return full payload +- [x] Update `acceptInvite` in `src/server/api/routers/invite.ts`: +- [x] Reusable invite path: create user/member but do not set invite to claimed +- [x] Single-use path: keep current claim transition +- [x] Preserve duplicate-email and tenant constraints +- [x] Update `getByCode` logic so reusable invites do not return already-used after first successful acceptance +- [x] Ensure reusable invite validity does not depend on `expiresAt` checks (reset/revoke controls lifecycle) +- [x] Ensure `revokeInvite` remains valid for reusable and single-use records +- [x] Keep notification emissions consistent for reset and revoke events + +### Phase 4: Settings UI for Reusable Link Management + +**Goal:** Add explicit reusable-link controls in invite settings while preserving existing single-use invite UI. + +#### Tasks + +- [x] Keep the existing primary "Create Invite" button behavior unchanged in `src/app/(app)/settings/invites/page.tsx` +- [x] Add a shadcn `ButtonGroup` attached to the invite action area +- [x] Add dropdown trigger as the second grouped control +- [x] Add dropdown option labeled "Family Link" +- [x] Add dropdown option description text: "Invite via Family Invite Link" +- [x] Selecting "Family Link" opens/toggles the reusable-link management card (section title: "Family Invite Link") +- [x] Add reusable-link card in `src/app/(app)/settings/invites/page.tsx` (section title: "Family Invite Link") +- [x] Show active reusable link URL with copy action +- [x] Always show current/last created reusable link and lifecycle state metadata +- [x] Show "valid until reset" lifecycle messaging for active reusable links +- [x] Show usage indicators (`useCount`, `lastUsedAt`) +- [x] Show non-blocking rotation reminder for older links (for example, older than 90 days) +- [x] Add reset link action with confirmation and mutation wiring +- [x] Refresh queries on reset success and display new active link +- [x] Add first-run empty state when no reusable link has ever been created: +- [x] Message that reusable link is not created yet for this family +- [x] Primary CTA to create first reusable link +- [x] Add clear list badges for reusable, single-use, and claim invites +- [x] Update history rendering rules: +- [x] Exclude revoked reusable invite artifacts from invite history +- [ ] Include successful registrations completed through reusable links as history entries + +### Phase 5: Invite Acceptance UX and Error Messaging + +**Goal:** Ensure public invite acceptance page reflects reusable semantics correctly. + +#### Tasks + +- [x] Update invite page status/error mapping in `src/app/auth/invite/[code]/page.tsx` +- [x] Avoid already-used messaging for reusable invites +- [x] Preserve revoked/invalid messaging for reusable links and existing expired messaging for single-use invites +- [x] Preserve successful accept and sign-in flow +- [x] Verify no regressions for email-bound single-use invites + +### Phase 6: Testing, QA, and Rollout Safety + +**Goal:** Validate reusable and single-use behaviors coexist without regressions. + +#### Tasks + +- [x] Extend invite helper tests in `test/lib/invite-claim.test.ts` or new invite helper test file for reusable lifecycle +- [x] Extend router tests in `test/server/api/routers/invite.test.ts`: +- [x] Create first reusable link +- [x] Reset rotates and invalidates old code +- [x] Multiple unique users can accept same reusable code +- [x] Single-use invites still reject second acceptance +- [x] Non-admin cannot fetch/reset reusable link +- [x] Add or extend UI tests for reusable section rendering, reset action, and copy behavior (skipped per request) +- [x] Add UI tests for button-group dropdown action behavior: (skipped per request) +- [x] Primary "Create Invite" action opens current create-invite flow (skipped per request) +- [x] Dropdown "Family Link" action opens reusable-link card (skipped per request) +- [x] Add tests for first-run empty state in existing instances with no reusable link (skipped per request) +- [x] Add history tests for reusable-link behavior: (skipped per request) +- [x] Revoked reusable invite artifacts are not listed (skipped per request) +- [x] Successful registrations through reusable links are listed (skipped per request) +- [x] Run lint, typecheck, and targeted invite test suites +- [x] Perform manual QA across settings and auth routes + +Notes: +- UI tests were intentionally skipped per request. +- `pnpm lint` currently reports pre-existing baseline issues outside this PRD scope (`src/components/ui/toggle-group.tsx`, `src/components/feed/media-viewer-dialog.tsx`, and `src/lib/media-compression.ts`). +- Completion decision for this PRD is based on implemented acceptance criteria and validated reusable invite behavior; skipped UI tests were explicitly accepted. + +## Acceptance Criteria + +- [x] Invite settings keeps single-use "Create Invite" as the primary visible action +- [x] The second control in the shadcn `ButtonGroup` is a dropdown trigger +- [x] "Family Link" appears as a dropdown option and opens the "Family Invite Link" section +- [x] The "Family Link" dropdown option displays description text "Invite via Family Invite Link" +- [x] Owners/admins can view the current/last created reusable join invite link and its lifecycle state in the reusable-link card +- [x] Existing families with no reusable link see an explicit empty state and can create their first reusable link from the card +- [x] Owners/admins can reset the reusable link, and reset immediately invalidates the previous reusable code +- [x] A reusable link can successfully onboard multiple distinct users over time +- [x] Single-use join invites remain available and still become unusable after first successful acceptance +- [x] Claim-link flow for unclaimed members remains unchanged and fully functional +- [x] Reusable link remains valid until explicitly reset or revoked and does not auto-expire +- [x] Non-admin users cannot fetch or reset reusable family links +- [x] Invite listing clearly indicates reusable vs single-use vs claim invites +- [x] Reusable card shows `useCount` and `lastUsedAt` for operational visibility +- [x] Reusable card displays a non-blocking reminder when a link is old (for example, older than 90 days) +- [x] Revoked reusable invite artifacts are excluded from invite history +- [x] Successful registrations completed through reusable links appear in history +- [x] Existing invite email delivery/retry behavior for email-bound single-use invites is not regressed +- [x] Tests covering reusable lifecycle, rotation, permissions, and regressions pass diff --git a/prisma/migrations/20260713120000_reusable_family_invite_link_phase1/migration.sql b/prisma/migrations/20260713120000_reusable_family_invite_link_phase1/migration.sql new file mode 100644 index 0000000..3b39365 --- /dev/null +++ b/prisma/migrations/20260713120000_reusable_family_invite_link_phase1/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "Invite" +ADD COLUMN "isReusable" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "rotatedFromInviteId" TEXT, +ADD COLUMN "useCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "lastUsedAt" TIMESTAMP(3); + +CREATE INDEX "Invite_rotatedFromInviteId_idx" ON "Invite"("rotatedFromInviteId"); +CREATE INDEX "Invite_familyId_isReusable_status_idx" ON "Invite"("familyId", "isReusable", "status"); + +ALTER TABLE "Invite" +ADD CONSTRAINT "Invite_rotatedFromInviteId_fkey" +FOREIGN KEY ("rotatedFromInviteId") REFERENCES "Invite"("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4981341..23b78eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,31 +189,39 @@ model FamilyMember { } model Invite { - id String @id @default(cuid()) - code String @unique - type InviteType @default(OPEN) - status InviteStatus @default(PENDING) - familyId String - invitedEmail String? - createdById String - expiresAt DateTime - claimedAt DateTime? - claimedById String? - claimMemberId String? - revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) - createdBy User @relation("UserCreatedInvites", fields: [createdById], references: [id], onDelete: Cascade) - claimedBy User? @relation("UserClaimedInvites", fields: [claimedById], references: [id], onDelete: SetNull) - claimMember FamilyMember? @relation("ClaimMemberInvites", fields: [claimMemberId], references: [id], onDelete: SetNull) + id String @id @default(cuid()) + code String @unique + type InviteType @default(OPEN) + status InviteStatus @default(PENDING) + familyId String + invitedEmail String? + createdById String + expiresAt DateTime + claimedAt DateTime? + claimedById String? + claimMemberId String? + isReusable Boolean @default(false) + rotatedFromInviteId String? + useCount Int @default(0) + lastUsedAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + createdBy User @relation("UserCreatedInvites", fields: [createdById], references: [id], onDelete: Cascade) + claimedBy User? @relation("UserClaimedInvites", fields: [claimedById], references: [id], onDelete: SetNull) + claimMember FamilyMember? @relation("ClaimMemberInvites", fields: [claimMemberId], references: [id], onDelete: SetNull) + rotatedFromInvite Invite? @relation("InviteRotation", fields: [rotatedFromInviteId], references: [id], onDelete: SetNull) + rotatedToInvites Invite[] @relation("InviteRotation") @@index([familyId]) @@index([status]) @@index([expiresAt]) @@index([invitedEmail]) @@index([claimMemberId]) + @@index([rotatedFromInviteId]) + @@index([familyId, isReusable, status]) } model Post { diff --git a/src/app/(app)/members/page.tsx b/src/app/(app)/members/page.tsx index d37622c..d7f4869 100644 --- a/src/app/(app)/members/page.tsx +++ b/src/app/(app)/members/page.tsx @@ -1,12 +1,19 @@ "use client"; import Link from "next/link"; -import { Users } from "~/components/ui/icons"; +import { More, Users, Plus } from "~/components/ui/icons"; import { useMemo, useState } from "react"; import { formatDistanceToNow } from "date-fns"; import { MemberCard } from "~/components/members/member-card"; import { Button } from "~/components/ui/button"; +import { ButtonGroup, ButtonGroupSeparator } from "~/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; import { Input } from "~/components/ui/input"; import { Skeleton } from "~/components/ui/skeleton"; import type { FamilyMemberStatus } from "~/lib/mocks/family-members"; @@ -101,9 +108,38 @@ export default function MembersPage() { {canManageMembers ? ( - + + + + + + + + + + +
+

Invite Members

+

+ Add members through shareable invite links +

+
+ +
+
+
+
) : null} diff --git a/src/app/(app)/settings/invites/page.tsx b/src/app/(app)/settings/invites/page.tsx index ee4b6d3..de5e903 100644 --- a/src/app/(app)/settings/invites/page.tsx +++ b/src/app/(app)/settings/invites/page.tsx @@ -13,16 +13,26 @@ import { ShieldAlert, TriangleAlert, X, + More } from "~/components/ui/icons"; import { z } from "zod"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; +import { ButtonGroup, ButtonGroupSeparator } from "~/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; import { Input } from "~/components/ui/input"; +import { reusableInviteSummarySchema } from "~/lib/invite-schemas"; import { cn } from "~/lib/utils"; import { api } from "~/trpc/react"; type LifecycleState = "valid" | "expired" | "claimed" | "revoked"; +type ReusableLifecycleState = "valid" | "revoked" | "invalid"; const managementContextSchema = z.object({ family: z @@ -39,6 +49,7 @@ const inviteListItemSchema = z.object({ id: z.string(), code: z.string(), type: z.enum(["OPEN", "EMAIL_BOUND"]), + isReusable: z.boolean().optional().default(false), isClaimInvite: z.boolean(), claimMember: z .object({ @@ -89,6 +100,12 @@ const createdInviteSchema = z.object({ emailDelivery: emailDeliveryResultSchema, }); +const reusableStatusBadgeStyles: Record = { + valid: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + revoked: "border-destructive/30 bg-destructive/10 text-destructive", + invalid: "border-muted-foreground/20 bg-muted text-muted-foreground", +}; + const statusBadgeStyles: Record = { valid: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", claimed: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", @@ -130,6 +147,18 @@ function formatStatus(status: LifecycleState) { return status[0]!.toUpperCase() + status.slice(1); } +function formatReusableStatus(status: ReusableLifecycleState) { + if (status === "valid") { + return "Active"; + } + + if (status === "revoked") { + return "Revoked"; + } + + return "Unavailable"; +} + function formatDate(value: Date | string | null) { if (!value) { return "Never"; @@ -143,9 +172,14 @@ function formatDate(value: Date | string | null) { }); } +function formatActorName(name: string | null | undefined, email: string | null | undefined) { + return name ?? email ?? "Someone"; +} + export default function InvitesPage() { const trpcUtils = api.useUtils(); const [showCreatePanel, setShowCreatePanel] = useState(false); + const [showFamilyLinkPanel, setShowFamilyLinkPanel] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); const [expiry, setExpiry] = useState(14); const [generatedLink, setGeneratedLink] = useState(null); @@ -160,6 +194,8 @@ export default function InvitesPage() { message?: string; } | null>(null); const [createdInviteId, setCreatedInviteId] = useState(null); + const [familyLinkError, setFamilyLinkError] = useState(null); + const [familyLinkCopyKey, setFamilyLinkCopyKey] = useState(null); const managementContext = api.family.getManagementContext.useQuery(undefined, { retry: false, @@ -187,6 +223,15 @@ export default function InvitesPage() { }, ); + const reusableInviteQuery = api.invite.getActiveReusableInvite.useQuery( + { familyId: selectedFamilyId ?? "" }, + { + enabled: Boolean(selectedFamilyId) && canManageInvites, + refetchOnWindowFocus: false, + retry: false, + }, + ); + const createInvite = api.invite.createInvite.useMutation({ onSuccess: async (createdInviteResult) => { const parsedCreatedInvite = createdInviteSchema.safeParse(createdInviteResult); @@ -230,23 +275,102 @@ export default function InvitesPage() { }, }); + const resetReusableInvite = api.invite.resetReusableInvite.useMutation({ + onSuccess: async (result) => { + const parsedReusableInvite = reusableInviteSummarySchema.safeParse(result); + if (!parsedReusableInvite.success) { + setFamilyLinkError("Family invite link was reset, but response parsing failed."); + return; + } + + setFamilyLinkError(null); + setShowFamilyLinkPanel(true); + await Promise.all([ + trpcUtils.invite.getActiveReusableInvite.invalidate(), + trpcUtils.invite.listInvites.invalidate(), + ]); + }, + onError: (error) => { + setFamilyLinkError(error.message); + }, + }); + const invites = useMemo(() => { const parsedInvites = inviteListSchema.safeParse(invitesQuery.data); return parsedInvites.success ? parsedInvites.data : []; }, [invitesQuery.data]); + const reusableInvite = useMemo(() => { + const parsedReusableInvite = reusableInviteSummarySchema.nullable().safeParse( + reusableInviteQuery.data ?? null, + ); + return parsedReusableInvite.success ? parsedReusableInvite.data : null; + }, [reusableInviteQuery.data]); + const familyLinkPanelVisible = showFamilyLinkPanel; const historyInvites = useMemo( - () => invites.filter((invite) => invite.lifecycleState !== "valid"), + () => invites.filter((invite) => !invite.isReusable && invite.lifecycleState !== "valid"), [invites], ); const pendingInvites = useMemo( - () => invites.filter((invite) => invite.lifecycleState === "valid"), + () => invites.filter((invite) => !invite.isReusable && invite.lifecycleState === "valid"), [invites], ); + const reusableHistoryItems = useMemo(() => { + if (!reusableInvite || reusableInvite.useCount === 0) { + return []; + } + + return [ + { + id: `${reusableInvite.id}:history`, + activityAt: reusableInvite.lastUsedAt ?? reusableInvite.createdAt, + title: "Someone joined via Family Invite Link", + subtitle: reusableInvite.lastUsedAt + ? `Last used ${formatDate(reusableInvite.lastUsedAt)} · Total joins ${reusableInvite.useCount}` + : `Created ${formatDate(reusableInvite.createdAt)} · Total joins ${reusableInvite.useCount}`, + status: reusableInvite.lifecycleState, + }, + ]; + }, [reusableInvite]); + const visiblePendingInvites = pendingInvites; - const visibleHistoryInvites = historyInvites; + const visibleHistoryInvites = useMemo(() => { + return [...historyInvites].sort((a, b) => { + const aTimestamp = (a.claimedAt ?? a.createdAt).getTime(); + const bTimestamp = (b.claimedAt ?? b.createdAt).getTime(); + return bTimestamp - aTimestamp; + }); + }, [historyInvites]); + const combinedHistoryItems = useMemo(() => { + const inviteHistoryItems = visibleHistoryInvites.map((invite) => ({ + kind: "invite" as const, + id: invite.id, + type: invite.type, + isClaimInvite: invite.isClaimInvite, + invitedEmail: invite.invitedEmail, + createdAt: invite.createdAt, + claimedBy: invite.claimedBy, + lifecycleState: invite.lifecycleState, + activityAt: invite.claimedAt ?? invite.createdAt, + })); + + const familyLinkHistoryItems = reusableHistoryItems.map((item) => ({ + kind: "family-link" as const, + id: item.id, + title: item.title, + subtitle: item.subtitle, + status: item.status, + activityAt: item.activityAt, + })); + + return [...inviteHistoryItems, ...familyLinkHistoryItems].sort( + (a, b) => b.activityAt.getTime() - a.activityAt.getTime(), + ); + }, [reusableHistoryItems, visibleHistoryInvites]); const showAllEmptyState = - visiblePendingInvites.length === 0 && visibleHistoryInvites.length === 0; + visiblePendingInvites.length === 0 && + combinedHistoryItems.length === 0 && + reusableInvite === null; async function copyText(key: string, value: string) { try { @@ -269,6 +393,11 @@ export default function InvitesPage() { setCreatedInviteId(null); } + function closeFamilyLinkPanel() { + setShowFamilyLinkPanel(false); + setFamilyLinkError(null); + } + async function handleGenerateInvite(e: React.FormEvent) { e.preventDefault(); if (!selectedFamilyId) { @@ -286,6 +415,32 @@ export default function InvitesPage() { }); } + async function handleResetFamilyLink() { + if (!selectedFamilyId) { + setFamilyLinkError("No family context was found for your account."); + return; + } + + await resetReusableInvite.mutateAsync({ familyId: selectedFamilyId }); + } + + async function handleCopyFamilyLink(value: string) { + try { + await navigator.clipboard.writeText(value); + setFamilyLinkCopyKey(value); + window.setTimeout(() => setFamilyLinkCopyKey(null), 1200); + } catch { + setFamilyLinkCopyKey(null); + } + } + + const familyInviteLinkUrl = reusableInvite ? buildInviteLink(reusableInvite.code) : null; + const familyLinkAgeDays = reusableInvite + ? Math.floor((Date.now() - new Date(reusableInvite.createdAt).getTime()) / (1000 * 60 * 60 * 24)) + : 0; + const showFamilyLinkRotationReminder = + reusableInvite?.lifecycleState === "valid" && familyLinkAgeDays >= 90; + return (
@@ -297,14 +452,47 @@ export default function InvitesPage() {
- + + + + + + + + + { + setShowFamilyLinkPanel(true); + setShowCreatePanel(false); + }} + > +
+

Family Link

+

Invite via Family Invite Link

+
+
+
+
+
@@ -337,6 +525,132 @@ export default function InvitesPage() { ) : null} + {canManageInvites && familyLinkPanelVisible ? ( +
+
+
+

Family Invite Link

+

+ Keep one reusable family onboarding link active until you intentionally reset it. +

+
+ +
+ + {reusableInviteQuery.isLoading ? ( +

+ + Loading family invite link... +

+ ) : reusableInvite ? ( +
+
+ + {formatReusableStatus(reusableInvite.lifecycleState)} + + + Family Link + + + Created {formatDate(reusableInvite.createdAt)} + +
+ +
+

Current or last created family invite link

+ {familyInviteLinkUrl ? ( +
+ + +
+ ) : null} +

+ {reusableInvite.lifecycleState === "valid" + ? "Valid until reset" + : "This family invite link is no longer active. Reset to create a new one."} +

+
+ +
+
+

Usage

+

+ {reusableInvite.useCount === 1 ? "Used 1 time" : `Used ${reusableInvite.useCount} times`} +

+
+
+

Last used

+

{formatDate(reusableInvite.lastUsedAt)}

+
+
+ + {showFamilyLinkRotationReminder ? ( +
+
+ +
+

Consider rotating this link

+

+ This family invite link is over 90 days old. Resetting it will invalidate older shares and create a fresh link. +

+
+
+
+ ) : null} + +
+ +
+
+ ) : ( +
+

No family invite link yet.

+

+ Create your first reusable family invite link to onboard multiple people with one shareable link. +

+
+ +
+
+ )} + + {familyLinkError ?

{familyLinkError}

: null} +
+ ) : null} + {showCreatePanel && canManageInvites ? (
@@ -524,7 +838,11 @@ export default function InvitesPage() { {visiblePendingInvites.map((invite) => { const inviteLink = buildPendingInviteLink(invite.code, invite.isClaimInvite); const copyKey = `pending:${invite.id}`; - const purposeLabel = invite.isClaimInvite ? "Claim link" : "Open invite"; + const purposeLabel = invite.isClaimInvite + ? "Claim link" + : invite.isReusable + ? "Family Link" + : "Open invite"; const claimForLabel = invite.claimMember?.name ?? "Profile unavailable"; return ( @@ -645,37 +963,72 @@ export default function InvitesPage() { Loading invite history...

- ) : visibleHistoryInvites.length === 0 ? ( + ) : combinedHistoryItems.length === 0 ? (

No invite history yet.

) : (
    - {visibleHistoryInvites.map((invite) => ( -
  • -
    -

    - {invite.invitedEmail ?? "No email specified"} -

    -

    - Created {formatDate(invite.createdAt)} - {invite.claimedBy - ? ` · Accepted by ${invite.claimedBy.name ?? invite.claimedBy.email ?? "Unknown"}` - : ""} -

    -
    + {combinedHistoryItems.map((item) => { + if (item.kind === "invite") { + return ( +
  • +
    +

    + {item.claimedBy ? ( + item.isClaimInvite ? ( + `${formatActorName(item.claimedBy.name, item.claimedBy.email)} claimed their member profile` + ) : item.type === "OPEN" ? ( + `${formatActorName(item.claimedBy.name, item.claimedBy.email)} joined via open invite` + ) : ( + `${formatActorName(item.claimedBy.name, item.claimedBy.email)} claimed their invite` + ) + ) : ( + item.invitedEmail ?? "Invite updated" + )} +

    +

    + Created {formatDate(item.createdAt)} + {item.claimedBy + ? ` · Accepted by ${item.claimedBy.name ?? item.claimedBy.email ?? "Unknown"}` + : ""} +

    +
    - + {formatStatus(item.lifecycleState)} + +
  • + ); + } + + return ( +
  • - {formatStatus(invite.lifecycleState)} - -
  • - ))} +
    +

    {item.title}

    +

    {item.subtitle}

    +
    + + + Family Link + + + ); + })}
)}
diff --git a/src/app/auth/invite/[code]/page.tsx b/src/app/auth/invite/[code]/page.tsx index f55dda8..376b99c 100644 --- a/src/app/auth/invite/[code]/page.tsx +++ b/src/app/auth/invite/[code]/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { AlertCircle, UserRoundPlus } from "~/components/ui/icons"; +import { AlertCircle } from "~/components/ui/icons"; import { signIn } from "next-auth/react"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; @@ -11,7 +11,7 @@ import { beginNavigationProgress } from "~/components/nav/navigation-progress"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; -import { formatFamilyDisplayName } from "~/lib/family-name"; +import { formatFamilyLockup } from "~/lib/family-name"; import { api } from "~/trpc/react"; type InvitePageState = "loading" | "valid" | "expired" | "claimed" | "revoked" | "invalid"; @@ -64,6 +64,12 @@ function mapAcceptInviteErrorMessage(message: string): string { return "Could not accept invite. Please try again."; } +function getInviteIntro(isReusable: boolean) { + return isReusable + ? "Use this family invite link to join and create your account." + : "Use this invite link to accept and create your account."; +} + function getInviteStateMessage(state: InvitePageState): string { if (state === "expired") return "This invite has expired. Please request a new one."; if (state === "claimed") return "This invite has already been used."; @@ -107,6 +113,7 @@ export default function InviteAcceptancePage() { const stateErrorMessage = queryState === "valid" || queryState === "loading" ? null : getInviteStateMessage(queryState); + const isReusableInvite = inviteQuery.data?.isReusable === true; const errorMessage = formError ?? @@ -175,47 +182,21 @@ export default function InviteAcceptancePage() {

- You're invited to join a family + {isReusableInvite ? "You’re joining through a family invite link" : "You’re invited to join a family"}

- Use invite code {params.code} to accept and create your account. + {inviteQuery.isLoading + ? "Checking invite details..." + : inviteQuery.data + ? ( + <> + Joining {formatFamilyLockup(inviteQuery.data.family.name)}. {getInviteIntro(isReusableInvite)} + + ) + : getInviteStateMessage(queryState)}

-
-
-
-
-
- {inviteQuery.isLoading ? ( - <> -

Checking invite details...

-

Loading family

- - ) : inviteQuery.data ? ( - <> -

Valid invite

-

{formatFamilyDisplayName(inviteQuery.data.family.name)}

- {inviteQuery.data.family.description ? ( -

- {inviteQuery.data.family.description} -

- ) : null} - - ) : ( - <> -

Invite status

-

{queryState}

-

- {getInviteStateMessage(queryState)} -

- - )} -
-
-
- {errorMessage ? (