diff --git a/apps/api/src/api/accounts/ownership-transfers.service.ts b/apps/api/src/api/accounts/ownership-transfers.service.ts index d40b05f6..4a130f13 100644 --- a/apps/api/src/api/accounts/ownership-transfers.service.ts +++ b/apps/api/src/api/accounts/ownership-transfers.service.ts @@ -261,47 +261,65 @@ export class OwnershipTransfersService { ): Promise { const tokenHash = hashOpaqueToken(token); - const [transfer] = await db - .select() - .from(accountOwnershipTransfers) - .where( - and( - eq(accountOwnershipTransfers.tokenHash, tokenHash), - isNull(accountOwnershipTransfers.acceptedAt), - isNull(accountOwnershipTransfers.declinedAt), - isNull(accountOwnershipTransfers.cancelledAt) + return db.transaction(async (tx) => { + /* + * Mirrors `accept()`'s `FOR UPDATE`. Without the row lock, an + * accept running in parallel commits its UPDATE between this + * SELECT and a naive UPDATE-by-id, and both `acceptedAt` and + * `declinedAt` end up set. The row lock + a WHERE that repeats + * the `acceptedAt IS NULL` predicates on the UPDATE forces the + * loser to fall through to a clean 404. + */ + const [transfer] = await tx + .select() + .from(accountOwnershipTransfers) + .where( + and( + eq(accountOwnershipTransfers.tokenHash, tokenHash), + isNull(accountOwnershipTransfers.acceptedAt), + isNull(accountOwnershipTransfers.declinedAt), + isNull(accountOwnershipTransfers.cancelledAt) + ) ) - ) - .limit(1); + .limit(1) + .for("update"); - if (!transfer) { - throw ApiErrors.notFound("Ownership transfer"); - } + if (!transfer) { + throw ApiErrors.notFound("Ownership transfer"); + } - if (transfer.toUserId !== decliningUserId) { - throw ApiErrors.forbidden( - "Only the named recipient can decline this transfer" - ); - } + if (transfer.toUserId !== decliningUserId) { + throw ApiErrors.forbidden( + "Only the named recipient can decline this transfer" + ); + } - const [declined] = await db - .update(accountOwnershipTransfers) - .set({ declinedAt: now(), updatedAt: now() }) - .where(eq(accountOwnershipTransfers.id, transfer.id)) - .returning(); + const [declined] = await tx + .update(accountOwnershipTransfers) + .set({ declinedAt: now(), updatedAt: now() }) + .where( + and( + eq(accountOwnershipTransfers.id, transfer.id), + isNull(accountOwnershipTransfers.acceptedAt), + isNull(accountOwnershipTransfers.declinedAt), + isNull(accountOwnershipTransfers.cancelledAt) + ) + ) + .returning(); - if (!declined) { - throw ApiErrors.database("Failed to mark transfer declined"); - } + if (!declined) { + throw ApiErrors.notFound("Ownership transfer"); + } - void auditLogService.record({ - userId: decliningUserId, - action: AUDIT_ACTIONS.ACCOUNT_OWNERSHIP_TRANSFER_DECLINED, - resource: `account:${transfer.accountId}`, - metadata: { transferId: transfer.id }, - }); + void auditLogService.record({ + userId: decliningUserId, + action: AUDIT_ACTIONS.ACCOUNT_OWNERSHIP_TRANSFER_DECLINED, + resource: `account:${transfer.accountId}`, + metadata: { transferId: transfer.id }, + }); - return toOwnershipTransfer(declined); + return toOwnershipTransfer(declined); + }); } async cancel( diff --git a/apps/api/src/api/auth/services/email-verification.service.ts b/apps/api/src/api/auth/services/email-verification.service.ts index edc6df5e..d70a86e2 100644 --- a/apps/api/src/api/auth/services/email-verification.service.ts +++ b/apps/api/src/api/auth/services/email-verification.service.ts @@ -37,44 +37,67 @@ export class EmailVerificationService { */ async verify(token: string): Promise { const tokenHash = hashOpaqueToken(token); - const record = await db.query.emailVerificationTokens.findFirst({ - where: and( - eq(emailVerificationTokens.tokenHash, tokenHash), - gt(emailVerificationTokens.expiresAt, now()) - ), - }); - - if (!record) { - throw ApiErrors.invalidInput("Invalid or expired verification token"); - } + const verifiedAt = now(); - const user = await db.query.users.findFirst({ - where: eq(users.id, record.userId), - }); + const { user, provisioned } = await db.transaction(async (tx) => { + /* + * Atomic token claim. Postgres serializes concurrent DELETEs on + * the same row: the first call returns the deleted row, every + * later call sees an empty RETURNING and falls through to the + * "invalid or expired" branch. Without this, two parallel verify + * calls both pass a pre-tx existence check, both call + * `provisionAfterVerification`, and the user ends up with two + * personal accounts because that path also does select-then- + * insert under no unique constraint. + */ + const [claimed] = await tx + .delete(emailVerificationTokens) + .where( + and( + eq(emailVerificationTokens.tokenHash, tokenHash), + gt(emailVerificationTokens.expiresAt, now()) + ) + ) + .returning(); + + if (!claimed) { + throw ApiErrors.invalidInput("Invalid or expired verification token"); + } - if (!user) { - throw ApiErrors.notFound("User"); - } + const [claimedUser] = await tx + .select() + .from(users) + .where(eq(users.id, claimed.userId)) + .limit(1) + .for("update"); - if (user.emailVerifiedAt !== null) { - throw ApiErrors.invalidInput("Email already verified"); - } + if (!claimedUser) { + throw ApiErrors.notFound("User"); + } - const verifiedAt = now(); + /* + * Resend can leave a fresh token on a user that's already been + * verified through a different link. Reject explicitly instead of + * silently re-running the provisioning path — the user-facing + * message ("Email already verified") is more informative than + * "Invalid or expired" for the legitimate "I clicked the older + * email" case. + */ + if (claimedUser.emailVerifiedAt !== null) { + throw ApiErrors.invalidInput("Email already verified"); + } - const provisioned = await db.transaction(async (tx) => { await tx .update(users) .set({ emailVerifiedAt: verifiedAt, updatedAt: verifiedAt }) - .where(eq(users.id, user.id)); - await tx - .delete(emailVerificationTokens) - .where(eq(emailVerificationTokens.tokenHash, tokenHash)); + .where(eq(users.id, claimedUser.id)); - return accountsService.provisionAfterVerification( - { userId: user.id }, + const account = await accountsService.provisionAfterVerification( + { userId: claimedUser.id }, tx ); + + return { user: claimedUser, provisioned: account }; }); /* diff --git a/apps/api/src/api/auth/services/password-reset.service.ts b/apps/api/src/api/auth/services/password-reset.service.ts index 8a9a21be..1b06ccd7 100644 --- a/apps/api/src/api/auth/services/password-reset.service.ts +++ b/apps/api/src/api/auth/services/password-reset.service.ts @@ -136,26 +136,38 @@ export class PasswordResetService { async complete(token: string, newPassword: string): Promise { const tokenHash = hashOpaqueToken(token); - const record = await db.query.passwordResetTokens.findFirst({ - where: and( - eq(passwordResetTokens.tokenHash, tokenHash), - gt(passwordResetTokens.expiresAt, now()) - ), - }); + const passwordHash = await passwordService.hash(newPassword); - if (!record) { - throw ApiErrors.invalidInput("Invalid or expired reset token"); - } + const record = await db.transaction(async (tx) => { + /* + * Atomic token claim — see email-verification.service.ts for the + * full rationale. The pre-hash bcrypt cost runs outside the tx so + * a duplicate submission still pays it, but only one call gets + * past the DELETE...RETURNING. The loser sees an empty result + * and surfaces "Invalid or expired" — the user already-completed + * state stays consistent because nothing past the claim runs + * twice. + */ + const [claimed] = await tx + .delete(passwordResetTokens) + .where( + and( + eq(passwordResetTokens.tokenHash, tokenHash), + gt(passwordResetTokens.expiresAt, now()) + ) + ) + .returning(); - const passwordHash = await passwordService.hash(newPassword); + if (!claimed) { + throw ApiErrors.invalidInput("Invalid or expired reset token"); + } - await db.transaction(async (tx) => { const updatedProviders = await tx .update(userAuthProviders) .set({ passwordHash }) .where( and( - eq(userAuthProviders.userId, record.userId), + eq(userAuthProviders.userId, claimed.userId), eq(userAuthProviders.provider, EMAIL_PROVIDER_KEY) ) ) @@ -165,9 +177,7 @@ export class PasswordResetService { throw ApiErrors.invalidInput("Password login is not enabled"); } - await tx - .delete(passwordResetTokens) - .where(eq(passwordResetTokens.userId, record.userId)); + return claimed; }); /* diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts index d48b3a5f..e8fe290c 100644 --- a/apps/api/src/api/billing/billing.service.ts +++ b/apps/api/src/api/billing/billing.service.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, or } from "drizzle-orm"; import Stripe from "stripe"; import { db } from "../../clients/postgres"; @@ -169,17 +169,46 @@ export class BillingService { let stripeCustomerId = account.stripeCustomerId; if (stripeCustomerId === null || stripeCustomerId === "") { - const customer = await this.stripe.customers.create({ - name: account.name, - metadata: { accountId: account.id }, - }); + /* + * Idempotency key keyed on the account id makes Stripe collapse a + * double-click into a single customer record. Without it, two + * parallel checkout requests each see `stripeCustomerId === null`, + * each `customers.create()` returns a *different* id, and the + * second DB write wins — the orphaned customer's future webhook + * deliveries don't resolve the account and the subscription + * silently lands on the wrong tenant. Stripe holds the key for + * 24h, which covers any plausible double-submit window. + */ + const customer = await this.stripe.customers.create( + { + name: account.name, + metadata: { accountId: account.id }, + }, + { idempotencyKey: `account-customer:${account.id}` } + ); stripeCustomerId = customer.id; + /* + * Conditional update — only write the customer id when the column + * is still empty. A concurrent request that beat us to the Stripe + * API may have already filled it with the same value (idempotency + * key collapse) or, in theory, raced past our row in a different + * order; either way, leaving the existing value alone keeps the + * write idempotent. + */ await db .update(accounts) .set({ stripeCustomerId }) - .where(eq(accounts.id, accountId)); + .where( + and( + eq(accounts.id, accountId), + or( + isNull(accounts.stripeCustomerId), + eq(accounts.stripeCustomerId, "") + ) + ) + ); } const session = await this.stripe.checkout.sessions.create({ diff --git a/apps/api/src/config/security/security.constants.ts b/apps/api/src/config/security/security.constants.ts index a889fdc2..b281adfe 100644 --- a/apps/api/src/config/security/security.constants.ts +++ b/apps/api/src/config/security/security.constants.ts @@ -21,7 +21,26 @@ export const CORS_ALLOWED_HEADERS = [ "Content-Type", "Authorization", "X-Requested-With", + /* + * Sentry browser SDK writes both the Sentry-native and W3C trace + * headers on outbound fetches when `browserTracingIntegration` is on + * (see apps/ui/src/app/main.tsx). Without these in the allowlist the + * cross-origin preflight rejects the request before it ever reaches + * the API, and the SPA degrades to untraced calls. + */ + "sentry-trace", + "baggage", + "traceparent", ]; +/** + * Response headers the browser is allowed to expose to JS via + * `response.headers.get(...)`. Same-origin reads them unconditionally; + * cross-origin needs an explicit allowlist. `x-request-id` is the + * forensic id our error toasts surface — without it, "ask support for + * request id X" breaks the moment the API runs on a different host. + */ +export const CORS_EXPOSED_HEADERS = ["x-request-id"]; + /** Browser preflight cache TTL in seconds (24h). */ export const CORS_MAX_AGE_SECONDS = 86_400; diff --git a/apps/api/src/config/security/security.ts b/apps/api/src/config/security/security.ts index 01c1a3ee..df0722c8 100644 --- a/apps/api/src/config/security/security.ts +++ b/apps/api/src/config/security/security.ts @@ -4,6 +4,7 @@ import { env } from "../env"; import { ValkeyRateLimitContext } from "../../lib/rate-limit/valkey-context"; import { CORS_ALLOWED_HEADERS, + CORS_EXPOSED_HEADERS, CORS_MAX_AGE_SECONDS, CORS_METHODS, } from "./security.constants"; @@ -25,6 +26,7 @@ export const buildCors = () => { credentials: true, methods: CORS_METHODS, allowedHeaders: CORS_ALLOWED_HEADERS, + exposeHeaders: CORS_EXPOSED_HEADERS, maxAge: CORS_MAX_AGE_SECONDS, aot: true, }); diff --git a/apps/api/tests/config/security/security.constants.test.ts b/apps/api/tests/config/security/security.constants.test.ts new file mode 100644 index 00000000..e5b16b0f --- /dev/null +++ b/apps/api/tests/config/security/security.constants.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; + +import { + CORS_ALLOWED_HEADERS, + CORS_EXPOSED_HEADERS, +} from "../../../src/config/security/security.constants"; + +describe("CORS allowlist", () => { + it("allows the Sentry browser SDK trace propagation headers", () => { + /* + * `browserTracingIntegration` writes both the Sentry-native + * `sentry-trace` + `baggage` headers and the W3C `traceparent` + * header on outbound /api/* fetches. Cross-origin preflight has + * to allow them all or the API never sees the call. + */ + expect(CORS_ALLOWED_HEADERS).toContain("sentry-trace"); + expect(CORS_ALLOWED_HEADERS).toContain("baggage"); + expect(CORS_ALLOWED_HEADERS).toContain("traceparent"); + }); + + it("exposes x-request-id to the browser", () => { + /* + * Error toasts read `response.headers.get("x-request-id")` and + * show it for support. Cross-origin reads need an explicit + * exposed-headers allowlist. + */ + expect(CORS_EXPOSED_HEADERS).toContain("x-request-id"); + }); +}); diff --git a/apps/ui/src/app/router/routes.tsx b/apps/ui/src/app/router/routes.tsx index 1909778e..f73df2b8 100644 --- a/apps/ui/src/app/router/routes.tsx +++ b/apps/ui/src/app/router/routes.tsx @@ -70,6 +70,26 @@ const InvitationsPage = lazy(() => })) ); +const InvitationAcceptPage = lazy(() => + import("@/features/accounts/components/InvitationAcceptPage").then((m) => ({ + default: m.InvitationAcceptPage + })) +); + +const OwnershipTransferAcceptPage = lazy(() => + import("@/features/accounts/components/OwnershipTransferAcceptPage").then( + (m) => ({ + default: m.OwnershipTransferAcceptPage + }) + ) +); + +const JoinRequestsPage = lazy(() => + import("@/features/accounts/components/JoinRequestsPage").then((m) => ({ + default: m.JoinRequestsPage + })) +); + const AuditLogPage = lazy(() => import("@/features/accounts/components/AuditLogPage").then((m) => ({ default: m.AuditLogPage @@ -289,6 +309,57 @@ const router = createBrowserRouter([ ) }, + { + /* + * Email-link landing for invitations. Auto-accepts the token then + * routes to /account/invitations. ProtectedRoute gate sends + * anonymous clicks through /login first; the search params survive + * the round-trip so the user lands back here. + */ + path: "/invitations/accept", + errorElement: , + element: ( + + }> + + + + ) + }, + { + /* + * Email-link landing for ownership transfers. Renders Accept and + * Decline buttons — never auto-fires, because either path mutates + * account roles. Authenticated only; the API also enforces that + * the recipient JWT matches the offer's `toUserId`. + */ + path: "/account/ownership-transfer/accept", + errorElement: , + element: ( + + }> + + + + ) + }, + { + /* + * Reviewer-side inbox for domain-claim join requests. The review + * email links here; the API enforces role on every approve/deny. + */ + path: "/account/requests", + errorElement: , + element: ( + + + }> + + + + + ) + }, { path: "*", errorElement: , diff --git a/apps/ui/src/features/accounts/Accounts.constants.ts b/apps/ui/src/features/accounts/Accounts.constants.ts index 9c8dc48d..3ddd4e42 100644 --- a/apps/ui/src/features/accounts/Accounts.constants.ts +++ b/apps/ui/src/features/accounts/Accounts.constants.ts @@ -5,5 +5,7 @@ */ export const ACCOUNTS_QUERY_KEYS = { invitations: (accountId: string) => - ["accounts", accountId, "invitations"] as const + ["accounts", accountId, "invitations"] as const, + joinRequests: (accountId: string) => + ["accounts", accountId, "join-requests"] as const }; diff --git a/apps/ui/src/features/accounts/Accounts.types.ts b/apps/ui/src/features/accounts/Accounts.types.ts index e362bc2d..dbfc22c3 100644 --- a/apps/ui/src/features/accounts/Accounts.types.ts +++ b/apps/ui/src/features/accounts/Accounts.types.ts @@ -17,3 +17,13 @@ type CreateInvitationResponse = export type ICreateInvitationResult = CreateInvitationResponse; export type IInviteRole = IInviteMemberInput["roleToAssign"]; + +type ListJoinRequestsResponse = + operations["getApiV1AccountsByIdJoin-requests"]["responses"][200]["content"]["application/json"]; + +export type IJoinRequest = ListJoinRequestsResponse[number]; + +type OwnershipTransferResponse = + operations["postApiV1InvitationsOwnership-transferAccept"]["responses"][200]["content"]["application/json"]["data"]; + +export type IOwnershipTransfer = OwnershipTransferResponse; diff --git a/apps/ui/src/features/accounts/Invitations.mutations.ts b/apps/ui/src/features/accounts/Invitations.mutations.ts index 50b0dbe4..0dfee648 100644 --- a/apps/ui/src/features/accounts/Invitations.mutations.ts +++ b/apps/ui/src/features/accounts/Invitations.mutations.ts @@ -7,12 +7,44 @@ import { import { ApiError } from "@/lib/api/ApiError"; import { apiClient } from "@/lib/api/client"; +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; + import { ACCOUNTS_QUERY_KEYS } from "./Accounts.constants"; import type { ICreateInvitationResult, IInviteMemberInput } from "./Accounts.types"; +export function useAcceptInvitation(): UseMutationResult< + { accepted: boolean }, + unknown, + { token: string } +> { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (input: { token: string }) => { + const { data } = await apiClient.POST("/api/v1/invitations/accept", { + body: { token: input.token } + }); + + if (!data?.data) { + throw new ApiError(0, { message: "Empty accept response" }); + } + + return data.data; + }, + onSuccess: async () => { + /* + * Acceptance grants a new membership. /me carries the membership + * set the AbilityProvider rebuilds against, so the next paint + * sees the new account in the switcher. + */ + await qc.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me }); + } + }); +} + export function useInviteMember( accountId: string | undefined ): UseMutationResult { diff --git a/apps/ui/src/features/accounts/JoinRequests.mutations.test.tsx b/apps/ui/src/features/accounts/JoinRequests.mutations.test.tsx new file mode 100644 index 00000000..c0ab6f44 --- /dev/null +++ b/apps/ui/src/features/accounts/JoinRequests.mutations.test.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + useApproveJoinRequest, + useDenyJoinRequest +} from "./JoinRequests.mutations"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ apiClient: apiMock })); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { Wrapper }; +} + +const row: { + id: string; + accountId: string; + userId: string; + email: string; + status: "pending" | "approved" | "denied"; + createdAt: string; + decidedAt: string | null; + decidedByUserId: string | null; +} = { + id: "jr1", + accountId: "acc-1", + userId: "u1", + email: "x@example.com", + status: "approved", + createdAt: "2026-06-01T00:00:00Z", + decidedAt: "2026-06-01T00:00:00Z", + decidedByUserId: "u-owner" +}; + +beforeEach(() => { + apiMock.POST.mockReset(); +}); + +describe("useApproveJoinRequest", () => { + it("rejects when no accountId is supplied (never calls the API)", async () => { + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useApproveJoinRequest(undefined), { + wrapper: Wrapper + }); + + await act(async () => { + await result.current + .mutateAsync({ requestId: "jr1" }) + .catch(() => undefined); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + expect(apiMock.POST).not.toHaveBeenCalled(); + }); + + it("POSTs the approve endpoint and returns the row", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: row, timestamp: "t" } + }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useApproveJoinRequest("acc-1"), { + wrapper: Wrapper + }); + + let response: typeof row | undefined; + + await act(async () => { + response = await result.current.mutateAsync({ requestId: "jr1" }); + }); + + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/accounts/{id}/join-requests/{requestId}/approve", + { params: { path: { id: "acc-1", requestId: "jr1" } } } + ); + expect(response).toEqual(row); + }); +}); + +describe("useDenyJoinRequest", () => { + it("POSTs the deny endpoint and returns the row", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { + success: true, + data: { ...row, status: "denied" }, + timestamp: "t" + } + }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useDenyJoinRequest("acc-1"), { + wrapper: Wrapper + }); + + await act(async () => { + await result.current.mutateAsync({ requestId: "jr1" }); + }); + + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/accounts/{id}/join-requests/{requestId}/deny", + { params: { path: { id: "acc-1", requestId: "jr1" } } } + ); + }); +}); diff --git a/apps/ui/src/features/accounts/JoinRequests.mutations.ts b/apps/ui/src/features/accounts/JoinRequests.mutations.ts new file mode 100644 index 00000000..447da5a8 --- /dev/null +++ b/apps/ui/src/features/accounts/JoinRequests.mutations.ts @@ -0,0 +1,79 @@ +import { + type UseMutationResult, + useMutation, + useQueryClient +} from "@tanstack/react-query"; + +import { ApiError } from "@/lib/api/ApiError"; +import { apiClient } from "@/lib/api/client"; + +import { ACCOUNTS_QUERY_KEYS } from "./Accounts.constants"; +import type { IJoinRequest } from "./Accounts.types"; + +export function useApproveJoinRequest( + accountId: string | undefined +): UseMutationResult { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (input: { requestId: string }) => { + if (accountId === undefined) { + throw new ApiError(0, { message: "No active account" }); + } + + const { data } = await apiClient.POST( + "/api/v1/accounts/{id}/join-requests/{requestId}/approve", + { + params: { path: { id: accountId, requestId: input.requestId } } + } + ); + + if (!data?.data) { + throw new ApiError(0, { message: "Empty approve response" }); + } + + return data.data; + }, + onSuccess: async () => { + if (accountId !== undefined) { + await qc.invalidateQueries({ + queryKey: ACCOUNTS_QUERY_KEYS.joinRequests(accountId) + }); + } + } + }); +} + +export function useDenyJoinRequest( + accountId: string | undefined +): UseMutationResult { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (input: { requestId: string }) => { + if (accountId === undefined) { + throw new ApiError(0, { message: "No active account" }); + } + + const { data } = await apiClient.POST( + "/api/v1/accounts/{id}/join-requests/{requestId}/deny", + { + params: { path: { id: accountId, requestId: input.requestId } } + } + ); + + if (!data?.data) { + throw new ApiError(0, { message: "Empty deny response" }); + } + + return data.data; + }, + onSuccess: async () => { + if (accountId !== undefined) { + await qc.invalidateQueries({ + queryKey: ACCOUNTS_QUERY_KEYS.joinRequests(accountId) + }); + } + } + }); +} diff --git a/apps/ui/src/features/accounts/JoinRequests.queries.test.tsx b/apps/ui/src/features/accounts/JoinRequests.queries.test.tsx new file mode 100644 index 00000000..74a125e0 --- /dev/null +++ b/apps/ui/src/features/accounts/JoinRequests.queries.test.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useJoinRequests } from "./JoinRequests.queries"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ apiClient: apiMock })); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { Wrapper }; +} + +beforeEach(() => { + apiMock.GET.mockReset(); +}); + +describe("useJoinRequests", () => { + it("is disabled when accountId is undefined (never calls the API)", () => { + const { Wrapper } = makeWrapper(); + + renderHook(() => useJoinRequests(undefined), { wrapper: Wrapper }); + + expect(apiMock.GET).not.toHaveBeenCalled(); + }); + + it("fetches the pending list and resolves to the row array", async () => { + const rows = [ + { + id: "jr1", + accountId: "acc-1", + userId: "u1", + email: "x@example.com", + status: "pending" as const, + createdAt: "2026-06-01T00:00:00Z", + decidedAt: null, + decidedByUserId: null + } + ]; + + apiMock.GET.mockResolvedValueOnce({ data: rows }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useJoinRequests("acc-1"), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(apiMock.GET).toHaveBeenCalledWith( + "/api/v1/accounts/{id}/join-requests", + { params: { path: { id: "acc-1" } } } + ); + expect(result.current.data).toEqual(rows); + }); + + it("resolves to an empty array when the API returns nullish data", async () => { + apiMock.GET.mockResolvedValueOnce({ data: undefined }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useJoinRequests("acc-1"), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); +}); diff --git a/apps/ui/src/features/accounts/JoinRequests.queries.ts b/apps/ui/src/features/accounts/JoinRequests.queries.ts new file mode 100644 index 00000000..e90d4958 --- /dev/null +++ b/apps/ui/src/features/accounts/JoinRequests.queries.ts @@ -0,0 +1,31 @@ +import { type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { ApiError } from "@/lib/api/ApiError"; +import { apiClient } from "@/lib/api/client"; + +import { ACCOUNTS_QUERY_KEYS } from "./Accounts.constants"; +import type { IJoinRequest } from "./Accounts.types"; + +export function useJoinRequests( + accountId: string | undefined +): UseQueryResult { + return useQuery({ + queryKey: + accountId === undefined + ? ACCOUNTS_QUERY_KEYS.joinRequests("anonymous") + : ACCOUNTS_QUERY_KEYS.joinRequests(accountId), + enabled: accountId !== undefined, + queryFn: async (): Promise => { + if (accountId === undefined) { + throw new ApiError(0, { message: "No active account" }); + } + + const { data } = await apiClient.GET( + "/api/v1/accounts/{id}/join-requests", + { params: { path: { id: accountId } } } + ); + + return data ?? []; + } + }); +} diff --git a/apps/ui/src/features/accounts/OwnershipTransfers.mutations.test.tsx b/apps/ui/src/features/accounts/OwnershipTransfers.mutations.test.tsx new file mode 100644 index 00000000..e7f79dd3 --- /dev/null +++ b/apps/ui/src/features/accounts/OwnershipTransfers.mutations.test.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + useAcceptOwnershipTransfer, + useDeclineOwnershipTransfer +} from "./OwnershipTransfers.mutations"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ apiClient: apiMock })); + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { Wrapper }; +} + +const transfer = { + id: "ot1", + accountId: "acc-1", + fromUserId: "u-old", + toUserId: "u-new", + expiresAt: "2026-06-08T00:00:00Z", + acceptedAt: "2026-06-01T00:00:00Z", + declinedAt: null, + cancelledAt: null, + createdAt: "2026-05-31T00:00:00Z" +}; + +beforeEach(() => { + apiMock.POST.mockReset(); +}); + +describe("useAcceptOwnershipTransfer", () => { + it("POSTs the accept endpoint with the token", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: transfer, timestamp: "t" } + }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useAcceptOwnershipTransfer(), { + wrapper: Wrapper + }); + + await act(async () => { + await result.current.mutateAsync({ token: "tok" }); + }); + + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/accept", + { body: { token: "tok" } } + ); + }); +}); + +describe("useDeclineOwnershipTransfer", () => { + it("POSTs the decline endpoint with the token", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { + success: true, + data: { + ...transfer, + acceptedAt: null, + declinedAt: "2026-06-01T00:00:00Z" + }, + timestamp: "t" + } + }); + + const { Wrapper } = makeWrapper(); + const { result } = renderHook(() => useDeclineOwnershipTransfer(), { + wrapper: Wrapper + }); + + await act(async () => { + await result.current.mutateAsync({ token: "tok" }); + }); + + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/decline", + { body: { token: "tok" } } + ); + }); +}); diff --git a/apps/ui/src/features/accounts/OwnershipTransfers.mutations.ts b/apps/ui/src/features/accounts/OwnershipTransfers.mutations.ts new file mode 100644 index 00000000..c8f73c78 --- /dev/null +++ b/apps/ui/src/features/accounts/OwnershipTransfers.mutations.ts @@ -0,0 +1,74 @@ +import { + type UseMutationResult, + useMutation, + useQueryClient +} from "@tanstack/react-query"; + +import { ApiError } from "@/lib/api/ApiError"; +import { apiClient } from "@/lib/api/client"; + +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; + +import type { IOwnershipTransfer } from "./Accounts.types"; + +/** + * Token-bearing mutations the ownership-transfer email landing page + * fires when the recipient clicks accept or decline. Authenticated: + * the API verifies that the named recipient matches the JWT subject + * before mutating either timestamp. + */ +export function useAcceptOwnershipTransfer(): UseMutationResult< + IOwnershipTransfer, + unknown, + { token: string } +> { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (input: { token: string }) => { + const { data } = await apiClient.POST( + "/api/v1/invitations/ownership-transfer/accept", + { body: { token: input.token } } + ); + + if (!data?.data) { + throw new ApiError(0, { + message: "Empty ownership-transfer accept response" + }); + } + + return data.data; + }, + onSuccess: async () => { + /* + * Accepting promotes the recipient to owner and demotes the prior + * owner; the role-aware UI surfaces live in /me + the membership + * cache. Drop both so the next paint sees the new role. + */ + await qc.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me }); + } + }); +} + +export function useDeclineOwnershipTransfer(): UseMutationResult< + IOwnershipTransfer, + unknown, + { token: string } +> { + return useMutation({ + mutationFn: async (input: { token: string }) => { + const { data } = await apiClient.POST( + "/api/v1/invitations/ownership-transfer/decline", + { body: { token: input.token } } + ); + + if (!data?.data) { + throw new ApiError(0, { + message: "Empty ownership-transfer decline response" + }); + } + + return data.data; + } + }); +} diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.constants.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.constants.ts new file mode 100644 index 00000000..a4355c8f --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.constants.ts @@ -0,0 +1,2 @@ +export const POST_ACCEPT_PATH = "/account/invitations"; +export const FAILURE_REDIRECT_PATH = "/login"; diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.test.tsx b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.test.tsx new file mode 100644 index 00000000..6673b085 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import type * as ReactRouterDom from "react-router-dom"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInvitationAcceptPage } from "./InvitationAcceptPage.hooks"; + +const navigateMock = vi.hoisted(() => vi.fn()); +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ apiClient: apiMock })); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual("react-router-dom"); + + return { ...actual, useNavigate: () => navigateMock }; +}); + +function makeWrapper(initialUrl: string) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { Wrapper }; +} + +beforeEach(() => { + apiMock.POST.mockReset(); + navigateMock.mockReset(); +}); + +describe("useInvitationAcceptPage", () => { + it("transitions to 'missing-token' when ?token= is absent", async () => { + const { Wrapper } = makeWrapper("/invitations/accept"); + const { result } = renderHook(() => useInvitationAcceptPage(), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.status).toBe("missing-token"); + }); + + expect(apiMock.POST).not.toHaveBeenCalled(); + }); + + it("POSTs the token + navigates on a successful accept", async () => { + apiMock.POST.mockResolvedValueOnce({ data: { success: true } }); + + const { Wrapper } = makeWrapper("/invitations/accept?token=tok-1"); + + renderHook(() => useInvitationAcceptPage(), { wrapper: Wrapper }); + + await waitFor(() => { + expect(apiMock.POST).toHaveBeenCalledWith("/api/v1/invitations/accept", { + body: { token: "tok-1" } + }); + }); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.ts new file mode 100644 index 00000000..3d093379 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { ApiError } from "@/lib/api/ApiError"; +import { apiClient } from "@/lib/api/client"; +import { logger } from "@/lib/logger/logger"; + +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; + +import { + FAILURE_REDIRECT_PATH, + POST_ACCEPT_PATH +} from "./InvitationAcceptPage.constants"; +import type { + IInvitationAcceptPageView, + InvitationAcceptStatus +} from "./InvitationAcceptPage.types"; + +/** + * Email-link landing page for invitations. Auto-fires the accept call + * because the entire intent of the link is "accept this invitation." + * The route is wrapped in ProtectedRoute, so the user is already + * authenticated by the time this hook runs — anonymous clicks land on + * /login first and come back here with the `?token=...` preserved. + * + * Mirrors VerifyEmailPage's "fire once per mount, no RTK retry" shape; + * a retry on a single-use token would always 4xx after the first call. + */ +export function useInvitationAcceptPage(): IInvitationAcceptPageView { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const [status, setStatus] = useState("accepting"); + const [errorMessage, setErrorMessage] = useState(null); + const firedRef = useRef(false); + + useEffect(() => { + if (firedRef.current) { + return; + } + + const token = searchParams.get("token"); + + if (token === null || token === "") { + setStatus("missing-token"); + + return; + } + + firedRef.current = true; + + void (async (): Promise => { + try { + await apiClient.POST("/api/v1/invitations/accept", { + body: { token } + }); + await qc.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.me }); + setStatus("success"); + logger.info({ event: "accounts.invitation_accepted" }); + await navigate(POST_ACCEPT_PATH, { replace: true }); + } catch (error) { + if (error instanceof ApiError && error.isValidation) { + setStatus("invalid-token"); + logger.warn({ event: "accounts.invitation_accept_invalid" }); + + return; + } + + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : null); + logger.warn({ + event: "accounts.invitation_accept_failed", + status: error instanceof ApiError ? error.status : undefined + }); + } + })(); + }, [navigate, qc, searchParams]); + + return { status, errorMessage }; +} + +export { FAILURE_REDIRECT_PATH }; diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.stories.tsx b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.stories.tsx new file mode 100644 index 00000000..7a3e6df6 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.stories.tsx @@ -0,0 +1,47 @@ +import type { JSX } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import InvitationAcceptPage from "./InvitationAcceptPage"; + +const meta: Meta = { + title: "Features/Accounts/InvitationAcceptPage", + component: InvitationAcceptPage, + parameters: { + layout: "fullscreen" + } +}; + +export default meta; + +type IStory = StoryObj; + +function withRoute(entry: string) { + return (Story: () => JSX.Element) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + return ( + + + + + + ); + }; +} + +export const Default: IStory = { + name: "Accepting (with token, request pending)", + decorators: [withRoute("/invitations/accept?token=demo-token-32")] +}; + +export const MissingToken: IStory = { + decorators: [withRoute("/invitations/accept")] +}; diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.test.tsx b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.test.tsx new file mode 100644 index 00000000..cf3cb0e7 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.test.tsx @@ -0,0 +1,101 @@ +import { MemoryRouter } from "react-router-dom"; +import type * as ReactRouterDom from "react-router-dom"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError } from "@/lib/api/ApiError"; + +import InvitationAcceptPage from "./InvitationAcceptPage"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ + apiClient: apiMock +})); + +const navigateMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual("react-router-dom"); + + return { + ...actual, + useNavigate: () => navigateMock + }; +}); + +function renderAt(url: string) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + + return render( + + + + + + ); +} + +beforeEach(() => { + apiMock.POST.mockReset(); + navigateMock.mockReset(); +}); + +describe("InvitationAcceptPage", () => { + it("POSTs the token immediately on mount and renders the accepting state", () => { + apiMock.POST.mockReturnValue( + new Promise(() => { + /* never resolves: holds the accepting state in view */ + }) + ); + renderAt("/invitations/accept?token=test-invitation-token-fixture"); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(apiMock.POST).toHaveBeenCalledWith("/api/v1/invitations/accept", { + body: { token: "test-invitation-token-fixture" } + }); + }); + + it("renders missing-token state when no ?token= is present", () => { + renderAt("/invitations/accept"); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect( + screen.getByText("accounts.invitations.accept.missingToken") + ).toBeInTheDocument(); + expect(apiMock.POST).not.toHaveBeenCalled(); + }); + + it("renders invalid-token state when the API responds 4xx", async () => { + apiMock.POST.mockRejectedValueOnce( + new ApiError(400, { message: "Invalid token" }) + ); + renderAt("/invitations/accept?token=expired"); + + await waitFor(() => { + expect( + screen.getByText("accounts.invitations.accept.errorInvalid") + ).toBeInTheDocument(); + }); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it("navigates to /account/invitations on success", async () => { + apiMock.POST.mockResolvedValueOnce({ data: undefined, response: {} }); + renderAt("/invitations/accept?token=valid-token"); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith("/account/invitations", { + replace: true + }); + }); + }); +}); diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.tsx b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.tsx new file mode 100644 index 00000000..c9d4da4b --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.tsx @@ -0,0 +1,97 @@ +import type { FC } from "react"; +import { Link } from "react-router-dom"; + +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; + +import { + FAILURE_REDIRECT_PATH, + useInvitationAcceptPage +} from "./InvitationAcceptPage.hooks"; +import { resolveErrorMessage } from "./InvitationAcceptPage.utils"; + +const InvitationAcceptPage: FC = () => { + const { t } = useTranslation(); + const { status, errorMessage } = useInvitationAcceptPage(); + + const heading = t("accounts.invitations.accept.pageTitle"); + + if (status === "accepting") { + return ( +
+ + + {heading} · {t("app.name")} + + +
+
+

+ {t("accounts.invitations.accept.pending")} +

+
+
+ ); + } + + if (status === "success") { + return ( +
+ + + {heading} · {t("app.name")} + + +

+ {t("accounts.invitations.accept.success")} +

+
+ ); + } + + const message = resolveErrorMessage(status, errorMessage, t); + + return ( +
+ + + {heading} · {t("app.name")} + + +
+ + {t("app.name")} + +

+ {heading} +

+

+ {message} +

+ +
+
+ ); +}; + +InvitationAcceptPage.displayName = "InvitationAcceptPage"; + +export default InvitationAcceptPage; +export { InvitationAcceptPage }; diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.types.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.types.ts new file mode 100644 index 00000000..ce7f8be4 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.types.ts @@ -0,0 +1,11 @@ +export type InvitationAcceptStatus = + | "accepting" + | "success" + | "missing-token" + | "invalid-token" + | "error"; + +export interface IInvitationAcceptPageView { + status: InvitationAcceptStatus; + errorMessage: string | null; +} diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.test.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.test.ts new file mode 100644 index 00000000..549340f1 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { resolveErrorMessage } from "./InvitationAcceptPage.utils"; + +const identityT = (key: string): string => key; + +describe("resolveErrorMessage", () => { + it("returns the missingToken copy when status is 'missing-token'", () => { + expect(resolveErrorMessage("missing-token", null, identityT)).toBe( + "accounts.invitations.accept.missingToken" + ); + }); + + it("returns the errorInvalid copy when status is 'invalid-token'", () => { + expect(resolveErrorMessage("invalid-token", null, identityT)).toBe( + "accounts.invitations.accept.errorInvalid" + ); + }); + + it("returns the server message verbatim for generic errors", () => { + expect(resolveErrorMessage("error", "Server is down", identityT)).toBe( + "Server is down" + ); + }); + + it("falls back to the generic copy when no server message is present", () => { + expect(resolveErrorMessage("error", null, identityT)).toBe( + "accounts.invitations.accept.errorGeneric" + ); + }); +}); diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.ts new file mode 100644 index 00000000..a50e59bc --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.ts @@ -0,0 +1,17 @@ +import type { InvitationAcceptStatus } from "./InvitationAcceptPage.types"; + +export function resolveErrorMessage( + status: InvitationAcceptStatus, + errorMessage: string | null, + t: (key: string) => string +): string { + if (status === "missing-token") { + return t("accounts.invitations.accept.missingToken"); + } + + if (status === "invalid-token") { + return t("accounts.invitations.accept.errorInvalid"); + } + + return errorMessage ?? t("accounts.invitations.accept.errorGeneric"); +} diff --git a/apps/ui/src/features/accounts/components/InvitationAcceptPage/index.ts b/apps/ui/src/features/accounts/components/InvitationAcceptPage/index.ts new file mode 100644 index 00000000..a4382060 --- /dev/null +++ b/apps/ui/src/features/accounts/components/InvitationAcceptPage/index.ts @@ -0,0 +1,2 @@ +export { default as InvitationAcceptPage } from "./InvitationAcceptPage"; +export * from "./InvitationAcceptPage.types"; diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.test.tsx b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.test.tsx new file mode 100644 index 00000000..126ba1c9 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.test.tsx @@ -0,0 +1,105 @@ +import type { ReactNode } from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; +import type { IMe } from "@/features/auth/Auth.types"; + +import { ACCOUNTS_QUERY_KEYS } from "../../Accounts.constants"; +import { useJoinRequestsPage } from "./JoinRequestsPage.hooks"; + +const approveMock = vi.hoisted(() => vi.fn()); +const denyMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../JoinRequests.mutations", () => ({ + useApproveJoinRequest: () => ({ mutate: approveMock, isPending: false }), + useDenyJoinRequest: () => ({ mutate: denyMock, isPending: false }) +})); + +function makeWrapper(seed: (client: QueryClient) => void) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + + seed(client); + + const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { Wrapper }; +} + +beforeEach(() => { + approveMock.mockReset(); + denyMock.mockReset(); +}); + +const me: IMe = { + user: { + id: "u1", + email: "owner@example.com", + firstName: "Demo", + lastName: "User", + emailVerified: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + }, + account: { id: "acc-1", name: "Acme" }, + role: "owner", + memberships: [{ accountId: "acc-1", accountName: "Acme", role: "owner" }], + features: { can_export: true, can_invite_team: true, max_seats: 10 }, + capabilities: { billing: false, notificationsSse: false, webPush: false }, + authProviders: ["email"], + hasPasswordLogin: true +}; + +describe("useJoinRequestsPage", () => { + it("returns the seeded join requests once /me + list are both warm", async () => { + const { Wrapper } = makeWrapper((client) => { + client.setQueryData(AUTH_QUERY_KEYS.me, me); + client.setQueryData(ACCOUNTS_QUERY_KEYS.joinRequests("acc-1"), [ + { + id: "jr1", + accountId: "acc-1", + userId: "u-x", + email: "x@example.com", + status: "pending", + createdAt: "2026-06-01T00:00:00Z", + decidedAt: null, + decidedByUserId: null + } + ]); + }); + + const { result } = renderHook(() => useJoinRequestsPage(), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.requests).toHaveLength(1); + }); + + it("forwards onApprove to the mutation hook with the chosen requestId", () => { + const { Wrapper } = makeWrapper((client) => { + client.setQueryData(AUTH_QUERY_KEYS.me, me); + client.setQueryData(ACCOUNTS_QUERY_KEYS.joinRequests("acc-1"), []); + }); + + const { result } = renderHook(() => useJoinRequestsPage(), { + wrapper: Wrapper + }); + + result.current.onApprove("jr1"); + + expect(approveMock).toHaveBeenCalledWith( + { requestId: "jr1" }, + expect.anything() + ); + }); +}); diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.ts b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.ts new file mode 100644 index 00000000..e2887afd --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.ts @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; + +import { useMe } from "@/features/auth/Auth.queries"; + +import { + useApproveJoinRequest, + useDenyJoinRequest +} from "../../JoinRequests.mutations"; +import { useJoinRequests } from "../../JoinRequests.queries"; +import type { IJoinRequestsPageView } from "./JoinRequestsPage.types"; + +/** + * Reviewer-side join-request inbox. The account id comes from the + * active membership in `/me` — the API enforces role on every + * mutation, so we don't gate the page in the hook; the empty state + * just renders for accounts where the user isn't an owner/admin. + */ +export function useJoinRequestsPage(): IJoinRequestsPageView { + const me = useMe(); + const accountId = me.data?.account.id; + const list = useJoinRequests(accountId); + const approveMutation = useApproveJoinRequest(accountId); + const denyMutation = useDenyJoinRequest(accountId); + const [pendingActionId, setPendingActionId] = useState(null); + + const onApprove = useCallback( + (requestId: string): void => { + setPendingActionId(requestId); + approveMutation.mutate( + { requestId }, + { + onSettled: () => { + setPendingActionId(null); + } + } + ); + }, + [approveMutation] + ); + + const onDeny = useCallback( + (requestId: string): void => { + setPendingActionId(requestId); + denyMutation.mutate( + { requestId }, + { + onSettled: () => { + setPendingActionId(null); + } + } + ); + }, + [denyMutation] + ); + + return { + isLoading: me.isPending || list.isPending, + isError: list.isError, + requests: list.data ?? [], + onApprove, + onDeny, + pendingActionId + }; +} diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.stories.tsx b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.stories.tsx new file mode 100644 index 00000000..5a1c3899 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.stories.tsx @@ -0,0 +1,105 @@ +import type { JSX } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; + +import { ACCOUNTS_QUERY_KEYS } from "../../Accounts.constants"; +import JoinRequestsPage from "./JoinRequestsPage"; + +const meta: Meta = { + title: "Features/Accounts/JoinRequestsPage", + component: JoinRequestsPage, + parameters: { layout: "fullscreen" } +}; + +export default meta; + +type IStory = StoryObj; + +const seedMe = (client: QueryClient): void => { + client.setQueryData(AUTH_QUERY_KEYS.me, { + status: "authed", + user: { + id: "u1", + email: "owner@example.com", + firstName: "O", + lastName: "Wner", + emailVerified: true + }, + account: { id: "acc-1", name: "Acme" }, + role: "owner", + features: { can_invite_team: true } + }); +}; + +const seedRequests = ( + client: QueryClient, + rows: readonly { + id: string; + accountId: string; + userId: string; + email: string; + status: "pending" | "approved" | "denied"; + createdAt: string; + decidedAt: string | null; + decidedByUserId: string | null; + }[] +): void => { + client.setQueryData(ACCOUNTS_QUERY_KEYS.joinRequests("acc-1"), rows); +}; + +function withSeed( + prep: (client: QueryClient) => void +): (Story: () => JSX.Element) => JSX.Element { + return (Story) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + prep(client); + + return ( + + + + + + ); + }; +} + +export const Default: IStory = { + name: "With pending requests", + decorators: [ + withSeed((client) => { + seedMe(client); + seedRequests(client, [ + { + id: "jr1", + accountId: "acc-1", + userId: "u-x", + email: "new-hire@example.com", + status: "pending", + createdAt: "2026-06-01T00:00:00Z", + decidedAt: null, + decidedByUserId: null + } + ]); + }) + ] +}; + +export const Empty: IStory = { + decorators: [ + withSeed((client) => { + seedMe(client); + seedRequests(client, []); + }) + ] +}; diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.test.tsx b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.test.tsx new file mode 100644 index 00000000..e107ad42 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.test.tsx @@ -0,0 +1,123 @@ +import { MemoryRouter } from "react-router-dom"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { HelmetProvider } from "react-helmet-async"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it, vi } from "vitest"; + +import { buildAbility } from "@/lib/acl/ability"; +import { AbilityContext } from "@/lib/acl/acl.context"; +import { i18n } from "@/lib/i18n/config"; + +import { AUTH_QUERY_KEYS } from "@/features/auth/Auth.constants"; +import type { IMe } from "@/features/auth/Auth.types"; + +import { ACCOUNTS_QUERY_KEYS } from "../../Accounts.constants"; +import type { IJoinRequest } from "../../Accounts.types"; +import { JoinRequestsPage } from "./JoinRequestsPage"; + +const approveMock = vi.hoisted(() => vi.fn()); +const denyMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../JoinRequests.mutations", () => ({ + useApproveJoinRequest: () => ({ mutate: approveMock, isPending: false }), + useDenyJoinRequest: () => ({ mutate: denyMock, isPending: false }) +})); + +const baseUser: IMe["user"] = { + id: "u1", + email: "owner@example.com", + firstName: "Demo", + lastName: "User", + emailVerified: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" +}; + +function buildMe(): IMe { + const me: IMe = { + user: baseUser, + account: { id: "acc-1", name: "Acme" }, + role: "owner", + memberships: [{ accountId: "acc-1", accountName: "Acme", role: "owner" }], + features: { + can_export: true, + can_invite_team: true, + max_seats: 10 + }, + capabilities: { + billing: false, + notificationsSse: false, + webPush: false + }, + authProviders: ["email"], + hasPasswordLogin: true + }; + + return me; +} + +function renderPage(rows: IJoinRequest[]): void { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + const me = buildMe(); + + client.setQueryData(AUTH_QUERY_KEYS.me, me); + client.setQueryData(ACCOUNTS_QUERY_KEYS.joinRequests("acc-1"), rows); + + render( + + + + + + + + + + + + ); +} + +describe("JoinRequestsPage", () => { + it("renders the empty state when no pending requests exist", () => { + renderPage([]); + + expect(screen.getByText(/no pending join requests/i)).toBeInTheDocument(); + }); + + it("renders one row per pending request and skips already-decided ones", () => { + renderPage([ + { + id: "jr1", + accountId: "acc-1", + userId: "u-x", + email: "new-hire@example.com", + status: "pending", + createdAt: "2026-06-01T00:00:00Z", + decidedAt: null, + decidedByUserId: null + }, + { + id: "jr2", + accountId: "acc-1", + userId: "u-y", + email: "former@example.com", + status: "approved", + createdAt: "2026-05-30T00:00:00Z", + decidedAt: "2026-05-31T00:00:00Z", + decidedByUserId: "u1" + } + ]); + + const rows = screen.getAllByTestId("join-request-row"); + + expect(rows).toHaveLength(1); + expect(rows[0]).toHaveAttribute("data-request-id", "jr1"); + }); +}); diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx new file mode 100644 index 00000000..7845ad0a --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx @@ -0,0 +1,113 @@ +import type { FC } from "react"; + +import { useTranslation } from "react-i18next"; + +import { AppPage } from "@/components/core/AppPage"; +import { Button } from "@/components/ui/button"; + +import { useJoinRequestsPage } from "./JoinRequestsPage.hooks"; +import { formatRequestedAt, makeIdHandler } from "./JoinRequestsPage.utils"; + +const JoinRequestsPage: FC = () => { + const { t } = useTranslation(); + const { isLoading, isError, requests, onApprove, onDeny, pendingActionId } = + useJoinRequestsPage(); + + const approveHandler = makeIdHandler(onApprove); + const denyHandler = makeIdHandler(onDeny); + + const renderRows = requests + .filter((row) => row.status === "pending") + .map((row) => ( + + {row.email} + + {formatRequestedAt(row.createdAt)} + + + + + + + )); + + return ( + +
+ {isLoading ? ( +

+ {t("accounts.joinRequests.loading")} +

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

+ {t("accounts.joinRequests.error")} +

+ ) : null} + + {!isLoading && !isError && renderRows.length === 0 ? ( +

+ {t("accounts.joinRequests.empty")} +

+ ) : null} + + {renderRows.length > 0 ? ( + + + + + + + + + {renderRows} +
+ {t("accounts.joinRequests.columns.email")} + + {t("accounts.joinRequests.columns.requested")} + + {t("accounts.joinRequests.columns.actions")} +
+ ) : null} +
+
+ ); +}; + +JoinRequestsPage.displayName = "JoinRequestsPage"; + +export default JoinRequestsPage; +export { JoinRequestsPage }; diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.types.ts b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.types.ts new file mode 100644 index 00000000..9ecd2665 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.types.ts @@ -0,0 +1,10 @@ +import type { IJoinRequest } from "../../Accounts.types"; + +export interface IJoinRequestsPageView { + isLoading: boolean; + isError: boolean; + requests: IJoinRequest[]; + onApprove: (requestId: string) => void; + onDeny: (requestId: string) => void; + pendingActionId: string | null; +} diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.test.ts b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.test.ts new file mode 100644 index 00000000..5ed6aea0 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { formatRequestedAt } from "./JoinRequestsPage.utils"; + +describe("formatRequestedAt", () => { + it("formats an ISO timestamp into a short locale-aware date", () => { + const result = formatRequestedAt("2026-06-01T00:00:00Z"); + + /* + * Locale isn't pinned — just assert the formatter ran and returned + * a non-empty string different from the raw ISO. + */ + expect(result).not.toBe(""); + expect(result).not.toBe("2026-06-01T00:00:00Z"); + }); + + it("returns the raw input when Date parsing fails", () => { + expect(formatRequestedAt("not-a-date")).toBe("Invalid Date"); + }); +}); diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.ts b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.ts new file mode 100644 index 00000000..3e646cb4 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.ts @@ -0,0 +1,31 @@ +/** + * Curried handler factory — `makeIdHandler(onApprove)(row.id)` returns + * a stable `() => onApprove(row.id)` closure that the row Button can + * reference without rebuilding an inline arrow inside the JSX. + */ +export function makeIdHandler( + fn: (id: string) => void +): (id: string) => () => void { + return (id: string) => (): void => { + fn(id); + }; +} + +export function formatRequestedAt(iso: string): string { + try { + const date = new Date(iso); + + /* + * Locale-aware short date — the underlying ISO is the source of + * truth, this is purely a display aid. `toLocaleDateString` with + * no locale arg honours the browser's preference. + */ + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric" + }); + } catch { + return iso; + } +} diff --git a/apps/ui/src/features/accounts/components/JoinRequestsPage/index.ts b/apps/ui/src/features/accounts/components/JoinRequestsPage/index.ts new file mode 100644 index 00000000..7954cb03 --- /dev/null +++ b/apps/ui/src/features/accounts/components/JoinRequestsPage/index.ts @@ -0,0 +1,2 @@ +export { default as JoinRequestsPage } from "./JoinRequestsPage"; +export * from "./JoinRequestsPage.types"; diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.constants.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.constants.ts new file mode 100644 index 00000000..2d3e8507 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.constants.ts @@ -0,0 +1 @@ +export const POST_ACTION_PATH = "/account/settings"; diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.test.tsx b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.test.tsx new file mode 100644 index 00000000..6f9b386f --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.test.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useOwnershipTransferAcceptPage } from "./OwnershipTransferAcceptPage.hooks"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ apiClient: apiMock })); + +function makeWrapper(initialUrl: string) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return { Wrapper }; +} + +beforeEach(() => { + apiMock.POST.mockReset(); +}); + +describe("useOwnershipTransferAcceptPage", () => { + it("transitions to 'missing-token' when ?token= is absent", async () => { + const { Wrapper } = makeWrapper("/account/ownership-transfer/accept"); + const { result } = renderHook(() => useOwnershipTransferAcceptPage(), { + wrapper: Wrapper + }); + + await waitFor(() => { + expect(result.current.status).toBe("missing-token"); + }); + }); + + it("starts in 'idle' when a token is present and fires Accept on demand", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: { id: "ot1" }, timestamp: "t" } + }); + + const { Wrapper } = makeWrapper( + "/account/ownership-transfer/accept?token=tok" + ); + const { result } = renderHook(() => useOwnershipTransferAcceptPage(), { + wrapper: Wrapper + }); + + expect(result.current.status).toBe("idle"); + + act(() => { + result.current.onAccept(); + }); + + await waitFor(() => { + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/accept", + { body: { token: "tok" } } + ); + }); + await waitFor(() => { + expect(result.current.status).toBe("accepted"); + }); + }); + + it("fires Decline when the user picks that path", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: { id: "ot1" }, timestamp: "t" } + }); + + const { Wrapper } = makeWrapper( + "/account/ownership-transfer/accept?token=tok" + ); + const { result } = renderHook(() => useOwnershipTransferAcceptPage(), { + wrapper: Wrapper + }); + + act(() => { + result.current.onDecline(); + }); + + await waitFor(() => { + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/decline", + { body: { token: "tok" } } + ); + }); + await waitFor(() => { + expect(result.current.status).toBe("declined"); + }); + }); +}); diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.ts new file mode 100644 index 00000000..456aada3 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.ts @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { ApiError } from "@/lib/api/ApiError"; +import { logger } from "@/lib/logger/logger"; + +import { + useAcceptOwnershipTransfer, + useDeclineOwnershipTransfer +} from "@/features/accounts/OwnershipTransfers.mutations"; + +import type { + IOwnershipTransferPageView, + OwnershipTransferStatus +} from "./OwnershipTransferAcceptPage.types"; + +/** + * Email-link landing for ownership transfers. Unlike invitations we + * DON'T auto-fire — accepting transfers ownership of a whole account, + * so a stray click on the email shouldn't perform it. The page renders + * Accept / Decline buttons and only acts on explicit user input. + */ +export function useOwnershipTransferAcceptPage(): IOwnershipTransferPageView { + const [searchParams] = useSearchParams(); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const acceptMutation = useAcceptOwnershipTransfer(); + const declineMutation = useDeclineOwnershipTransfer(); + + useEffect(() => { + const token = searchParams.get("token"); + + if (token === null || token === "") { + setStatus("missing-token"); + } + }, [searchParams]); + + const token = searchParams.get("token"); + + const run = async ( + action: "accept" | "decline", + fire: () => Promise + ): Promise => { + if (token === null || token === "") { + setStatus("missing-token"); + + return; + } + + setStatus(action === "accept" ? "accepting" : "declining"); + setErrorMessage(null); + + try { + await fire(); + + if (action === "accept") { + setStatus("accepted"); + logger.info({ event: "accounts.ownership_transfer_accepted" }); + } else { + setStatus("declined"); + logger.info({ event: "accounts.ownership_transfer_declined" }); + } + } catch (error) { + if ( + error instanceof ApiError && + (error.isValidation || error.status === 404) + ) { + setStatus("invalid-token"); + logger.warn({ + event: "accounts.ownership_transfer_invalid", + action + }); + + return; + } + + setStatus("error"); + setErrorMessage(error instanceof Error ? error.message : null); + logger.warn({ + event: "accounts.ownership_transfer_failed", + action, + status: error instanceof ApiError ? error.status : undefined + }); + } + }; + + return { + status, + errorMessage, + onAccept: () => { + if (token !== null && token !== "") { + void run("accept", () => acceptMutation.mutateAsync({ token })); + } + }, + onDecline: () => { + if (token !== null && token !== "") { + void run("decline", () => declineMutation.mutateAsync({ token })); + } + } + }; +} diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.stories.tsx b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.stories.tsx new file mode 100644 index 00000000..37e48935 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.stories.tsx @@ -0,0 +1,49 @@ +import type { JSX } from "react"; +import { MemoryRouter } from "react-router-dom"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import OwnershipTransferAcceptPage from "./OwnershipTransferAcceptPage"; + +const meta: Meta = { + title: "Features/Accounts/OwnershipTransferAcceptPage", + component: OwnershipTransferAcceptPage, + parameters: { + layout: "fullscreen" + } +}; + +export default meta; + +type IStory = StoryObj; + +function withRoute(entry: string) { + return (Story: () => JSX.Element) => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + return ( + + + + + + ); + }; +} + +export const Default: IStory = { + name: "Idle (Accept / Decline buttons visible)", + decorators: [ + withRoute("/account/ownership-transfer/accept?token=demo-token-32") + ] +}; + +export const MissingToken: IStory = { + decorators: [withRoute("/account/ownership-transfer/accept")] +}; diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.test.tsx b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.test.tsx new file mode 100644 index 00000000..af025cd5 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.test.tsx @@ -0,0 +1,92 @@ +import { MemoryRouter } from "react-router-dom"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import OwnershipTransferAcceptPage from "./OwnershipTransferAcceptPage"; + +const apiMock = vi.hoisted(() => ({ + GET: vi.fn(), + POST: vi.fn() +})); + +vi.mock("@/lib/api/client", () => ({ + apiClient: apiMock +})); + +function renderAt(url: string) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + + return render( + + + + + + ); +} + +beforeEach(() => { + apiMock.POST.mockReset(); +}); + +describe("OwnershipTransferAcceptPage", () => { + it("renders the idle state with Accept and Decline buttons when token is present", () => { + renderAt("/account/ownership-transfer/accept?token=demo-token-32"); + + expect( + screen.getByRole("button", { name: "accounts.ownershipTransfer.accept" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "accounts.ownershipTransfer.decline" }) + ).toBeInTheDocument(); + expect(apiMock.POST).not.toHaveBeenCalled(); + }); + + it("renders missing-token state when no ?token= is present", () => { + renderAt("/account/ownership-transfer/accept"); + + expect( + screen.getByText("accounts.ownershipTransfer.missingToken") + ).toBeInTheDocument(); + }); + + it("fires the accept endpoint on Accept click", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: { id: "ot1" }, timestamp: "t" } + }); + renderAt("/account/ownership-transfer/accept?token=tok"); + + fireEvent.click( + screen.getByRole("button", { name: "accounts.ownershipTransfer.accept" }) + ); + + await waitFor(() => { + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/accept", + { body: { token: "tok" } } + ); + }); + }); + + it("fires the decline endpoint on Decline click", async () => { + apiMock.POST.mockResolvedValueOnce({ + data: { success: true, data: { id: "ot1" }, timestamp: "t" } + }); + renderAt("/account/ownership-transfer/accept?token=tok"); + + fireEvent.click( + screen.getByRole("button", { name: "accounts.ownershipTransfer.decline" }) + ); + + await waitFor(() => { + expect(apiMock.POST).toHaveBeenCalledWith( + "/api/v1/invitations/ownership-transfer/decline", + { body: { token: "tok" } } + ); + }); + }); +}); diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.tsx b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.tsx new file mode 100644 index 00000000..f0091730 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.tsx @@ -0,0 +1,92 @@ +import type { FC } from "react"; +import { Link } from "react-router-dom"; + +import { Helmet } from "react-helmet-async"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; + +import { POST_ACTION_PATH } from "./OwnershipTransferAcceptPage.constants"; +import { useOwnershipTransferAcceptPage } from "./OwnershipTransferAcceptPage.hooks"; +import { resolveStatusMessage } from "./OwnershipTransferAcceptPage.utils"; + +const OwnershipTransferAcceptPage: FC = () => { + const { t } = useTranslation(); + const { status, errorMessage, onAccept, onDecline } = + useOwnershipTransferAcceptPage(); + + const heading = t("accounts.ownershipTransfer.pageTitle"); + const message = resolveStatusMessage(status, errorMessage, t); + + const isTerminal = + status === "accepted" || + status === "declined" || + status === "missing-token" || + status === "invalid-token" || + status === "error"; + + const isWorking = status === "accepting" || status === "declining"; + + return ( +
+ + + {heading} · {t("app.name")} + + +
+ + {t("app.name")} + +

+ {heading} +

+

+ {message} +

+ + {isTerminal ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); +}; + +OwnershipTransferAcceptPage.displayName = "OwnershipTransferAcceptPage"; + +export default OwnershipTransferAcceptPage; +export { OwnershipTransferAcceptPage }; diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.types.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.types.ts new file mode 100644 index 00000000..cf40b020 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.types.ts @@ -0,0 +1,16 @@ +export type OwnershipTransferStatus = + | "idle" + | "accepting" + | "declining" + | "accepted" + | "declined" + | "missing-token" + | "invalid-token" + | "error"; + +export interface IOwnershipTransferPageView { + status: OwnershipTransferStatus; + errorMessage: string | null; + onAccept: () => void; + onDecline: () => void; +} diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.test.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.test.ts new file mode 100644 index 00000000..7cb96b01 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { resolveStatusMessage } from "./OwnershipTransferAcceptPage.utils"; + +const identityT = (key: string): string => key; + +describe("resolveStatusMessage", () => { + it("returns the missingToken copy when no token is present", () => { + expect(resolveStatusMessage("missing-token", null, identityT)).toBe( + "accounts.ownershipTransfer.missingToken" + ); + }); + + it("returns the errorInvalid copy on a 4xx from the API", () => { + expect(resolveStatusMessage("invalid-token", null, identityT)).toBe( + "accounts.ownershipTransfer.errorInvalid" + ); + }); + + it("returns the success-accepted copy after a successful accept", () => { + expect(resolveStatusMessage("accepted", null, identityT)).toBe( + "accounts.ownershipTransfer.successAccepted" + ); + }); + + it("returns the success-declined copy after a successful decline", () => { + expect(resolveStatusMessage("declined", null, identityT)).toBe( + "accounts.ownershipTransfer.successDeclined" + ); + }); + + it("returns the server-provided message verbatim for generic errors", () => { + expect(resolveStatusMessage("error", "boom", identityT)).toBe("boom"); + }); + + it("falls back to the generic error when no server message is present", () => { + expect(resolveStatusMessage("error", null, identityT)).toBe( + "accounts.ownershipTransfer.errorGeneric" + ); + }); + + it("returns the intro copy in idle / pending states", () => { + expect(resolveStatusMessage("idle", null, identityT)).toBe( + "accounts.ownershipTransfer.intro" + ); + expect(resolveStatusMessage("accepting", null, identityT)).toBe( + "accounts.ownershipTransfer.intro" + ); + expect(resolveStatusMessage("declining", null, identityT)).toBe( + "accounts.ownershipTransfer.intro" + ); + }); +}); diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.ts new file mode 100644 index 00000000..d5deab51 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.ts @@ -0,0 +1,25 @@ +import type { OwnershipTransferStatus } from "./OwnershipTransferAcceptPage.types"; + +export function resolveStatusMessage( + status: OwnershipTransferStatus, + errorMessage: string | null, + t: (key: string) => string +): string { + switch (status) { + case "missing-token": + return t("accounts.ownershipTransfer.missingToken"); + case "invalid-token": + return t("accounts.ownershipTransfer.errorInvalid"); + case "accepted": + return t("accounts.ownershipTransfer.successAccepted"); + case "declined": + return t("accounts.ownershipTransfer.successDeclined"); + case "error": + return errorMessage ?? t("accounts.ownershipTransfer.errorGeneric"); + case "idle": + case "accepting": + case "declining": + default: + return t("accounts.ownershipTransfer.intro"); + } +} diff --git a/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/index.ts b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/index.ts new file mode 100644 index 00000000..5a14b538 --- /dev/null +++ b/apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/index.ts @@ -0,0 +1,2 @@ +export { default as OwnershipTransferAcceptPage } from "./OwnershipTransferAcceptPage"; +export * from "./OwnershipTransferAcceptPage.types"; diff --git a/apps/ui/src/lib/i18n/locales/de/common.json b/apps/ui/src/lib/i18n/locales/de/common.json index dfd726e5..cb0c7bbc 100644 --- a/apps/ui/src/lib/i18n/locales/de/common.json +++ b/apps/ui/src/lib/i18n/locales/de/common.json @@ -488,7 +488,47 @@ "roleTitle": "Diese Rolle kann keine Teammitglieder einladen", "roleBody": "Die aktive Mitgliedschaft ist viewer oder member, und die ACL gewährt `invite TeamMember` nur für owner und admin. Passe die Rollenregeln in `apps/ui/src/lib/acl/ability.ts` (und im Server-Spiegel `apps/api/src/lib/acl/ability.ts`) an, wenn weitere Rollen einladen dürfen sollen.", "roleDocsCta": "ACL & Feature-Auflösung" + }, + "accept": { + "pageTitle": "Einladung annehmen", + "pending": "Einladung wird angenommen…", + "success": "Geschafft. Du wirst zum Dashboard weitergeleitet.", + "missingToken": "Diesem Link fehlt ein Token. Öffne die letzte Einladungs-E-Mail und klicke dort auf die Schaltfläche.", + "errorInvalid": "Diese Einladung ist nicht mehr gültig. Bitte um eine neue.", + "errorGeneric": "Die Einladung konnte nicht angenommen werden. Versuche es erneut oder bitte um einen neuen Link.", + "backToLogin": "Zurück zur Anmeldung" } + }, + "ownershipTransfer": { + "pageTitle": "Eigentumsübertragung annehmen", + "intro": "Der aktuelle Inhaber dieses Kontos möchte es an dich übergeben. Mit der Annahme wirst du zum Inhaber; der bisherige Inhaber wird zum Admin.", + "missingToken": "Diesem Link fehlt ein Token. Öffne die letzte Übertragungs-E-Mail und klicke dort auf die Schaltfläche.", + "accept": "Eigentum übernehmen", + "decline": "Ablehnen", + "accepting": "Wird verarbeitet…", + "declining": "Wird verarbeitet…", + "successAccepted": "Du bist jetzt der Inhaber dieses Kontos.", + "successDeclined": "Du hast die Übertragung abgelehnt. Der aktuelle Inhaber wurde benachrichtigt.", + "errorInvalid": "Dieser Übertragungslink ist nicht mehr gültig.", + "errorGeneric": "Die Übertragung konnte nicht verarbeitet werden. Versuche es erneut.", + "goToAccount": "Zum Konto" + }, + "joinRequests": { + "pageTitle": "Beitrittsanfragen", + "pageSubtitle": "Offene Anfragen von Nutzern mit passender E-Mail-Domain.", + "navLabel": "Beitrittsanfragen", + "empty": "Keine offenen Beitrittsanfragen.", + "loading": "Beitrittsanfragen werden geladen…", + "error": "Beitrittsanfragen konnten nicht geladen werden. Versuche es erneut.", + "columns": { + "email": "E-Mail", + "requested": "Angefragt", + "actions": "Aktionen" + }, + "approve": "Annehmen", + "deny": "Ablehnen", + "approving": "Wird angenommen…", + "denying": "Wird abgelehnt…" } }, "consent": { diff --git a/apps/ui/src/lib/i18n/locales/en/common.json b/apps/ui/src/lib/i18n/locales/en/common.json index 60ad6885..ca007db1 100644 --- a/apps/ui/src/lib/i18n/locales/en/common.json +++ b/apps/ui/src/lib/i18n/locales/en/common.json @@ -467,7 +467,47 @@ "roleTitle": "This role can't invite teammates", "roleBody": "The active membership is a viewer or member, and the ACL only grants `invite TeamMember` to owner and admin. Adjust the role grants in `apps/ui/src/lib/acl/ability.ts` (and its server mirror in `apps/api/src/lib/acl/ability.ts`) if you want other roles to invite.", "roleDocsCta": "ACL & feature resolution" + }, + "accept": { + "pageTitle": "Accept invitation", + "pending": "Accepting your invitation…", + "success": "You're in. Heading to your dashboard.", + "missingToken": "This link is missing a token. Open the most recent invite email and click the button there.", + "errorInvalid": "This invitation link is no longer valid. Ask the inviter for a fresh one.", + "errorGeneric": "Couldn't accept the invitation. Try again, or ask the inviter for a fresh link.", + "backToLogin": "Back to login" } + }, + "ownershipTransfer": { + "pageTitle": "Accept ownership transfer", + "intro": "The current owner of this account wants to hand it to you. Accepting promotes you to owner; the current owner becomes an admin.", + "missingToken": "This link is missing a token. Open the most recent transfer email and click the button there.", + "accept": "Accept ownership", + "decline": "Decline", + "accepting": "Working…", + "declining": "Working…", + "successAccepted": "You're now the owner of this account.", + "successDeclined": "You declined the transfer. The current owner has been notified.", + "errorInvalid": "This transfer link is no longer valid.", + "errorGeneric": "Couldn't process the transfer. Try again.", + "goToAccount": "Go to your account" + }, + "joinRequests": { + "pageTitle": "Join requests", + "pageSubtitle": "Pending requests from users whose email domain matches this account.", + "navLabel": "Join requests", + "empty": "No pending join requests.", + "loading": "Loading join requests…", + "error": "Couldn't load join requests. Try again.", + "columns": { + "email": "Email", + "requested": "Requested", + "actions": "Actions" + }, + "approve": "Approve", + "deny": "Deny", + "approving": "Approving…", + "denying": "Denying…" } }, "errors": { diff --git a/apps/ui/src/lib/logger/logger.events.ts b/apps/ui/src/lib/logger/logger.events.ts index 88128d16..a69d7db6 100644 --- a/apps/ui/src/lib/logger/logger.events.ts +++ b/apps/ui/src/lib/logger/logger.events.ts @@ -4,6 +4,13 @@ * against this union. Sorted to make conflicts during PR rebase trivial. */ export const LOG_EVENTS = [ + "accounts.invitation_accept_failed", + "accounts.invitation_accept_invalid", + "accounts.invitation_accepted", + "accounts.ownership_transfer_accepted", + "accounts.ownership_transfer_declined", + "accounts.ownership_transfer_failed", + "accounts.ownership_transfer_invalid", "api.error_parse_failed", "app.bootstrapped", "auth.attempt",