From e2614d436810e296811ae2b0e0e69e89111ba8e2 Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:14:13 +0100 Subject: [PATCH 01/17] chore(prd): add prd to implement reusable family onboarding invite link with reset rotation --- .../prd.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md 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..9503f82 --- /dev/null +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -0,0 +1,210 @@ +--- +title: "Reusable Family Onboarding Invite Link with Reset Rotation" +status: draft +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" +--- + +> **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. +- Priority-first entry UX: Single-use "Create Invite" remains the primary visible action; "Family Link" is exposed as a secondary action in a dropdown menu to de-emphasize reusable-link usage. +- 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 + +- [ ] Update Invite model in `prisma/schema.prisma` with reusable-link support fields: +- [ ] Add `isReusable` boolean (default false) +- [ ] Add `rotatedFromInviteId` nullable self-reference (recommended) +- [ ] Add `useCount` integer default 0 (recommended) +- [ ] Add `lastUsedAt` nullable timestamp (recommended) +- [ ] Add optional `createdAt`-based rotation reminder support (for example UI threshold at 90 days) +- [ ] Add indexes to support fast lookup of active reusable invite by family (`familyId`, `isReusable`, `status`) +- [ ] Create and apply migration for new fields/indexes +- [ ] 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 + +- [ ] Extend lifecycle/usability helpers in `src/lib/invite.ts` to account for reusable invites +- [ ] Ensure lifecycle state mapping for reusable links blocks only on revoked/invalid, not prior successful use +- [ ] Keep claim invite helper behavior unchanged +- [ ] Extend schema inputs in `src/lib/invite-schemas.ts` with reusable link fetch/reset inputs +- [ ] 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 + +- [ ] Add protected query `getActiveReusableInvite` in `src/server/api/routers/invite.ts` +- [ ] Validate owner/admin role in target family for reusable-link operations +- [ ] Add protected mutation `resetReusableInvite` in `src/server/api/routers/invite.ts` +- [ ] In reset flow, atomically revoke old active reusable invite(s), create new reusable invite with new code, and return full payload +- [ ] Update `acceptInvite` in `src/server/api/routers/invite.ts`: +- [ ] Reusable invite path: create user/member but do not set invite to claimed +- [ ] Single-use path: keep current claim transition +- [ ] Preserve duplicate-email and tenant constraints +- [ ] Update `getByCode` logic so reusable invites do not return already-used after first successful acceptance +- [ ] Ensure reusable invite validity does not depend on `expiresAt` checks (reset/revoke controls lifecycle) +- [ ] Ensure `revokeInvite` remains valid for reusable and single-use records +- [ ] 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 + +- [ ] Keep the existing primary "Create Invite" button behavior unchanged in `src/app/(app)/settings/invites/page.tsx` +- [ ] Add a dropdown menu attached to the invite action area +- [ ] Add dropdown option labeled "Family Link" as a secondary action +- [ ] Add dropdown option description text: "Invite via Family Invite Link" +- [ ] Selecting "Family Link" opens/toggles the reusable-link management card (section title: "Family Invite Link") +- [ ] Add reusable-link card in `src/app/(app)/settings/invites/page.tsx` (section title: "Family Invite Link") +- [ ] Show active reusable link URL with copy action +- [ ] Always show current/last created reusable link and lifecycle state metadata +- [ ] Show "valid until reset" lifecycle messaging for active reusable links +- [ ] Show usage indicators (`useCount`, `lastUsedAt`) +- [ ] Show non-blocking rotation reminder for older links (for example, older than 90 days) +- [ ] Add reset link action with confirmation and mutation wiring +- [ ] Refresh queries on reset success and display new active link +- [ ] Add first-run empty state when no reusable link has ever been created: +- [ ] Message that reusable link is not created yet for this family +- [ ] Primary CTA to create first reusable link +- [ ] Add clear list badges for reusable, single-use, and claim invites +- [ ] Update history rendering rules: +- [ ] 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 + +- [ ] Update invite page status/error mapping in `src/app/auth/invite/[code]/page.tsx` +- [ ] Avoid already-used messaging for reusable invites +- [ ] Preserve revoked/invalid messaging for reusable links and existing expired messaging for single-use invites +- [ ] Preserve successful accept and sign-in flow +- [ ] 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 + +- [ ] Extend invite helper tests in `test/lib/invite-claim.test.ts` or new invite helper test file for reusable lifecycle +- [ ] Extend router tests in `test/server/api/routers/invite.test.ts`: +- [ ] Create first reusable link +- [ ] Reset rotates and invalidates old code +- [ ] Multiple unique users can accept same reusable code +- [ ] Single-use invites still reject second acceptance +- [ ] Non-admin cannot fetch/reset reusable link +- [ ] Add or extend UI tests for reusable section rendering, reset action, and copy behavior +- [ ] Add UI tests for primary/dropdown action behavior: +- [ ] Primary "Create Invite" action opens current create-invite flow +- [ ] Dropdown "Family Link" action opens reusable-link card +- [ ] Add tests for first-run empty state in existing instances with no reusable link +- [ ] Add history tests for reusable-link behavior: +- [ ] Revoked reusable invite artifacts are not listed +- [ ] Successful registrations through reusable links are listed +- [ ] Run lint, typecheck, and targeted invite test suites +- [ ] Perform manual QA across settings and auth routes + +## Acceptance Criteria + +- [ ] Invite settings keeps single-use "Create Invite" as the primary visible action +- [ ] "Family Link" appears as a secondary dropdown option and opens the "Family Invite Link" section +- [ ] The "Family Link" dropdown option displays description text "Invite via Family Invite Link" +- [ ] Owners/admins can view the current/last created reusable join invite link and its lifecycle state in the reusable-link card +- [ ] Existing families with no reusable link see an explicit empty state and can create their first reusable link from the card +- [ ] Owners/admins can reset the reusable link, and reset immediately invalidates the previous reusable code +- [ ] A reusable link can successfully onboard multiple distinct users over time +- [ ] Single-use join invites remain available and still become unusable after first successful acceptance +- [ ] Claim-link flow for unclaimed members remains unchanged and fully functional +- [ ] Reusable link remains valid until explicitly reset or revoked and does not auto-expire +- [ ] Non-admin users cannot fetch or reset reusable family links +- [ ] Invite listing clearly indicates reusable vs single-use vs claim invites +- [ ] Reusable card shows `useCount` and `lastUsedAt` for operational visibility +- [ ] Reusable card displays a non-blocking reminder when a link is old (for example, older than 90 days) +- [ ] Revoked reusable invite artifacts are excluded from invite history +- [ ] Successful registrations completed through reusable links appear in history +- [ ] Existing invite email delivery/retry behavior for email-bound single-use invites is not regressed +- [ ] Tests covering reusable lifecycle, rotation, permissions, and regressions pass From b421d8e4165aad359b86ff2e82135b43651d835f Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:18:02 +0100 Subject: [PATCH 02/17] chore(prd): add reference to implementation PR for @all member mention feature --- .project/prds/031-all-member-mention/prd.md | 3 +++ 1 file changed, 3 insertions(+) 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:** From 844b142dac7820637acc4369265810b57892f75e Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:19:58 +0100 Subject: [PATCH 03/17] feat: implement reusable family onboarding invite link with reset rotation --- .../prd.md | 18 ++++---- .../migration.sql | 12 +++++ prisma/schema.prisma | 46 +++++++++++-------- 3 files changed, 48 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/20260713120000_reusable_family_invite_link_phase1/migration.sql 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 index 9503f82..2fa627e 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -1,6 +1,6 @@ --- title: "Reusable Family Onboarding Invite Link with Reset Rotation" -status: draft +status: in-progress references: - type: doc url: .project/brief.md @@ -85,14 +85,14 @@ Out of scope: #### Tasks - [ ] Update Invite model in `prisma/schema.prisma` with reusable-link support fields: -- [ ] Add `isReusable` boolean (default false) -- [ ] Add `rotatedFromInviteId` nullable self-reference (recommended) -- [ ] Add `useCount` integer default 0 (recommended) -- [ ] Add `lastUsedAt` nullable timestamp (recommended) -- [ ] Add optional `createdAt`-based rotation reminder support (for example UI threshold at 90 days) -- [ ] Add indexes to support fast lookup of active reusable invite by family (`familyId`, `isReusable`, `status`) -- [ ] Create and apply migration for new fields/indexes -- [ ] Confirm existing invite and claim queries remain compatible +- [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 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 { From 81ebffaf1da3d5b0614b649793fb68bf1c89adc3 Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:24:40 +0100 Subject: [PATCH 04/17] feat: implement reusable invite functionality with lifecycle management and validation schemas --- .../prd.md | 12 +-- src/lib/invite-schemas.ts | 36 ++++++- src/lib/invite.ts | 33 +++++++ test/lib/invite-claim.test.ts | 95 +++++++++++++++++++ test/lib/invite-schemas.test.ts | 84 ++++++++++++++++ 5 files changed, 253 insertions(+), 7 deletions(-) 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 index 2fa627e..54c3be5 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -84,7 +84,7 @@ Out of scope: #### Tasks -- [ ] Update Invite model in `prisma/schema.prisma` with reusable-link support fields: +- [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) @@ -100,11 +100,11 @@ Out of scope: #### Tasks -- [ ] Extend lifecycle/usability helpers in `src/lib/invite.ts` to account for reusable invites -- [ ] Ensure lifecycle state mapping for reusable links blocks only on revoked/invalid, not prior successful use -- [ ] Keep claim invite helper behavior unchanged -- [ ] Extend schema inputs in `src/lib/invite-schemas.ts` with reusable link fetch/reset inputs -- [ ] Add or adjust response parsing schemas at UI boundaries for reusable metadata +- [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 diff --git a/src/lib/invite-schemas.ts b/src/lib/invite-schemas.ts index d60ea1a..158e166 100644 --- a/src/lib/invite-schemas.ts +++ b/src/lib/invite-schemas.ts @@ -1,7 +1,12 @@ import { z } from "zod" import { normalizeEmail } from "~/lib/email" -import { INVITE_DEFAULT_TTL_DAYS, INVITE_TYPES, CLAIM_DEFAULT_TTL_DAYS } from "~/lib/invite" +import { + INVITE_DEFAULT_TTL_DAYS, + INVITE_TYPES, + INVITE_STATUSES, + CLAIM_DEFAULT_TTL_DAYS, +} from "~/lib/invite" export const inviteCodeSchema = z .string() @@ -62,6 +67,35 @@ export const retryEmailSendInputSchema = z.object({ export type RetryEmailSendInput = z.infer +export const reusableInviteLifecycleStateSchema = z.enum(["valid", "revoked", "invalid"]) + +export const getActiveReusableInviteInputSchema = z.object({ + familyId: z.string().cuid(), +}) + +export const resetReusableInviteInputSchema = z.object({ + familyId: z.string().cuid(), +}) + +export const reusableInviteSummarySchema = z.object({ + id: z.string(), + code: z.string(), + familyId: z.string(), + isReusable: z.literal(true), + status: z.enum(INVITE_STATUSES), + lifecycleState: reusableInviteLifecycleStateSchema, + createdAt: z.date(), + updatedAt: z.date(), + revokedAt: z.date().nullable(), + rotatedFromInviteId: z.string().nullable(), + useCount: z.number().int().nonnegative(), + lastUsedAt: z.date().nullable(), +}) + +export type GetActiveReusableInviteInput = z.infer +export type ResetReusableInviteInput = z.infer +export type ReusableInviteSummary = z.infer + // ─── Claim schemas ──────────────────────────────────────────────────────────── const internalMediaUrlSchema = z diff --git a/src/lib/invite.ts b/src/lib/invite.ts index 9c9c481..539bddd 100644 --- a/src/lib/invite.ts +++ b/src/lib/invite.ts @@ -12,6 +12,7 @@ export type InviteTypeValue = (typeof INVITE_TYPES)[number] export type InviteStatusValue = (typeof INVITE_STATUSES)[number] export type InviteLifecycleState = "valid" | "expired" | "claimed" | "revoked" +export type ReusableInviteLifecycleState = "valid" | "revoked" | "invalid" /** All states a looked-up claim link can be in, including "not found". */ export type ClaimLifecycleState = "valid" | "expired" | "claimed" | "revoked" | "invalid" @@ -23,6 +24,12 @@ export type InviteLifecycleRecord = { revokedAt: Date | null } +export type ReusableInviteLifecycleRecord = { + isReusable: boolean + status: InviteStatusValue + revokedAt: Date | null +} + export type InviteBindingRecord = { type: InviteTypeValue invitedEmail: string | null @@ -91,6 +98,32 @@ export function isInviteUsable( return invite.status === "PENDING" && getInviteLifecycleState(invite, now) === "valid" } +export function isReusableInvite(invite: Pick): boolean { + return invite.isReusable +} + +export function getReusableInviteLifecycleState( + invite: ReusableInviteLifecycleRecord | null, +): ReusableInviteLifecycleState { + if (!invite || !invite.isReusable) { + return "invalid" + } + + if (invite.revokedAt || invite.status === "REVOKED") { + return "revoked" + } + + if (invite.status !== "PENDING") { + return "invalid" + } + + return "valid" +} + +export function isReusableInviteUsable(invite: ReusableInviteLifecycleRecord | null): boolean { + return getReusableInviteLifecycleState(invite) === "valid" +} + export function validateInviteEmailBinding( invite: InviteBindingRecord, email: string, diff --git a/test/lib/invite-claim.test.ts b/test/lib/invite-claim.test.ts index 237bcc4..93ae0f3 100644 --- a/test/lib/invite-claim.test.ts +++ b/test/lib/invite-claim.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect } from "vitest"; import { + isReusableInvite, isClaimInvite, isMemberAlreadyClaimed, getClaimLifecycleState, + getReusableInviteLifecycleState, + isReusableInviteUsable, validateClaimEmailBinding, generateClaimToken, getInviteExpiryDate, @@ -38,6 +41,18 @@ describe("isClaimInvite", () => { }); }); +// ─── isReusableInvite ───────────────────────────────────────────────────────── + +describe("isReusableInvite", () => { + it("returns true when isReusable is true", () => { + expect(isReusableInvite({ isReusable: true })).toBe(true); + }); + + it("returns false when isReusable is false", () => { + expect(isReusableInvite({ isReusable: false })).toBe(false); + }); +}); + // ─── isMemberAlreadyClaimed ──────────────────────────────────────────────────── describe("isMemberAlreadyClaimed", () => { @@ -102,6 +117,86 @@ describe("getClaimLifecycleState", () => { }); }); +// ─── getReusableInviteLifecycleState ────────────────────────────────────────── + +describe("getReusableInviteLifecycleState", () => { + it('returns "invalid" when invite is null', () => { + expect(getReusableInviteLifecycleState(null)).toBe("invalid"); + }); + + it('returns "invalid" when invite is not marked reusable', () => { + expect( + getReusableInviteLifecycleState({ + isReusable: false, + status: "PENDING", + revokedAt: null, + }), + ).toBe("invalid"); + }); + + it('returns "valid" for a pending reusable invite', () => { + expect( + getReusableInviteLifecycleState({ + isReusable: true, + status: "PENDING", + revokedAt: null, + }), + ).toBe("valid"); + }); + + it('returns "revoked" when revokedAt is set', () => { + expect( + getReusableInviteLifecycleState({ + isReusable: true, + status: "PENDING", + revokedAt: new Date(), + }), + ).toBe("revoked"); + }); + + it('returns "revoked" when status is REVOKED', () => { + expect( + getReusableInviteLifecycleState({ + isReusable: true, + status: "REVOKED", + revokedAt: null, + }), + ).toBe("revoked"); + }); + + it('returns "invalid" for non-pending reusable invite states', () => { + expect( + getReusableInviteLifecycleState({ + isReusable: true, + status: "CLAIMED", + revokedAt: null, + }), + ).toBe("invalid"); + }); +}); + +describe("isReusableInviteUsable", () => { + it("returns true only for valid reusable invites", () => { + expect( + isReusableInviteUsable({ + isReusable: true, + status: "PENDING", + revokedAt: null, + }), + ).toBe(true); + }); + + it("returns false for revoked reusable invites", () => { + expect( + isReusableInviteUsable({ + isReusable: true, + status: "REVOKED", + revokedAt: null, + }), + ).toBe(false); + }); +}); + // ─── validateClaimEmailBinding ───────────────────────────────────────────────── describe("validateClaimEmailBinding", () => { diff --git a/test/lib/invite-schemas.test.ts b/test/lib/invite-schemas.test.ts index 1ee46ed..3d99ad6 100644 --- a/test/lib/invite-schemas.test.ts +++ b/test/lib/invite-schemas.test.ts @@ -3,6 +3,9 @@ import { createUnclaimedMemberInputSchema, createClaimLinkInputSchema, claimMemberInputSchema, + getActiveReusableInviteInputSchema, + resetReusableInviteInputSchema, + reusableInviteSummarySchema, } from "~/lib/invite-schemas"; import { CLAIM_DEFAULT_TTL_DAYS } from "~/lib/invite"; @@ -203,3 +206,84 @@ describe("claimMemberInputSchema", () => { expect(result.success).toBe(false); }); }); + +// ─── reusable invite schemas ────────────────────────────────────────────────── + +describe("getActiveReusableInviteInputSchema", () => { + it("accepts a valid familyId", () => { + const result = getActiveReusableInviteInputSchema.safeParse({ + familyId: VALID_CUID, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing familyId", () => { + const result = getActiveReusableInviteInputSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe("resetReusableInviteInputSchema", () => { + it("accepts a valid familyId", () => { + const result = resetReusableInviteInputSchema.safeParse({ + familyId: VALID_CUID, + }); + expect(result.success).toBe(true); + }); +}); + +describe("reusableInviteSummarySchema", () => { + it("accepts valid reusable invite metadata for UI parsing", () => { + const result = reusableInviteSummarySchema.safeParse({ + id: "invite-1", + code: "A".repeat(24), + familyId: VALID_CUID, + isReusable: true, + status: "PENDING", + lifecycleState: "valid", + createdAt: new Date(), + updatedAt: new Date(), + revokedAt: null, + rotatedFromInviteId: null, + useCount: 3, + lastUsedAt: new Date(), + }); + expect(result.success).toBe(true); + }); + + it("rejects non-reusable payloads", () => { + const result = reusableInviteSummarySchema.safeParse({ + id: "invite-1", + code: "A".repeat(24), + familyId: VALID_CUID, + isReusable: false, + status: "PENDING", + lifecycleState: "valid", + createdAt: new Date(), + updatedAt: new Date(), + revokedAt: null, + rotatedFromInviteId: null, + useCount: 0, + lastUsedAt: null, + }); + expect(result.success).toBe(false); + }); + + it("rejects negative useCount", () => { + const result = reusableInviteSummarySchema.safeParse({ + id: "invite-1", + code: "A".repeat(24), + familyId: VALID_CUID, + isReusable: true, + status: "PENDING", + lifecycleState: "valid", + createdAt: new Date(), + updatedAt: new Date(), + revokedAt: null, + rotatedFromInviteId: null, + useCount: -1, + lastUsedAt: null, + }); + expect(result.success).toBe(false); + }); +}); From 9133ad69881b6b5c8d84a8007e538b25cf5ba46d Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:28:26 +0100 Subject: [PATCH 05/17] feat: add reusable invite management with active invite retrieval and reset functionality --- .../prd.md | 24 +- src/server/api/routers/invite.ts | 302 ++++++++++++++++-- test/server/api/routers/invite.test.ts | 245 ++++++++++++++ 3 files changed, 535 insertions(+), 36 deletions(-) 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 index 54c3be5..e770046 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -112,18 +112,18 @@ Out of scope: #### Tasks -- [ ] Add protected query `getActiveReusableInvite` in `src/server/api/routers/invite.ts` -- [ ] Validate owner/admin role in target family for reusable-link operations -- [ ] Add protected mutation `resetReusableInvite` in `src/server/api/routers/invite.ts` -- [ ] In reset flow, atomically revoke old active reusable invite(s), create new reusable invite with new code, and return full payload -- [ ] Update `acceptInvite` in `src/server/api/routers/invite.ts`: -- [ ] Reusable invite path: create user/member but do not set invite to claimed -- [ ] Single-use path: keep current claim transition -- [ ] Preserve duplicate-email and tenant constraints -- [ ] Update `getByCode` logic so reusable invites do not return already-used after first successful acceptance -- [ ] Ensure reusable invite validity does not depend on `expiresAt` checks (reset/revoke controls lifecycle) -- [ ] Ensure `revokeInvite` remains valid for reusable and single-use records -- [ ] Keep notification emissions consistent for reset and revoke events +- [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 diff --git a/src/server/api/routers/invite.ts b/src/server/api/routers/invite.ts index a50c1f5..302f756 100644 --- a/src/server/api/routers/invite.ts +++ b/src/server/api/routers/invite.ts @@ -13,14 +13,19 @@ import { generateInviteCode, getInviteExpiryDate, getInviteLifecycleState, + getReusableInviteLifecycleState, isInviteUsable, + isReusableInvite, + isReusableInviteUsable, validateInviteEmailBinding, } from "~/lib/invite" import { + getActiveReusableInviteInputSchema, inviteLookupInputSchema, inviteAcceptInputSchema, inviteCreateInputSchema, inviteRevokeInputSchema, + resetReusableInviteInputSchema, retryEmailSendInputSchema, } from "~/lib/invite-schemas" import { normalizeEmail } from "~/lib/email" @@ -44,7 +49,88 @@ import { getClaimedAdminMemberIds, } from "~/server/notifications" +const REUSABLE_INVITE_FUTURE_EXPIRY = new Date("2999-12-31T00:00:00.000Z") + +function buildReusableInviteSummary(invite: { + id: string + code: string + familyId: string + isReusable: boolean + status: "PENDING" | "CLAIMED" | "EXPIRED" | "REVOKED" + createdAt: Date + updatedAt: Date + revokedAt: Date | null + rotatedFromInviteId: string | null + useCount: number + lastUsedAt: Date | null +}) { + return { + id: invite.id, + code: invite.code, + familyId: invite.familyId, + isReusable: true as const, + status: invite.status, + lifecycleState: getReusableInviteLifecycleState(invite), + createdAt: invite.createdAt, + updatedAt: invite.updatedAt, + revokedAt: invite.revokedAt, + rotatedFromInviteId: invite.rotatedFromInviteId, + useCount: invite.useCount, + lastUsedAt: invite.lastUsedAt, + } +} + export const inviteRouter = createTRPCRouter({ + getActiveReusableInvite: protectedProcedure + .input(getActiveReusableInviteInputSchema) + .query(async ({ ctx, input }) => { + const membership = await ctx.db.familyMember.findUnique({ + where: { + familyId_userId: { + familyId: input.familyId, + userId: ctx.session.user.id, + }, + }, + }) + + if (!membership || (membership.role !== "ADMIN" && membership.role !== "OWNER")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to view family invite links for this family", + }) + } + + const activeReusableInvite = await ctx.db.invite.findFirst({ + where: { + familyId: input.familyId, + isReusable: true, + claimMemberId: null, + status: "PENDING", + revokedAt: null, + }, + orderBy: { createdAt: "desc" }, + }) + + if (activeReusableInvite) { + return buildReusableInviteSummary(activeReusableInvite) + } + + const latestReusableInvite = await ctx.db.invite.findFirst({ + where: { + familyId: input.familyId, + isReusable: true, + claimMemberId: null, + }, + orderBy: { createdAt: "desc" }, + }) + + if (!latestReusableInvite) { + return null + } + + return buildReusableInviteSummary(latestReusableInvite) + }), + /** * Public query: Get invite details by code for pre-acceptance viewing. * Returns family info and invite metadata but no sensitive claim history. @@ -74,12 +160,14 @@ export const inviteRouter = createTRPCRouter({ }) } - const state = getInviteLifecycleState({ - status: invite.status, - expiresAt: invite.expiresAt, - claimedAt: invite.claimedAt, - revokedAt: invite.revokedAt, - }) + const state = isReusableInvite(invite) + ? getReusableInviteLifecycleState(invite) + : getInviteLifecycleState({ + status: invite.status, + expiresAt: invite.expiresAt, + claimedAt: invite.claimedAt, + revokedAt: invite.revokedAt, + }) // Map lifecycle state to user-facing error codes if (state === "revoked") { @@ -103,9 +191,17 @@ export const inviteRouter = createTRPCRouter({ }) } + if (state === "invalid") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This invite is not valid", + }) + } + return { id: invite.id, code: invite.code, + isReusable: invite.isReusable, family: { id: invite.family.id, name: invite.family.name, @@ -147,7 +243,22 @@ export const inviteRouter = createTRPCRouter({ } // Check invite usability - if (!isInviteUsable(invite)) { + if (isReusableInvite(invite)) { + if (!isReusableInviteUsable(invite)) { + const state = getReusableInviteLifecycleState(invite) + if (state === "revoked") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This invite has been revoked", + }) + } + + throw new TRPCError({ + code: "FORBIDDEN", + message: "This invite is not valid", + }) + } + } else if (!isInviteUsable(invite)) { const state = getInviteLifecycleState(invite) if (state === "revoked") { throw new TRPCError({ @@ -230,21 +341,35 @@ export const inviteRouter = createTRPCRouter({ const claimedAt = new Date() - // Claim invite atomically - only succeeds if invite is still usable. - const claimResult = await tx.invite.updateMany({ - where: { - id: invite.id, - status: "PENDING", - claimedAt: null, - revokedAt: null, - expiresAt: { gt: claimedAt }, - }, - data: { - status: "CLAIMED", - claimedById: user.id, - claimedAt, - }, - }) + const claimResult = isReusableInvite(invite) + ? await tx.invite.updateMany({ + where: { + id: invite.id, + isReusable: true, + status: "PENDING", + revokedAt: null, + }, + data: { + useCount: { + increment: 1, + }, + lastUsedAt: claimedAt, + }, + }) + : await tx.invite.updateMany({ + where: { + id: invite.id, + status: "PENDING", + claimedAt: null, + revokedAt: null, + expiresAt: { gt: claimedAt }, + }, + data: { + status: "CLAIMED", + claimedById: user.id, + claimedAt, + }, + }) if (claimResult.count === 0) { throw new TRPCError({ @@ -279,8 +404,12 @@ export const inviteRouter = createTRPCRouter({ eventType: "INVITE_STATUS_CHANGED" as const, sourceType: "invite", sourceId: invite.id, - title: "An invite was claimed", - body: "A pending invite has been claimed.", + title: isReusableInvite(invite) + ? "A family invite link was used" + : "An invite was claimed", + body: isReusableInvite(invite) + ? "A reusable family invite link was used to join the family." + : "A pending invite has been claimed.", })), ) } @@ -352,6 +481,131 @@ export const inviteRouter = createTRPCRouter({ } }), + resetReusableInvite: protectedProcedure + .input(resetReusableInviteInputSchema) + .mutation(async ({ ctx, input }) => { + const membership = await ctx.db.familyMember.findUnique({ + where: { + familyId_userId: { + familyId: input.familyId, + userId: ctx.session.user.id, + }, + }, + }) + + if (!membership || (membership.role !== "ADMIN" && membership.role !== "OWNER")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to manage family invite links for this family", + }) + } + + const result = await ctx.db.$transaction(async (tx) => { + const activeReusableInvite = await tx.invite.findFirst({ + where: { + familyId: input.familyId, + isReusable: true, + claimMemberId: null, + status: "PENDING", + revokedAt: null, + }, + orderBy: { createdAt: "desc" }, + }) + + const resetAt = new Date() + + if (activeReusableInvite) { + await tx.invite.updateMany({ + where: { + familyId: input.familyId, + isReusable: true, + claimMemberId: null, + status: "PENDING", + revokedAt: null, + }, + data: { + status: "REVOKED", + revokedAt: resetAt, + }, + }) + } + + let code: string | undefined + + for (let attempt = 0; attempt < 3; attempt++) { + const candidate = generateInviteCode() + const conflict = await tx.invite.findUnique({ where: { code: candidate } }) + if (!conflict) { + code = candidate + break + } + } + + if (!code) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to generate unique invite code", + }) + } + + const createdInvite = await tx.invite.create({ + data: { + code, + type: "OPEN", + invitedEmail: null, + familyId: input.familyId, + createdById: ctx.session.user.id, + expiresAt: REUSABLE_INVITE_FUTURE_EXPIRY, + status: "PENDING", + isReusable: true, + rotatedFromInviteId: activeReusableInvite?.id ?? null, + }, + }) + + const adminRecipientIds = await getClaimedAdminMemberIds(tx, input.familyId, [membership.id]) + if (adminRecipientIds.length > 0) { + const notifications = [] + + if (activeReusableInvite) { + notifications.push( + ...adminRecipientIds.map((recipientMemberId) => ({ + familyId: input.familyId, + recipientMemberId, + actorMemberId: membership.id, + category: "INVITE" as const, + eventType: "INVITE_STATUS_CHANGED" as const, + sourceType: "invite", + sourceId: activeReusableInvite.id, + title: "A family invite link was reset", + body: "A family admin reset the reusable family invite link.", + })), + ) + } + + notifications.push( + ...adminRecipientIds.map((recipientMemberId) => ({ + familyId: input.familyId, + recipientMemberId, + actorMemberId: membership.id, + category: "INVITE" as const, + eventType: "INVITE_CREATED" as const, + sourceType: "invite", + sourceId: createdInvite.id, + title: "A new family invite link was created", + body: "A family admin created a reusable family invite link.", + })), + ) + + const createdNotifications = await createNotifications(tx, notifications) + void dispatchPushForNotifications(createdNotifications) + } + + return createdInvite + }) + + return buildReusableInviteSummary(result) + }), + /** * Protected mutation: Create a new invite (admin-only). * Generates a unique code and sets expiry based on TTL. diff --git a/test/server/api/routers/invite.test.ts b/test/server/api/routers/invite.test.ts index 42dfe38..1da5556 100644 --- a/test/server/api/routers/invite.test.ts +++ b/test/server/api/routers/invite.test.ts @@ -332,6 +332,251 @@ describe("inviteRouter notification producers", () => { }); }); +describe("inviteRouter reusable family links", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "info").mockImplementation(() => undefined); + }); + + const familyId = "clh0000000000000000007001"; + const actorMemberId = "clh0000000000000000007002"; + + it("returns the active reusable invite for a family", async () => { + const reusableInvite = { + id: "clh0000000000000000007010", + code: "REUSABLE_ACTIVE_CODE", + familyId, + isReusable: true, + status: "PENDING", + createdAt: new Date("2030-01-01T00:00:00.000Z"), + updatedAt: new Date("2030-01-01T00:00:00.000Z"), + revokedAt: null, + rotatedFromInviteId: null, + useCount: 2, + lastUsedAt: new Date("2030-01-02T00:00:00.000Z"), + claimMemberId: null, + }; + + const db = { + familyMember: { + findUnique: vi.fn().mockResolvedValue({ + id: actorMemberId, + familyId, + role: "ADMIN", + }), + }, + invite: { + findFirst: vi.fn().mockResolvedValue(reusableInvite), + }, + } as never; + + const result = await createCaller(db).getActiveReusableInvite({ familyId }); + + expect(result).toMatchObject({ + id: reusableInvite.id, + code: reusableInvite.code, + isReusable: true, + lifecycleState: "valid", + useCount: 2, + }); + }); + + it("returns null when no reusable invite exists", async () => { + const db = { + familyMember: { + findUnique: vi.fn().mockResolvedValue({ + id: actorMemberId, + familyId, + role: "OWNER", + }), + }, + invite: { + findFirst: vi.fn().mockResolvedValue(null), + }, + } as never; + + const result = await createCaller(db).getActiveReusableInvite({ familyId }); + expect(result).toBeNull(); + }); + + it("resetReusableInvite revokes the previous active link and creates a new reusable link", async () => { + const activeReusableInvite = { + id: "clh0000000000000000007020", + code: "OLD_REUSABLE_CODE", + familyId, + isReusable: true, + status: "PENDING", + revokedAt: null, + createdAt: new Date("2030-01-01T00:00:00.000Z"), + updatedAt: new Date("2030-01-01T00:00:00.000Z"), + rotatedFromInviteId: null, + useCount: 4, + lastUsedAt: null, + claimMemberId: null, + }; + const newReusableInvite = { + id: "clh0000000000000000007021", + code: "NEW_REUSABLE_CODE", + familyId, + isReusable: true, + status: "PENDING", + revokedAt: null, + createdAt: new Date("2030-01-02T00:00:00.000Z"), + updatedAt: new Date("2030-01-02T00:00:00.000Z"), + rotatedFromInviteId: activeReusableInvite.id, + useCount: 0, + lastUsedAt: null, + }; + + const tx = { + invite: { + findFirst: vi.fn().mockResolvedValue(activeReusableInvite), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue(newReusableInvite), + }, + }; + + const db = { + familyMember: { + findUnique: vi.fn().mockResolvedValue({ id: actorMemberId, familyId, role: "ADMIN" }), + }, + $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), + } as never; + + const result = await createCaller(db).resetReusableInvite({ familyId }); + + expect(tx.invite.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ familyId, isReusable: true, status: "PENDING" }), + data: expect.objectContaining({ status: "REVOKED" }), + }), + ); + expect(tx.invite.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + familyId, + isReusable: true, + type: "OPEN", + rotatedFromInviteId: activeReusableInvite.id, + }), + }), + ); + expect(result).toMatchObject({ + id: newReusableInvite.id, + code: newReusableInvite.code, + isReusable: true, + lifecycleState: "valid", + }); + }); + + it("acceptInvite allows reusable invites even when expiresAt is in the past and increments usage", async () => { + const reusableInvite = { + id: "clh0000000000000000007030", + code: "REUSABLE_JOIN_CODE", + type: "OPEN", + status: "PENDING", + familyId, + invitedEmail: null, + createdById: "creator-1", + expiresAt: new Date("2020-01-01T00:00:00.000Z"), + claimedAt: null, + claimedById: null, + claimMemberId: null, + revokedAt: null, + isReusable: true, + useCount: 0, + lastUsedAt: null, + }; + const tx = { + user: { + create: vi.fn().mockResolvedValue({ id: "user-new", email: "new@example.com" }), + }, + invite: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + familyMember: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "member-new" }), + }, + }; + const db = { + invite: { + findUnique: vi.fn().mockResolvedValue(reusableInvite), + }, + user: { + findFirst: vi.fn().mockResolvedValue(null), + }, + $transaction: vi.fn(async (cb: (txArg: typeof tx) => Promise) => cb(tx)), + } as never; + + const result = await inviteRouter.createCaller({ + db, + session: null, + headers: new Headers(), + } as never).acceptInvite({ + code: reusableInvite.code, + email: "new@example.com", + password: "password123", + name: "New User", + }); + + expect(result).toMatchObject({ userId: "user-new", email: "new@example.com" }); + expect(tx.invite.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: reusableInvite.id, isReusable: true, status: "PENDING" }), + data: expect.objectContaining({ + useCount: { increment: 1 }, + }), + }), + ); + }); + + it("getByCode accepts reusable invites without treating past expiresAt as invalid", async () => { + const reusableInvite = { + id: "clh0000000000000000007040", + code: "REUSABLE_PUBLIC_CODE", + familyId, + type: "OPEN", + invitedEmail: null, + expiresAt: new Date("2020-01-01T00:00:00.000Z"), + status: "PENDING", + claimedAt: null, + revokedAt: null, + isReusable: true, + family: { + id: familyId, + name: "Ng", + description: "Family description", + }, + }; + + const db = { + invite: { + findUnique: vi.fn().mockResolvedValue(reusableInvite), + }, + } as never; + + const result = await inviteRouter.createCaller({ + db, + session: null, + headers: new Headers(), + } as never).getByCode({ code: reusableInvite.code }); + + expect(result).toMatchObject({ + id: reusableInvite.id, + code: reusableInvite.code, + isReusable: true, + family: { + id: familyId, + }, + }); + }); +}); + describe("inviteRouter.createInvite email delivery status", () => { beforeEach(() => { vi.restoreAllMocks(); From 400a1dacd683b52c11b8b440411ab90397cd07fd Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:39:52 +0100 Subject: [PATCH 06/17] feat: enhance reusable family onboarding invite link with management UI and reset functionality --- .../prd.md | 38 +-- src/app/(app)/settings/invites/page.tsx | 306 +++++++++++++++++- src/server/api/routers/invite.ts | 1 + 3 files changed, 320 insertions(+), 25 deletions(-) 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 index e770046..77f099a 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -131,25 +131,25 @@ Out of scope: #### Tasks -- [ ] Keep the existing primary "Create Invite" button behavior unchanged in `src/app/(app)/settings/invites/page.tsx` -- [ ] Add a dropdown menu attached to the invite action area -- [ ] Add dropdown option labeled "Family Link" as a secondary action -- [ ] Add dropdown option description text: "Invite via Family Invite Link" -- [ ] Selecting "Family Link" opens/toggles the reusable-link management card (section title: "Family Invite Link") -- [ ] Add reusable-link card in `src/app/(app)/settings/invites/page.tsx` (section title: "Family Invite Link") -- [ ] Show active reusable link URL with copy action -- [ ] Always show current/last created reusable link and lifecycle state metadata -- [ ] Show "valid until reset" lifecycle messaging for active reusable links -- [ ] Show usage indicators (`useCount`, `lastUsedAt`) -- [ ] Show non-blocking rotation reminder for older links (for example, older than 90 days) -- [ ] Add reset link action with confirmation and mutation wiring -- [ ] Refresh queries on reset success and display new active link -- [ ] Add first-run empty state when no reusable link has ever been created: -- [ ] Message that reusable link is not created yet for this family -- [ ] Primary CTA to create first reusable link -- [ ] Add clear list badges for reusable, single-use, and claim invites -- [ ] Update history rendering rules: -- [ ] Exclude revoked reusable invite artifacts from invite history +- [x] Keep the existing primary "Create Invite" button behavior unchanged in `src/app/(app)/settings/invites/page.tsx` +- [x] Add a dropdown menu attached to the invite action area +- [x] Add dropdown option labeled "Family Link" as a secondary action +- [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 diff --git a/src/app/(app)/settings/invites/page.tsx b/src/app/(app)/settings/invites/page.tsx index ee4b6d3..8f4b9dc 100644 --- a/src/app/(app)/settings/invites/page.tsx +++ b/src/app/(app)/settings/invites/page.tsx @@ -8,6 +8,7 @@ import { Copy, Link2, Loader, + More, Plus, Send, ShieldAlert, @@ -18,11 +19,19 @@ import { z } from "zod"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; +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 +48,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 +99,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 +146,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"; @@ -146,6 +174,7 @@ function formatDate(value: Date | string | null) { 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 +189,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 +218,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 +270,73 @@ 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 || reusableInvite !== null; 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`, + title: + reusableInvite.useCount === 1 + ? "Family Invite Link used once" + : `Family Invite Link used ${reusableInvite.useCount} times`, + subtitle: reusableInvite.lastUsedAt + ? `Last used ${formatDate(reusableInvite.lastUsedAt)}` + : `Created ${formatDate(reusableInvite.createdAt)}`, + status: reusableInvite.lifecycleState, + }, + ]; + }, [reusableInvite]); + const visiblePendingInvites = pendingInvites; const visibleHistoryInvites = historyInvites; const showAllEmptyState = - visiblePendingInvites.length === 0 && visibleHistoryInvites.length === 0; + visiblePendingInvites.length === 0 && + visibleHistoryInvites.length === 0 && + reusableHistoryItems.length === 0 && + reusableInvite === null; async function copyText(key: string, value: string) { try { @@ -269,6 +359,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 +381,31 @@ 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 = Boolean(reusableInvite && reusableInvite.lifecycleState === "valid" && familyLinkAgeDays >= 90); + return (
@@ -299,12 +419,36 @@ export default function InvitesPage() {
+ + + + + + { + setShowFamilyLinkPanel((current) => !current || reusableInvite !== null); + setShowCreatePanel(false); + }} + > +
+

Family Link

+

Invite via Family Invite Link

+
+
+
+
@@ -337,6 +481,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 +794,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,7 +919,7 @@ export default function InvitesPage() { Loading invite history...

- ) : visibleHistoryInvites.length === 0 ? ( + ) : visibleHistoryInvites.length === 0 && reusableHistoryItems.length === 0 ? (

No invite history yet.

) : (
    @@ -676,6 +950,26 @@ export default function InvitesPage() { ))} + {reusableHistoryItems.map((item) => ( +
  • +
    +

    {item.title}

    +

    {item.subtitle}

    +
    + + + Family Link + +
  • + ))}
)}
diff --git a/src/server/api/routers/invite.ts b/src/server/api/routers/invite.ts index 302f756..ee89be9 100644 --- a/src/server/api/routers/invite.ts +++ b/src/server/api/routers/invite.ts @@ -840,6 +840,7 @@ export const inviteRouter = createTRPCRouter({ id: inv.id, code: inv.code, type: inv.type, + isReusable: inv.isReusable, isClaimInvite: inv.claimMemberId !== null, claimMember: inv.claimMember ? { From 45d8be4cd16be7ecab0a16bd9d10a673c1b0dd8c Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 16:58:26 +0100 Subject: [PATCH 07/17] feat: implement button group for invite actions and add reusable link management UI --- .../prd.md | 12 +-- src/app/(app)/settings/invites/page.tsx | 77 +++++++++------- src/components/ui/button-group.tsx | 83 +++++++++++++++++ src/components/ui/separator.tsx | 28 ++++++ src/components/ui/toggle-group.tsx | 89 +++++++++++++++++++ src/components/ui/toggle.tsx | 47 ++++++++++ 6 files changed, 298 insertions(+), 38 deletions(-) create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx 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 index 77f099a..b4c3cb1 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -52,7 +52,7 @@ Out of scope: - 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. -- Priority-first entry UX: Single-use "Create Invite" remains the primary visible action; "Family Link" is exposed as a secondary action in a dropdown menu to de-emphasize reusable-link usage. +- 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. @@ -132,8 +132,9 @@ Out of scope: #### Tasks - [x] Keep the existing primary "Create Invite" button behavior unchanged in `src/app/(app)/settings/invites/page.tsx` -- [x] Add a dropdown menu attached to the invite action area -- [x] Add dropdown option labeled "Family Link" as a secondary action +- [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") @@ -178,7 +179,7 @@ Out of scope: - [ ] Single-use invites still reject second acceptance - [ ] Non-admin cannot fetch/reset reusable link - [ ] Add or extend UI tests for reusable section rendering, reset action, and copy behavior -- [ ] Add UI tests for primary/dropdown action behavior: +- [ ] Add UI tests for button-group dropdown action behavior: - [ ] Primary "Create Invite" action opens current create-invite flow - [ ] Dropdown "Family Link" action opens reusable-link card - [ ] Add tests for first-run empty state in existing instances with no reusable link @@ -191,7 +192,8 @@ Out of scope: ## Acceptance Criteria - [ ] Invite settings keeps single-use "Create Invite" as the primary visible action -- [ ] "Family Link" appears as a secondary dropdown option and opens the "Family Invite Link" section +- [ ] The second control in the shadcn `ButtonGroup` is a dropdown trigger +- [ ] "Family Link" appears as a dropdown option and opens the "Family Invite Link" section - [ ] The "Family Link" dropdown option displays description text "Invite via Family Invite Link" - [ ] Owners/admins can view the current/last created reusable join invite link and its lifecycle state in the reusable-link card - [ ] Existing families with no reusable link see an explicit empty state and can create their first reusable link from the card diff --git a/src/app/(app)/settings/invites/page.tsx b/src/app/(app)/settings/invites/page.tsx index 8f4b9dc..50b8404 100644 --- a/src/app/(app)/settings/invites/page.tsx +++ b/src/app/(app)/settings/invites/page.tsx @@ -8,17 +8,18 @@ import { Copy, Link2, Loader, - More, Plus, Send, 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, @@ -417,38 +418,48 @@ export default function InvitesPage() {
- - - - - - - { - setShowFamilyLinkPanel((current) => !current || reusableInvite !== null); - setShowCreatePanel(false); - }} - > -
-

Family Link

-

Invite via Family Invite Link

-
-
-
-
+ + + + + + + + + { + setShowFamilyLinkPanel((current) => !current || reusableInvite !== null); + setShowCreatePanel(false); + }} + > +
+

Family Link

+

Invite via Family Invite Link

+
+
+
+
+
diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 0000000..d755d8c --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "~/lib/utils" +import { Separator } from "~/components/ui/separator" + +const buttonGroupVariants = cva( + "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[>[data-variant=outline]]:*:data-[slot=input-group]:border-border has-[>[data-variant=outline]]:*:data-[slot=select-trigger]:border-border has-[>[data-variant=outline]]:[&>[data-slot=input-group]:has(:focus-visible)]:border-ring has-[>[data-variant=outline]]:[&>[data-slot=select-trigger]:focus-visible]:border-ring has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-variant=outline]]:[&>input]:border-border has-[>[data-variant=outline]]:[&>input:focus-visible]:border-ring", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl!", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl!", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot.Root : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..9eeeb2d --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..666e780 --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" +import { toggleVariants } from "~/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + } +>({ + size: "default", + variant: "default", + spacing: 2, + orientation: "horizontal", +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 2, + orientation = "horizontal", + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..e2931ae --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,47 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Toggle as TogglePrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-3xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: + "h-9 min-w-9 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + sm: "h-8 min-w-8 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + lg: "h-10 min-w-10 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } From 7450e9b6230138aa4d841c93eb364d8561a5d631 Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 17:30:34 +0100 Subject: [PATCH 08/17] feat: simplify family link panel visibility logic and ensure it is always shown on selection --- src/app/(app)/settings/invites/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(app)/settings/invites/page.tsx b/src/app/(app)/settings/invites/page.tsx index 50b8404..baa7515 100644 --- a/src/app/(app)/settings/invites/page.tsx +++ b/src/app/(app)/settings/invites/page.tsx @@ -301,7 +301,7 @@ export default function InvitesPage() { ); return parsedReusableInvite.success ? parsedReusableInvite.data : null; }, [reusableInviteQuery.data]); - const familyLinkPanelVisible = showFamilyLinkPanel || reusableInvite !== null; + const familyLinkPanelVisible = showFamilyLinkPanel; const historyInvites = useMemo( () => invites.filter((invite) => !invite.isReusable && invite.lifecycleState !== "valid"), [invites], @@ -448,7 +448,7 @@ export default function InvitesPage() { { - setShowFamilyLinkPanel((current) => !current || reusableInvite !== null); + setShowFamilyLinkPanel(true); setShowCreatePanel(false); }} > From cb9a448c93818c51de404f66af1f2cc325a89f42 Mon Sep 17 00:00:00 2001 From: babblebey Date: Mon, 13 Jul 2026 17:30:59 +0100 Subject: [PATCH 09/17] feat: update invite page to support reusable invite messaging and improve user prompts --- .../prd.md | 10 ++-- src/app/auth/invite/[code]/page.tsx | 57 +++++++------------ 2 files changed, 24 insertions(+), 43 deletions(-) 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 index b4c3cb1..db3931a 100644 --- a/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md +++ b/.project/prds/032-reusable-family-onboarding-invite-link-rotation/prd.md @@ -159,11 +159,11 @@ Out of scope: #### Tasks -- [ ] Update invite page status/error mapping in `src/app/auth/invite/[code]/page.tsx` -- [ ] Avoid already-used messaging for reusable invites -- [ ] Preserve revoked/invalid messaging for reusable links and existing expired messaging for single-use invites -- [ ] Preserve successful accept and sign-in flow -- [ ] Verify no regressions for email-bound single-use invites +- [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 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 ? (