From 0cdb206dc9a167b524653df4360d849829d903d1 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 1 Jun 2026 10:17:53 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(stack):=20close=20codex=20follow-up=20?= =?UTF-8?q?=E2=80=94=20auth=20races,=20CORS=20gaps,=20missing=20email-link?= =?UTF-8?q?=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Codex pass uncovered six gaps. This PR ships fixes for all six, each with its own guardrail or sibling test. ## Atomicity in single-use token flows * Email verification: token lookup moves INSIDE the transaction as an atomic `DELETE … RETURNING`. The user row is then locked `FOR UPDATE`. Two parallel verify calls used to both pass a pre-tx existence check and both call `provisionAfterVerification` (which itself does select-then-insert against no unique constraint) — the user ended up with two personal accounts. The atomic claim collapses the race to one winner. * Password reset: same shape — atomic `DELETE … RETURNING` inside the tx. The bcrypt hash still runs outside the tx so it doesn't pin a connection, but only the call that wins the DELETE proceeds to UPDATE the password. * Ownership transfer decline: now transactional with the same `FOR UPDATE` lock + repeated `acceptedAt/declinedAt IS NULL` predicates on the UPDATE that accept() uses. Without it, an accept/decline race could leave BOTH timestamps set. ## Billing customer creation * Stripe `customers.create` now passes `idempotencyKey: account-customer:{id}` so a double-click collapses to one customer record. The DB write-back is conditional (`stripeCustomerId IS NULL OR ''`) so a concurrent caller that beat us to the API doesn't get overwritten. Stripe holds idempotency keys for 24h — well beyond any plausible double-submit window. ## CORS for cross-origin Sentry tracing * Added `sentry-trace`, `baggage`, `traceparent` to `CORS_ALLOWED_HEADERS`. The Sentry browser SDK writes these on every outbound /api/* fetch when `browserTracingIntegration` is loaded; without the allowlist the cross- origin preflight rejects every traced call. * Added `exposeHeaders: ['x-request-id']` so the SPA's error toasts can still surface the request id when the API runs on a different host. * Sibling test in `security.constants.test.ts` locks both behaviours. ## Three missing email-link landing routes * `/invitations/accept` — auto-fires the accept call (mirrors VerifyEmailPage shape). ProtectedRoute-wrapped so anonymous clicks land on /login first and come back with the `?token=` preserved. * `/account/ownership-transfer/accept` — renders Accept and Decline buttons, NEVER auto-fires. Accepting transfers a whole account — a stray click on the email shouldn't perform it. The API also enforces recipient-JWT match. * `/account/requests` — reviewer-side inbox for domain-join requests with approve/deny actions. Lives inside AppShell. The API enforces role on every mutation; we don't add a second gate. Each new page ships with `.hooks.ts`, `.utils.ts`, `.types.ts`, `.constants.ts`, `.stories.tsx`, `.test.tsx`, `.utils.test.ts`, plus matching sibling `.hooks.test.tsx`. i18n keys added to both en and de common.json bundles. Log event names registered in `logger.events.ts`. ## Out of scope (P2/P3 from the Codex pass) * P2: OpenAPI fidelity — `success: boolean` → `t.Literal(true)`, dropping the multipart/text-plain default. Touches every route in the API and is pure schema hygiene; deferred to its own PR. * P3: UI/API authorization rule duplication — would need a generated rule matrix or parity test, neither of which fits a follow-up. ## Gates * API: 1033/1035 (2 DB-only skipped — local pg unavailable) * UI: 572/572 * Both apps: lint + lint-meta + typecheck + knip clean --- .../accounts/ownership-transfers.service.ts | 86 +++++++----- .../services/email-verification.service.ts | 67 +++++---- .../auth/services/password-reset.service.ts | 40 +++--- apps/api/src/api/billing/billing.service.ts | 41 +++++- .../src/config/security/security.constants.ts | 19 +++ apps/api/src/config/security/security.ts | 2 + .../security/security.constants.test.ts | 29 ++++ apps/ui/src/app/router/routes.tsx | 71 ++++++++++ .../features/accounts/Accounts.constants.ts | 4 +- .../src/features/accounts/Accounts.types.ts | 10 ++ .../accounts/Invitations.mutations.ts | 32 +++++ .../accounts/JoinRequests.mutations.test.tsx | 127 ++++++++++++++++++ .../accounts/JoinRequests.mutations.ts | 79 +++++++++++ .../accounts/JoinRequests.queries.test.tsx | 89 ++++++++++++ .../features/accounts/JoinRequests.queries.ts | 31 +++++ .../OwnershipTransfers.mutations.test.tsx | 102 ++++++++++++++ .../accounts/OwnershipTransfers.mutations.ts | 74 ++++++++++ .../InvitationAcceptPage.constants.ts | 2 + .../InvitationAcceptPage.hooks.test.tsx | 77 +++++++++++ .../InvitationAcceptPage.hooks.ts | 84 ++++++++++++ .../InvitationAcceptPage.stories.tsx | 47 +++++++ .../InvitationAcceptPage.test.tsx | 101 ++++++++++++++ .../InvitationAcceptPage.tsx | 97 +++++++++++++ .../InvitationAcceptPage.types.ts | 11 ++ .../InvitationAcceptPage.utils.test.ts | 31 +++++ .../InvitationAcceptPage.utils.ts | 17 +++ .../components/InvitationAcceptPage/index.ts | 2 + .../JoinRequestsPage.hooks.test.tsx | 105 +++++++++++++++ .../JoinRequestsPage.hooks.ts | 64 +++++++++ .../JoinRequestsPage.stories.tsx | 105 +++++++++++++++ .../JoinRequestsPage.test.tsx | 123 +++++++++++++++++ .../JoinRequestsPage/JoinRequestsPage.tsx | 113 ++++++++++++++++ .../JoinRequestsPage.types.ts | 10 ++ .../JoinRequestsPage.utils.test.ts | 20 +++ .../JoinRequestsPage.utils.ts | 31 +++++ .../components/JoinRequestsPage/index.ts | 2 + .../OwnershipTransferAcceptPage.constants.ts | 1 + ...OwnershipTransferAcceptPage.hooks.test.tsx | 107 +++++++++++++++ .../OwnershipTransferAcceptPage.hooks.ts | 101 ++++++++++++++ .../OwnershipTransferAcceptPage.stories.tsx | 49 +++++++ .../OwnershipTransferAcceptPage.test.tsx | 92 +++++++++++++ .../OwnershipTransferAcceptPage.tsx | 92 +++++++++++++ .../OwnershipTransferAcceptPage.types.ts | 16 +++ .../OwnershipTransferAcceptPage.utils.test.ts | 53 ++++++++ .../OwnershipTransferAcceptPage.utils.ts | 25 ++++ .../OwnershipTransferAcceptPage/index.ts | 2 + apps/ui/src/lib/i18n/locales/de/common.json | 40 ++++++ apps/ui/src/lib/i18n/locales/en/common.json | 40 ++++++ apps/ui/src/lib/logger/logger.events.ts | 7 + 49 files changed, 2486 insertions(+), 84 deletions(-) create mode 100644 apps/api/tests/config/security/security.constants.test.ts create mode 100644 apps/ui/src/features/accounts/JoinRequests.mutations.test.tsx create mode 100644 apps/ui/src/features/accounts/JoinRequests.mutations.ts create mode 100644 apps/ui/src/features/accounts/JoinRequests.queries.test.tsx create mode 100644 apps/ui/src/features/accounts/JoinRequests.queries.ts create mode 100644 apps/ui/src/features/accounts/OwnershipTransfers.mutations.test.tsx create mode 100644 apps/ui/src/features/accounts/OwnershipTransfers.mutations.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.constants.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.test.tsx create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.hooks.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.stories.tsx create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.test.tsx create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.tsx create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.types.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.test.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/InvitationAcceptPage.utils.ts create mode 100644 apps/ui/src/features/accounts/components/InvitationAcceptPage/index.ts create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.test.tsx create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.hooks.ts create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.stories.tsx create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.test.tsx create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.tsx create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.types.ts create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.test.ts create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/JoinRequestsPage.utils.ts create mode 100644 apps/ui/src/features/accounts/components/JoinRequestsPage/index.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.constants.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.test.tsx create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.hooks.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.stories.tsx create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.test.tsx create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.tsx create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.types.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.test.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/OwnershipTransferAcceptPage.utils.ts create mode 100644 apps/ui/src/features/accounts/components/OwnershipTransferAcceptPage/index.ts 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..37a203cb 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,55 @@ 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"); - } - - const verifiedAt = now(); + if (!claimedUser) { + throw ApiErrors.notFound("User"); + } - 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", From 14fa8d96b62688250d28698847f6d9d7de80f5fe Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Mon, 1 Jun 2026 10:23:13 +0200 Subject: [PATCH 2/2] fix(auth): keep "Email already verified" branch after atomic claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex follow-up tightened email verify to use atomic DELETE...RETURNING on the token row, which collapsed the duplicate-account race. But it also removed the explicit "already verified" check — a second click on an older verification link (still a valid token row from a prior resend) would now silently re-run the provisioning idempotency path instead of telling the user the email is already verified. Reinstates the check inside the transaction, after the user row is locked FOR UPDATE. Both contracts hold: races are atomic, and a stale verification email surfaces the user-friendly error string the integration test still asserts. --- .../api/auth/services/email-verification.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 37a203cb..d70a86e2 100644 --- a/apps/api/src/api/auth/services/email-verification.service.ts +++ b/apps/api/src/api/auth/services/email-verification.service.ts @@ -75,6 +75,18 @@ export class EmailVerificationService { throw ApiErrors.notFound("User"); } + /* + * 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"); + } + await tx .update(users) .set({ emailVerifiedAt: verifiedAt, updatedAt: verifiedAt })