# Phase 4 — Settings + RBAC + Team Invitations Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Donner aux Owners et Admins une UI pour gérer leur organisation (settings, équipe) + brancher l'envoi d'email d'invitation via Resend + formaliser le guard RBAC `requireOrgMember({ minRole })` qui sécurise les Server Actions et les Server Components.

**Architecture:** Trois pages sous `/settings/{profile,organization,team}` avec une layout commune (tabs). Un guard `requireOrgMember()` qui résout `{ session, user, activeOrganizationId, role }` à partir de la session + la table `member`, redirige `/onboarding` si pas de membership, throw 403 si role < minRole. Toutes les Server Actions de Phase 3+4 adoptent le pattern uniforme **guard → Zod → exec → revalidate**. L'envoi d'email d'invitation est wired via la callback `sendInvitationEmail` du plugin Better Auth `organization()`, qui appelle Resend.

**Tech Stack:** Next.js 16 App Router, Better Auth 1.6 (organization plugin avec callback `sendInvitationEmail`), Resend SDK (déjà installé `^6.12.3`), Zod 4, react-hook-form (déjà installé), next-intl, shadcn (`Tabs`, `Form`, `Select`, `Dialog`, `AlertDialog`).

**Spec source:**

- `docs/superpowers/specs/2026-05-25-org-roles-stripe-billing-design.md` §5.2 (RBAC matrix), §5.3 (Guards), §5.4 (Defense in depth), §7.1 (arbo), §7.3 (Server Actions), §7.4 (UI components).

**Hors-scope (différé) :**

- `/settings/billing` (Phase 6).
- Stripe / quotas (Phases 5-6).
- Tests Vitest infra (continuer avec smoke scripts pour l'instant).
- Audit log persistant.

**Dépendances déjà en place (Phase 3) :**

- `lib/actions/organization.ts` : `switchActiveOrganization`, `createOrganization`, `acceptInvitation`, `rejectInvitation` — sera refactor en Task 1 pour adopter le pattern guard+Zod.
- `lib/data/organization.ts` : `listUserOrganizations`.
- `lib/auth-guards.ts` : `requireAdmin`, `requireOrgSession`, `getActiveOrgIdFromSession`, `resolveActiveOrgId`.
- `<OrgSwitcher>` dans la sidebar.
- i18n namespaces `Org.switcher`, `Onboarding`, `Invitation`, `Common`.

---

## File Structure

| Path                                                       | Status | Responsibility                                                                                                                 |
| ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | ----- | ---------------------------- |
| `lib/auth-guards.ts`                                       | modify | Ajout `requireOrgMember({ minRole? })` qui retourne `{ session, user, activeOrganizationId, role }`.                           |
| `lib/auth.ts`                                              | modify | Configure le plugin `organization()` avec `sendInvitationEmail` callback (Resend).                                             |
| `lib/email/invitations.ts`                                 | create | Helper `sendInvitationEmail(args)` qui appelle Resend avec un template HTML simple.                                            |
| `lib/actions/organization.ts`                              | modify | Refactor les 4 actions Phase 3 vers pattern guard+Zod. Ajoute `updateOrganization`, `deleteOrganization`, `transferOwnership`. |
| `lib/actions/team.ts`                                      | create | `inviteMember`, `revokeInvitation`, `removeMember`, `updateMemberRole`.                                                        |
| `lib/data/team.ts`                                         | create | `listMembers(orgId)`, `listPendingInvitations(orgId)`.                                                                         |
| `lib/actions/profile.ts`                                   | create | `updateProfile({ name })` Server Action (basique).                                                                             |
| `messages/{en,fr,es}.json`                                 | modify | Namespaces `Settings.profile`, `Settings.organization`, `Settings.team`, `Settings.tabs`.                                      |
| `app/[locale]/settings/layout.tsx`                         | create | Settings layout avec `<SettingsTabs>` (Profile / Organization / Team / Billing-disabled).                                      |
| `app/[locale]/settings/settings-tabs.tsx`                  | create | Client tabs navigation.                                                                                                        |
| `app/[locale]/settings/profile/page.tsx`                   | create | RSC : form `name` (lecture seule pour email/password — Better Auth gère via Account settings plus tard).                       |
| `app/[locale]/settings/profile/profile-form.tsx`           | create | Client form react-hook-form pour `updateProfile`.                                                                              |
| `app/[locale]/settings/organization/page.tsx`              | create | RSC : form `name`, `slug` (lecture seule), guard admin+.                                                                       |
| `app/[locale]/settings/organization/organization-form.tsx` | create | Client form `updateOrganization`.                                                                                              |
| `app/[locale]/settings/organization/danger-zone.tsx`       | create | Client : `<DeleteOrgDialog>` + `<TransferOwnershipDialog>` (owner only).                                                       |
| `app/[locale]/settings/team/page.tsx`                      | create | RSC : liste members + invitations pending + invite form, guard admin+.                                                         |
| `app/[locale]/settings/team/invite-form.tsx`               | create | Client form `inviteMember(email, role)`.                                                                                       |
| `app/[locale]/settings/team/member-row.tsx`                | create | Client : 1 row member avec actions (change role, remove).                                                                      |
| `app/[locale]/settings/team/pending-invitation-row.tsx`    | create | Client : 1 row pending invitation avec revoke action.                                                                          |
| `components/org/role-badge.tsx`                            | create | Composant `<RoleBadge role="owner                                                                                              | admin | member">` shadcn-style chip. |
| `app/[locale]/settings/loading.tsx`                        | create | Skeleton commun aux 3 onglets.                                                                                                 |
| `app/[locale]/settings/error.tsx`                          | create | Error boundary i18n-aware.                                                                                                     |
| `app/[locale]/settings/page.tsx`                           | create | Redirige vers `/settings/profile`.                                                                                             |
| `scripts/verify-team-invite.ts`                            | create | Smoke : signup 2 users, owner invite user2, user2 accept, assert membership.                                                   |
| `package.json`                                             | modify | Script `verify:team-invite`.                                                                                                   |

---

## Task 1: `requireOrgMember()` guard + refactor Phase 3 Server Actions

**Files:**

- Modify: `lib/auth-guards.ts`
- Modify: `lib/actions/organization.ts`

- [ ] **Step 1.1: Add `requireOrgMember()` to `lib/auth-guards.ts`**

After `requireOrgSession()`, append:

```ts
import { eq, and } from "drizzle-orm";
import { member } from "@/lib/db/schema";

export type OrgRole = "owner" | "admin" | "member";

const ROLE_RANK: Record<OrgRole, number> = { member: 0, admin: 1, owner: 2 };

/* Resolves the user's active org AND their role in it. Redirects /login
   if unauth, /onboarding if no active org. Throws Error("Forbidden: ...")
   if the user is signed in + has an active org but is NOT a member of
   it (defensive; shouldn't happen post Phase 1), or if their role is
   below minRole.

   Use this in Server Components and Server Actions that need a role
   check. For pure session+org resolution (no role check), use
   requireOrgSession instead. */
export async function requireOrgMember(opts: { minRole?: OrgRole; locale?: Locale } = {}): Promise<{
  session: Session;
  user: Session["user"];
  activeOrganizationId: string;
  role: OrgRole;
}> {
  const locale = opts.locale ?? routing.defaultLocale;
  const { session, activeOrganizationId } = await requireOrgSession(locale);

  const rows = await db
    .select({ role: member.role })
    .from(member)
    .where(and(eq(member.userId, session.user.id), eq(member.organizationId, activeOrganizationId)))
    .limit(1);

  if (rows.length === 0) {
    throw new Error("Forbidden: not a member of the active organization");
  }
  const role = rows[0]!.role as OrgRole;

  if (opts.minRole && ROLE_RANK[role] < ROLE_RANK[opts.minRole]) {
    throw new Error(`Forbidden: requires role ${opts.minRole}, has ${role}`);
  }

  return { session, user: session.user, activeOrganizationId, role };
}
```

Add `import { db } from "@/lib/db";` at the top if not present.

- [ ] **Step 1.2: Typecheck**

```bash
pnpm typecheck 2>&1 | tail -5
```

Expected: clean.

- [ ] **Step 1.3: Refactor Phase 3 actions to use the new guard + Zod**

Open `lib/actions/organization.ts`. Replace the entire file with:

```ts
"use server";

import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";

import { auth } from "@/lib/auth";
import { requireOrgMember, requireOrgSession } from "@/lib/auth-guards";

/* All actions follow the same pattern: guard → Zod validate → exec → revalidate.
   This file holds org-level actions (switching, creating, accepting/rejecting
   invitations, updating, deleting, transferring). Team-member actions live in
   lib/actions/team.ts. */

const SwitchSchema = z.object({ organizationId: z.string().min(1) });

export async function switchActiveOrganization(input: { organizationId: string }) {
  // No role gate — any signed-in user who is a member can switch.
  await requireOrgSession();
  const { organizationId } = SwitchSchema.parse(input);

  await auth.api.setActiveOrganization({
    headers: await headers(),
    body: { organizationId },
  });
  revalidatePath("/", "layout");
}

const CreateSchema = z.object({
  name: z.string().trim().min(1).max(80),
  slug: z.string().trim().min(1).max(80).optional(),
});

function slugify(input: string): string {
  const base = input
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "")
    .slice(0, 50);
  return base || `org-${Date.now()}`;
}

export async function createOrganization(input: { name: string; slug?: string }) {
  await requireOrgSession();
  const parsed = CreateSchema.parse(input);

  const result = await auth.api.createOrganization({
    headers: await headers(),
    body: { name: parsed.name, slug: parsed.slug ?? slugify(parsed.name) },
  });
  if (!result) throw new Error("Failed to create organization");

  await auth.api.setActiveOrganization({
    headers: await headers(),
    body: { organizationId: result.id },
  });
  revalidatePath("/", "layout");
  return result;
}

const InvitationIdSchema = z.object({ invitationId: z.string().min(1) });

export async function acceptInvitation(input: { invitationId: string }) {
  await requireOrgSession();
  const { invitationId } = InvitationIdSchema.parse(input);

  const result = await auth.api.acceptInvitation({
    headers: await headers(),
    body: { invitationId },
  });
  if (result?.invitation?.organizationId) {
    await auth.api.setActiveOrganization({
      headers: await headers(),
      body: { organizationId: result.invitation.organizationId },
    });
  }
  revalidatePath("/", "layout");
  return result;
}

export async function rejectInvitation(input: { invitationId: string }) {
  await requireOrgSession();
  const { invitationId } = InvitationIdSchema.parse(input);

  await auth.api.rejectInvitation({
    headers: await headers(),
    body: { invitationId },
  });
}

/* ----- Phase 4: org settings actions ----- */

const UpdateOrgSchema = z.object({
  name: z.string().trim().min(1).max(80),
  logo: z.string().trim().url().or(z.literal("")).optional(),
});

export async function updateOrganization(input: { name: string; logo?: string }) {
  // admin+ can edit name + logo.
  const { activeOrganizationId } = await requireOrgMember({ minRole: "admin" });
  const parsed = UpdateOrgSchema.parse(input);

  await auth.api.updateOrganization({
    headers: await headers(),
    body: {
      organizationId: activeOrganizationId,
      data: {
        name: parsed.name,
        logo: parsed.logo || undefined,
      },
    },
  });
  revalidatePath("/settings/organization");
  revalidatePath("/", "layout");
}

export async function deleteOrganization() {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  await auth.api.deleteOrganization({
    headers: await headers(),
    body: { organizationId: activeOrganizationId },
  });
  revalidatePath("/", "layout");
}

const TransferOwnershipSchema = z.object({ toUserId: z.string().min(1) });

export async function transferOwnership(input: { toUserId: string }) {
  // Only the current owner can transfer.
  const { activeOrganizationId } = await requireOrgMember({ minRole: "owner" });
  const { toUserId } = TransferOwnershipSchema.parse(input);

  // Better Auth supports updateMemberRole; transfer = promote target to
  // owner, then demote current user to admin (atomic via Better Auth API).
  // The plugin's updateMemberRole supports role: "owner" on org admins.
  await auth.api.updateMemberRole({
    headers: await headers(),
    body: {
      organizationId: activeOrganizationId,
      memberId: toUserId,
      role: "owner",
    },
  });
  revalidatePath("/settings/organization");
  revalidatePath("/settings/team");
}
```

- [ ] **Step 1.4: Typecheck + lint**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint lib/auth-guards.ts lib/actions/organization.ts 2>&1 | tail -3
```

Expected: 0 errors.

- [ ] **Step 1.5: Commit**

```bash
git add lib/auth-guards.ts lib/actions/organization.ts
git commit -m "feat(auth): add requireOrgMember(minRole) + adopt guard+Zod pattern in org actions

- requireOrgMember resolves { session, user, activeOrganizationId, role }
  and gates by role rank (member < admin < owner). Throws "Forbidden"
  instead of redirecting — Server Actions should surface the error to
  the UI, not redirect.
- All 4 Phase 3 actions (switch/create/accept/reject) now follow the
  spec §7.3 pattern: guard first, Zod validate, then exec + revalidate.
- Adds Phase 4 org actions: updateOrganization (admin+), deleteOrganization
  (owner), transferOwnership (owner)."
```

---

## Task 2: i18n keys for Phase 4 settings

**Files:**

- Modify: `messages/en.json`
- Modify: `messages/fr.json`
- Modify: `messages/es.json`

- [ ] **Step 2.1: Add 4 namespaces to all 3 message files**

For `messages/en.json` (append at end before closing `}`):

```json
"Settings": {
  "tabs": {
    "profile": "Profile",
    "organization": "Organization",
    "team": "Team",
    "billing": "Billing",
    "billingDisabled": "Coming soon"
  },
  "profile": {
    "title": "Profile",
    "description": "Your personal account settings.",
    "nameLabel": "Name",
    "namePlaceholder": "Your full name",
    "emailLabel": "Email",
    "emailHelper": "Email is your sign-in identifier and can't be changed here.",
    "submit": "Save changes",
    "submitting": "Saving…",
    "saveSuccess": "Profile saved.",
    "saveFailed": "Failed to save profile."
  },
  "organization": {
    "title": "Organization",
    "description": "Settings for {orgName}. Only admins and owners can edit.",
    "nameLabel": "Organization name",
    "namePlaceholder": "Acme Corp",
    "slugLabel": "Slug",
    "slugHelper": "The slug is auto-generated from the name and can't be changed here.",
    "submit": "Save changes",
    "submitting": "Saving…",
    "saveSuccess": "Organization saved.",
    "saveFailed": "Failed to save organization.",
    "dangerZone": "Danger zone",
    "dangerZoneDescription": "Permanent actions. Only the owner can perform them.",
    "deleteOrg": "Delete organization",
    "deleteOrgDescription": "This will permanently delete {orgName} and all its sites and tests. There is no undo.",
    "deleteOrgConfirm": "Type \"{orgName}\" to confirm",
    "deleteOrgButton": "Delete forever",
    "deleteOrgFailed": "Failed to delete organization.",
    "transferOwnership": "Transfer ownership",
    "transferOwnershipDescription": "Promote a current admin to owner. You will be demoted to admin.",
    "transferOwnershipSelect": "Select the new owner",
    "transferOwnershipButton": "Transfer",
    "transferOwnershipFailed": "Failed to transfer ownership."
  },
  "team": {
    "title": "Team",
    "description": "Members of {orgName} and pending invitations.",
    "inviteTitle": "Invite a new member",
    "inviteEmailLabel": "Email",
    "inviteEmailPlaceholder": "teammate@company.com",
    "inviteRoleLabel": "Role",
    "inviteRoleAdmin": "Admin",
    "inviteRoleMember": "Member",
    "inviteSubmit": "Send invitation",
    "inviteSubmitting": "Sending…",
    "inviteSuccess": "Invitation sent to {email}.",
    "inviteFailed": "Failed to send invitation.",
    "membersTitle": "Members ({count})",
    "pendingTitle": "Pending invitations ({count})",
    "noMembers": "No members yet.",
    "noPending": "No pending invitations.",
    "memberRoleChange": "Change role",
    "memberRemove": "Remove from organization",
    "memberRemoveConfirm": "Remove {name} from {orgName}? They will lose access immediately.",
    "memberRemoveSuccess": "Member removed.",
    "memberRemoveFailed": "Failed to remove member.",
    "memberRoleUpdated": "Role updated.",
    "memberRoleFailed": "Failed to update role.",
    "invitationRevoke": "Revoke",
    "invitationRevokeConfirm": "Revoke the invitation sent to {email}?",
    "invitationRevokeSuccess": "Invitation revoked.",
    "invitationRevokeFailed": "Failed to revoke invitation.",
    "invitationExpiresAt": "Expires {date}"
  }
}
```

For `messages/fr.json` (matching keys, French translations):

```json
"Settings": {
  "tabs": {
    "profile": "Profil",
    "organization": "Organisation",
    "team": "Équipe",
    "billing": "Facturation",
    "billingDisabled": "Bientôt disponible"
  },
  "profile": {
    "title": "Profil",
    "description": "Les paramètres de votre compte personnel.",
    "nameLabel": "Nom",
    "namePlaceholder": "Votre nom complet",
    "emailLabel": "Email",
    "emailHelper": "Votre email sert d'identifiant de connexion et ne peut pas être modifié ici.",
    "submit": "Enregistrer",
    "submitting": "Enregistrement…",
    "saveSuccess": "Profil enregistré.",
    "saveFailed": "Échec de l'enregistrement du profil."
  },
  "organization": {
    "title": "Organisation",
    "description": "Paramètres de {orgName}. Seuls les admins et propriétaires peuvent modifier.",
    "nameLabel": "Nom de l'organisation",
    "namePlaceholder": "Acme Corp",
    "slugLabel": "Slug",
    "slugHelper": "Le slug est généré automatiquement à partir du nom et ne peut pas être modifié ici.",
    "submit": "Enregistrer",
    "submitting": "Enregistrement…",
    "saveSuccess": "Organisation enregistrée.",
    "saveFailed": "Échec de l'enregistrement de l'organisation.",
    "dangerZone": "Zone dangereuse",
    "dangerZoneDescription": "Actions permanentes. Seul le propriétaire peut les effectuer.",
    "deleteOrg": "Supprimer l'organisation",
    "deleteOrgDescription": "Cette action supprimera définitivement {orgName} ainsi que tous ses sites et tests. Aucun retour en arrière possible.",
    "deleteOrgConfirm": "Tapez \"{orgName}\" pour confirmer",
    "deleteOrgButton": "Supprimer définitivement",
    "deleteOrgFailed": "Échec de la suppression de l'organisation.",
    "transferOwnership": "Transférer la propriété",
    "transferOwnershipDescription": "Promouvoir un admin actuel au statut de propriétaire. Vous serez rétrogradé en admin.",
    "transferOwnershipSelect": "Sélectionnez le nouveau propriétaire",
    "transferOwnershipButton": "Transférer",
    "transferOwnershipFailed": "Échec du transfert de propriété."
  },
  "team": {
    "title": "Équipe",
    "description": "Membres de {orgName} et invitations en attente.",
    "inviteTitle": "Inviter un nouveau membre",
    "inviteEmailLabel": "Email",
    "inviteEmailPlaceholder": "collegue@entreprise.com",
    "inviteRoleLabel": "Rôle",
    "inviteRoleAdmin": "Administrateur",
    "inviteRoleMember": "Membre",
    "inviteSubmit": "Envoyer l'invitation",
    "inviteSubmitting": "Envoi…",
    "inviteSuccess": "Invitation envoyée à {email}.",
    "inviteFailed": "Échec de l'envoi de l'invitation.",
    "membersTitle": "Membres ({count})",
    "pendingTitle": "Invitations en attente ({count})",
    "noMembers": "Aucun membre.",
    "noPending": "Aucune invitation en attente.",
    "memberRoleChange": "Changer le rôle",
    "memberRemove": "Retirer de l'organisation",
    "memberRemoveConfirm": "Retirer {name} de {orgName} ? L'accès sera perdu immédiatement.",
    "memberRemoveSuccess": "Membre retiré.",
    "memberRemoveFailed": "Échec du retrait du membre.",
    "memberRoleUpdated": "Rôle mis à jour.",
    "memberRoleFailed": "Échec de la mise à jour du rôle.",
    "invitationRevoke": "Révoquer",
    "invitationRevokeConfirm": "Révoquer l'invitation envoyée à {email} ?",
    "invitationRevokeSuccess": "Invitation révoquée.",
    "invitationRevokeFailed": "Échec de la révocation de l'invitation.",
    "invitationExpiresAt": "Expire le {date}"
  }
}
```

For `messages/es.json` (matching keys, Spanish translations):

```json
"Settings": {
  "tabs": {
    "profile": "Perfil",
    "organization": "Organización",
    "team": "Equipo",
    "billing": "Facturación",
    "billingDisabled": "Próximamente"
  },
  "profile": {
    "title": "Perfil",
    "description": "Configuración de su cuenta personal.",
    "nameLabel": "Nombre",
    "namePlaceholder": "Su nombre completo",
    "emailLabel": "Email",
    "emailHelper": "Su email es su identificador de inicio de sesión y no se puede cambiar aquí.",
    "submit": "Guardar cambios",
    "submitting": "Guardando…",
    "saveSuccess": "Perfil guardado.",
    "saveFailed": "No se pudo guardar el perfil."
  },
  "organization": {
    "title": "Organización",
    "description": "Configuración de {orgName}. Solo los admins y propietarios pueden editar.",
    "nameLabel": "Nombre de la organización",
    "namePlaceholder": "Acme Corp",
    "slugLabel": "Slug",
    "slugHelper": "El slug se genera automáticamente a partir del nombre y no se puede cambiar aquí.",
    "submit": "Guardar cambios",
    "submitting": "Guardando…",
    "saveSuccess": "Organización guardada.",
    "saveFailed": "No se pudo guardar la organización.",
    "dangerZone": "Zona de peligro",
    "dangerZoneDescription": "Acciones permanentes. Solo el propietario puede realizarlas.",
    "deleteOrg": "Eliminar organización",
    "deleteOrgDescription": "Esto eliminará permanentemente {orgName} y todos sus sitios y pruebas. No hay vuelta atrás.",
    "deleteOrgConfirm": "Escriba \"{orgName}\" para confirmar",
    "deleteOrgButton": "Eliminar para siempre",
    "deleteOrgFailed": "No se pudo eliminar la organización.",
    "transferOwnership": "Transferir propiedad",
    "transferOwnershipDescription": "Promover a un admin actual a propietario. Usted será degradado a admin.",
    "transferOwnershipSelect": "Seleccione el nuevo propietario",
    "transferOwnershipButton": "Transferir",
    "transferOwnershipFailed": "No se pudo transferir la propiedad."
  },
  "team": {
    "title": "Equipo",
    "description": "Miembros de {orgName} e invitaciones pendientes.",
    "inviteTitle": "Invitar a un nuevo miembro",
    "inviteEmailLabel": "Email",
    "inviteEmailPlaceholder": "companero@empresa.com",
    "inviteRoleLabel": "Rol",
    "inviteRoleAdmin": "Administrador",
    "inviteRoleMember": "Miembro",
    "inviteSubmit": "Enviar invitación",
    "inviteSubmitting": "Enviando…",
    "inviteSuccess": "Invitación enviada a {email}.",
    "inviteFailed": "No se pudo enviar la invitación.",
    "membersTitle": "Miembros ({count})",
    "pendingTitle": "Invitaciones pendientes ({count})",
    "noMembers": "Sin miembros.",
    "noPending": "Sin invitaciones pendientes.",
    "memberRoleChange": "Cambiar rol",
    "memberRemove": "Retirar de la organización",
    "memberRemoveConfirm": "¿Retirar a {name} de {orgName}? Perderá el acceso inmediatamente.",
    "memberRemoveSuccess": "Miembro retirado.",
    "memberRemoveFailed": "No se pudo retirar al miembro.",
    "memberRoleUpdated": "Rol actualizado.",
    "memberRoleFailed": "No se pudo actualizar el rol.",
    "invitationRevoke": "Revocar",
    "invitationRevokeConfirm": "¿Revocar la invitación enviada a {email}?",
    "invitationRevokeSuccess": "Invitación revocada.",
    "invitationRevokeFailed": "No se pudo revocar la invitación.",
    "invitationExpiresAt": "Expira el {date}"
  }
}
```

- [ ] **Step 2.2: Verify JSON parse + key parity**

```bash
jq . messages/en.json > /dev/null && jq . messages/fr.json > /dev/null && jq . messages/es.json > /dev/null
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/fr.json | sort)
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/es.json | sort)
```

Both diffs MUST be empty.

- [ ] **Step 2.3: Commit**

```bash
git add messages/
git commit -m "i18n(settings): add Settings.tabs/profile/organization/team namespaces (EN/FR/ES)"
```

---

## Task 3: Resend invitation email + Better Auth callback

**Files:**

- Create: `lib/email/invitations.ts`
- Modify: `lib/auth.ts`

- [ ] **Step 3.1: Create the Resend helper**

```ts
// lib/email/invitations.ts
import { Resend } from "resend";

import { env } from "@/lib/env";

const resend = new Resend(env.RESEND_API_KEY);

const FROM = env.RESEND_FROM_EMAIL || "Eleva Speed <onboarding@resend.dev>";

export type InvitationEmailArgs = {
  to: string;
  inviterName: string;
  organizationName: string;
  role: string;
  acceptUrl: string;
};

export async function sendInvitationEmail(args: InvitationEmailArgs) {
  const subject = `${args.inviterName} invited you to ${args.organizationName} on Eleva Speed`;
  const html = renderHtml(args);
  const text = renderText(args);

  const result = await resend.emails.send({
    from: FROM,
    to: args.to,
    subject,
    html,
    text,
  });

  if (result.error) {
    throw new Error(`Resend failed: ${result.error.message}`);
  }
  return result.data?.id ?? null;
}

function renderHtml(args: InvitationEmailArgs): string {
  return `<!doctype html>
<html lang="en">
  <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 40px auto; color: #18181b;">
    <h1 style="font-size: 22px; margin-bottom: 16px;">You've been invited to ${escapeHtml(args.organizationName)}</h1>
    <p style="line-height: 1.5; color: #52525b;">
      <strong>${escapeHtml(args.inviterName)}</strong> invited you to join
      <strong>${escapeHtml(args.organizationName)}</strong> on Eleva Speed as
      <strong>${escapeHtml(args.role)}</strong>.
    </p>
    <p style="margin: 32px 0;">
      <a href="${args.acceptUrl}" style="display: inline-block; padding: 12px 20px; background: rgb(253, 16, 94); color: white; text-decoration: none; border-radius: 8px; font-weight: 600;">
        Accept invitation
      </a>
    </p>
    <p style="font-size: 12px; color: #71717a;">
      If you weren't expecting this invitation, you can safely ignore this email.
    </p>
  </body>
</html>`;
}

function renderText(args: InvitationEmailArgs): string {
  return `${args.inviterName} invited you to join ${args.organizationName} on Eleva Speed as ${args.role}.

Accept the invitation: ${args.acceptUrl}

If you weren't expecting this, you can safely ignore this email.`;
}

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}
```

Note: `env.RESEND_FROM_EMAIL` may not exist yet — add it to `lib/env.ts` if missing. If `env.RESEND_API_KEY` is missing too, add it (project already uses Resend per package.json, the env var likely exists).

- [ ] **Step 3.2: Check `lib/env.ts` has both env vars**

Read `lib/env.ts`. If `RESEND_API_KEY` is missing, add it as `z.string().min(1)`. If `RESEND_FROM_EMAIL` is missing, add it as `z.string().optional()`. If `BETTER_AUTH_URL` is missing, add it too (used to build the accept URL).

- [ ] **Step 3.3: Wire the callback in `lib/auth.ts`**

Modify the `organization()` plugin call to pass a `sendInvitationEmail` config. Replace:

```ts
plugins: [admin(), organization(), nextCookies()],
```

With:

```ts
plugins: [
  admin(),
  organization({
    sendInvitationEmail: async (data) => {
      const { sendInvitationEmail } = await import("@/lib/email/invitations");
      const url = `${env.BETTER_AUTH_URL || "http://localhost:3000"}/accept-invitation/${data.id}`;
      await sendInvitationEmail({
        to: data.email,
        inviterName: data.inviter.user.name ?? data.inviter.user.email,
        organizationName: data.organization.name,
        role: data.role,
        acceptUrl: url,
      });
    },
  }),
  nextCookies(),
],
```

Add `import { env } from "@/lib/env";` at the top if not present.

The dynamic `import()` inside the callback avoids pulling Resend into the cold-start of any non-invitation auth call. Acceptable trade-off.

- [ ] **Step 3.4: Typecheck**

```bash
pnpm typecheck 2>&1 | tail -5
```

Expected: clean.

- [ ] **Step 3.5: Commit**

```bash
git add lib/email/ lib/auth.ts lib/env.ts
git commit -m "feat(invitations): wire Resend for invitation emails via Better Auth callback

The organization() plugin's sendInvitationEmail callback now sends a
branded HTML+text invitation via Resend. Dynamic import keeps Resend
out of cold-start. Email contains the acceptUrl /accept-invitation/<id>.
Env vars RESEND_API_KEY (required) and RESEND_FROM_EMAIL (optional,
defaults to onboarding@resend.dev) validated at boot."
```

---

## Task 4: Team Server Actions + data helpers

**Files:**

- Create: `lib/actions/team.ts`
- Create: `lib/actions/profile.ts`
- Create: `lib/data/team.ts`

- [ ] **Step 4.1: Create `lib/data/team.ts`**

```ts
import { and, eq, isNull } from "drizzle-orm";

import { db } from "@/lib/db";
import { invitation, member, user } from "@/lib/db/schema";

export type MemberRow = {
  id: string;
  userId: string;
  role: "owner" | "admin" | "member";
  email: string;
  name: string;
  createdAt: Date;
};

export async function listMembers(organizationId: string): Promise<MemberRow[]> {
  const rows = await db
    .select({
      id: member.id,
      userId: member.userId,
      role: member.role,
      email: user.email,
      name: user.name,
      createdAt: member.createdAt,
    })
    .from(member)
    .innerJoin(user, eq(user.id, member.userId))
    .where(eq(member.organizationId, organizationId))
    .orderBy(member.createdAt);

  return rows.map((r) => ({
    id: r.id,
    userId: r.userId,
    role: r.role as "owner" | "admin" | "member",
    email: r.email,
    name: r.name,
    createdAt: r.createdAt,
  }));
}

export type PendingInvitationRow = {
  id: string;
  email: string;
  role: "admin" | "member";
  expiresAt: Date;
};

export async function listPendingInvitations(
  organizationId: string,
): Promise<PendingInvitationRow[]> {
  const rows = await db
    .select({
      id: invitation.id,
      email: invitation.email,
      role: invitation.role,
      expiresAt: invitation.expiresAt,
    })
    .from(invitation)
    .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, "pending")))
    .orderBy(invitation.expiresAt);

  return rows.map((r) => ({
    id: r.id,
    email: r.email,
    role: r.role as "admin" | "member",
    expiresAt: r.expiresAt,
  }));
}
```

- [ ] **Step 4.2: Create `lib/actions/team.ts`**

```ts
"use server";

import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";

import { auth } from "@/lib/auth";
import { requireOrgMember } from "@/lib/auth-guards";

const InviteSchema = z.object({
  email: z.email(),
  role: z.enum(["admin", "member"]),
});

export async function inviteMember(input: { email: string; role: "admin" | "member" }) {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "admin" });
  const parsed = InviteSchema.parse(input);

  await auth.api.createInvitation({
    headers: await headers(),
    body: {
      organizationId: activeOrganizationId,
      email: parsed.email,
      role: parsed.role,
    },
  });
  revalidatePath("/settings/team");
}

const InvitationIdSchema = z.object({ invitationId: z.string().min(1) });

export async function revokeInvitation(input: { invitationId: string }) {
  await requireOrgMember({ minRole: "admin" });
  const { invitationId } = InvitationIdSchema.parse(input);

  await auth.api.cancelInvitation({
    headers: await headers(),
    body: { invitationId },
  });
  revalidatePath("/settings/team");
}

const MemberIdSchema = z.object({ memberId: z.string().min(1) });

export async function removeMember(input: { memberId: string }) {
  const { activeOrganizationId } = await requireOrgMember({ minRole: "admin" });
  const { memberId } = MemberIdSchema.parse(input);

  await auth.api.removeMember({
    headers: await headers(),
    body: { organizationId: activeOrganizationId, memberIdOrEmail: memberId },
  });
  revalidatePath("/settings/team");
}

const UpdateRoleSchema = z.object({
  memberId: z.string().min(1),
  role: z.enum(["admin", "member"]),
});

export async function updateMemberRole(input: { memberId: string; role: "admin" | "member" }) {
  // admin+ can change member ↔ admin. Owner promotion is via transferOwnership only.
  const { activeOrganizationId } = await requireOrgMember({ minRole: "admin" });
  const parsed = UpdateRoleSchema.parse(input);

  await auth.api.updateMemberRole({
    headers: await headers(),
    body: {
      organizationId: activeOrganizationId,
      memberId: parsed.memberId,
      role: parsed.role,
    },
  });
  revalidatePath("/settings/team");
}
```

- [ ] **Step 4.3: Create `lib/actions/profile.ts`**

```ts
"use server";

import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { z } from "zod";

import { requireOrgSession } from "@/lib/auth-guards";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";

const ProfileSchema = z.object({
  name: z.string().trim().min(1).max(80),
});

export async function updateProfile(input: { name: string }) {
  const { session } = await requireOrgSession();
  const parsed = ProfileSchema.parse(input);

  await db
    .update(user)
    .set({ name: parsed.name, updatedAt: new Date() })
    .where(eq(user.id, session.user.id));
  revalidatePath("/settings/profile");
  revalidatePath("/", "layout");
}
```

- [ ] **Step 4.4: Typecheck + lint**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint lib/actions/ lib/data/team.ts 2>&1 | tail -3
```

Expected: 0 errors. If `auth.api.createInvitation` signature differs from what's assumed (e.g., requires different field names), peek at `node_modules/better-auth/dist/plugins/organization/routes/crud-invites.d.mts` and adjust.

- [ ] **Step 4.5: Commit**

```bash
git add lib/actions/team.ts lib/actions/profile.ts lib/data/team.ts
git commit -m "feat(team,profile): Server Actions for team management + profile update

lib/actions/team.ts: inviteMember (admin+, calls BA createInvitation +
triggers Resend email via sendInvitationEmail callback), revokeInvitation,
removeMember, updateMemberRole. All follow guard+Zod pattern.

lib/actions/profile.ts: updateProfile updates user.name (email + password
remain managed by Better Auth's account settings).

lib/data/team.ts: listMembers + listPendingInvitations for the
/settings/team page."
```

---

## Task 5: `<RoleBadge>` component

**Files:**

- Create: `components/org/role-badge.tsx`

- [ ] **Step 5.1: Create the badge**

```tsx
"use client";

import { useTranslations } from "next-intl";

import { cn } from "@/lib/utils";

type Role = "owner" | "admin" | "member";

const STYLES: Record<Role, string> = {
  owner:
    "bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300 border-amber-500/20",
  admin: "bg-blue-500/10 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300 border-blue-500/20",
  member: "bg-zinc-500/10 text-zinc-700 dark:bg-zinc-500/20 dark:text-zinc-300 border-zinc-500/20",
};

export function RoleBadge({ role, className }: { role: Role; className?: string }) {
  const t = useTranslations("Org.switcher");
  const label =
    role === "owner" ? t("roleOwner") : role === "admin" ? t("roleAdmin") : t("roleMember");
  return (
    <span
      className={cn(
        "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium uppercase tracking-wider",
        STYLES[role],
        className,
      )}
    >
      {label}
    </span>
  );
}
```

- [ ] **Step 5.2: Commit**

```bash
git add components/org/role-badge.tsx
git commit -m "feat(org): add <RoleBadge> chip for owner/admin/member"
```

---

## Task 6: Settings layout + tabs + redirect from /settings

**Files:**

- Create: `app/[locale]/settings/layout.tsx`
- Create: `app/[locale]/settings/settings-tabs.tsx`
- Create: `app/[locale]/settings/page.tsx`
- Create: `app/[locale]/settings/loading.tsx`
- Create: `app/[locale]/settings/error.tsx`

- [ ] **Step 6.1: Create `layout.tsx`**

```tsx
import { getTranslations } from "next-intl/server";

import { requireOrgMember } from "@/lib/auth-guards";

import { SettingsTabs } from "./settings-tabs";

export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
  // Settings require at least an active org. The individual pages do their
  // own role checks (e.g., /settings/organization requires admin+).
  await requireOrgMember();
  const t = await getTranslations("Settings");
  return (
    <div className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-4 py-8">
      <header>
        <h1 className="text-2xl font-semibold tracking-tight">{t("tabs.profile")}</h1>
      </header>
      <SettingsTabs />
      <div>{children}</div>
    </div>
  );
}
```

- [ ] **Step 6.2: Create `settings-tabs.tsx`**

```tsx
"use client";

import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import Link from "next/link";

import { cn } from "@/lib/utils";

const TABS = [
  { key: "profile", href: "/settings/profile" },
  { key: "organization", href: "/settings/organization" },
  { key: "team", href: "/settings/team" },
  { key: "billing", href: "/settings/billing", disabled: true },
] as const;

export function SettingsTabs() {
  const t = useTranslations("Settings.tabs");
  const pathname = usePathname();

  return (
    <nav className="flex gap-2 border-b">
      {TABS.map((tab) => {
        // pathname is locale-prefixed (e.g., /en/settings/team); use endsWith
        // so we don't have to strip locale.
        const active = pathname.endsWith(tab.href);
        const className = cn(
          "px-3 py-2 text-sm font-medium border-b-2 -mb-px",
          active
            ? "border-primary text-primary"
            : "border-transparent text-muted-foreground hover:text-foreground",
          tab.disabled && "pointer-events-none opacity-50",
        );
        if (tab.disabled) {
          return (
            <span key={tab.key} className={className}>
              {t(tab.key)} <span className="ml-1 text-xs opacity-60">({t("billingDisabled")})</span>
            </span>
          );
        }
        return (
          <Link key={tab.key} href={tab.href} className={className}>
            {t(tab.key)}
          </Link>
        );
      })}
    </nav>
  );
}
```

- [ ] **Step 6.3: Create `page.tsx` (redirect to profile)**

```tsx
import { redirect } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";

export default async function SettingsIndexPage({
  params,
}: {
  params: Promise<{ locale: Locale }>;
}) {
  const { locale } = await params;
  redirect({ href: "/settings/profile", locale: locale ?? routing.defaultLocale });
}
```

- [ ] **Step 6.4: Create `loading.tsx`**

```tsx
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
  return (
    <div className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-4 py-8">
      <Skeleton className="h-8 w-32" />
      <div className="flex gap-2 border-b">
        <Skeleton className="h-8 w-24" />
        <Skeleton className="h-8 w-24" />
        <Skeleton className="h-8 w-24" />
      </div>
      <div className="flex flex-col gap-4">
        <Skeleton className="h-32 w-full" />
        <Skeleton className="h-10 w-32" />
      </div>
    </div>
  );
}
```

- [ ] **Step 6.5: Create `error.tsx`** (i18n-aware, like the Phase 3 fix)

```tsx
"use client";

import { useTranslations } from "next-intl";

import { Button } from "@/components/ui/button";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const t = useTranslations("Common");
  return (
    <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
      <h1 className="text-xl font-semibold">{t("errorTitle")}</h1>
      <p className="text-sm text-muted-foreground">{error.message}</p>
      <Button type="button" onClick={reset}>
        {t("errorRetry")}
      </Button>
    </main>
  );
}
```

- [ ] **Step 6.6: Typecheck + lint + commit**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/settings/' 2>&1 | tail -3
git add 'app/[locale]/settings/layout.tsx' 'app/[locale]/settings/settings-tabs.tsx' 'app/[locale]/settings/page.tsx' 'app/[locale]/settings/loading.tsx' 'app/[locale]/settings/error.tsx'
git commit -m "feat(settings): /settings layout with tabs + redirect to /settings/profile

The layout gates by requireOrgMember (any role) since individual pages
do their own role checks. Tabs use locale-prefixed Link via next-intl
navigation; Billing tab is rendered but disabled (Phase 6). Index page
redirects to /settings/profile."
```

---

## Task 7: `/settings/profile` page

**Files:**

- Create: `app/[locale]/settings/profile/page.tsx`
- Create: `app/[locale]/settings/profile/profile-form.tsx`

- [ ] **Step 7.1: Create `page.tsx`**

```tsx
import { getTranslations } from "next-intl/server";

import { requireOrgMember } from "@/lib/auth-guards";

import { ProfileForm } from "./profile-form";

export default async function ProfilePage() {
  const { user } = await requireOrgMember();
  const t = await getTranslations("Settings.profile");

  return (
    <section className="flex flex-col gap-6">
      <div className="flex flex-col gap-1">
        <h2 className="text-xl font-semibold">{t("title")}</h2>
        <p className="text-sm text-muted-foreground">{t("description")}</p>
      </div>
      <ProfileForm defaultName={user.name ?? ""} email={user.email} />
    </section>
  );
}
```

- [ ] **Step 7.2: Create `profile-form.tsx`**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateProfile } from "@/lib/actions/profile";

export function ProfileForm({ defaultName, email }: { defaultName: string; email: string }) {
  const t = useTranslations("Settings.profile");
  const [name, setName] = useState(defaultName);
  const [isPending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmed = name.trim();
    if (!trimmed || trimmed === defaultName) return;
    startTransition(async () => {
      try {
        await updateProfile({ name: trimmed });
        toast.success(t("saveSuccess"));
      } catch {
        toast.error(t("saveFailed"));
      }
    });
  }

  return (
    <form onSubmit={onSubmit} className="flex max-w-md flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="profile-name">{t("nameLabel")}</Label>
        <Input
          id="profile-name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder={t("namePlaceholder")}
          required
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="profile-email">{t("emailLabel")}</Label>
        <Input id="profile-email" value={email} disabled />
        <p className="text-xs text-muted-foreground">{t("emailHelper")}</p>
      </div>
      <div>
        <Button type="submit" disabled={isPending || !name.trim() || name.trim() === defaultName}>
          {isPending ? t("submitting") : t("submit")}
        </Button>
      </div>
    </form>
  );
}
```

- [ ] **Step 7.3: Typecheck + lint + commit**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/settings/profile/' 2>&1 | tail -3
git add 'app/[locale]/settings/profile/'
git commit -m "feat(settings/profile): /settings/profile page for editing user name

Server Component reads { user } from requireOrgMember and passes name +
email to a client form. The form calls updateProfile Server Action; email
is read-only (managed by Better Auth account settings later)."
```

---

## Task 8: `/settings/organization` page + danger zone

**Files:**

- Create: `app/[locale]/settings/organization/page.tsx`
- Create: `app/[locale]/settings/organization/organization-form.tsx`
- Create: `app/[locale]/settings/organization/danger-zone.tsx`

- [ ] **Step 8.1: Create `page.tsx`**

```tsx
import { eq } from "drizzle-orm";
import { getTranslations } from "next-intl/server";

import { requireOrgMember } from "@/lib/auth-guards";
import { listMembers } from "@/lib/data/team";
import { db } from "@/lib/db";
import { organization } from "@/lib/db/schema";

import { DangerZone } from "./danger-zone";
import { OrganizationForm } from "./organization-form";

export default async function OrganizationSettingsPage() {
  // Admin+ required to view this page.
  const { activeOrganizationId, role } = await requireOrgMember({ minRole: "admin" });

  const t = await getTranslations("Settings.organization");
  const [orgRow] = await db
    .select({ id: organization.id, name: organization.name, slug: organization.slug })
    .from(organization)
    .where(eq(organization.id, activeOrganizationId))
    .limit(1);
  if (!orgRow) throw new Error("Active organization missing");

  const members = await listMembers(activeOrganizationId);
  const admins = members.filter((m) => m.role === "admin");

  return (
    <section className="flex flex-col gap-8">
      <div className="flex flex-col gap-1">
        <h2 className="text-xl font-semibold">{t("title")}</h2>
        <p className="text-sm text-muted-foreground">
          {t("description", { orgName: orgRow.name })}
        </p>
      </div>
      <OrganizationForm defaultName={orgRow.name} slug={orgRow.slug} />
      {role === "owner" && (
        <DangerZone
          orgName={orgRow.name}
          admins={admins.map((a) => ({ memberId: a.id, name: a.name, email: a.email }))}
        />
      )}
    </section>
  );
}
```

- [ ] **Step 8.2: Create `organization-form.tsx`**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateOrganization } from "@/lib/actions/organization";

export function OrganizationForm({ defaultName, slug }: { defaultName: string; slug: string }) {
  const t = useTranslations("Settings.organization");
  const router = useRouter();
  const [name, setName] = useState(defaultName);
  const [isPending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmed = name.trim();
    if (!trimmed || trimmed === defaultName) return;
    startTransition(async () => {
      try {
        await updateOrganization({ name: trimmed });
        toast.success(t("saveSuccess"));
        router.refresh();
      } catch {
        toast.error(t("saveFailed"));
      }
    });
  }

  return (
    <form onSubmit={onSubmit} className="flex max-w-md flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="org-name">{t("nameLabel")}</Label>
        <Input
          id="org-name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder={t("namePlaceholder")}
          required
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="org-slug">{t("slugLabel")}</Label>
        <Input id="org-slug" value={slug} disabled />
        <p className="text-xs text-muted-foreground">{t("slugHelper")}</p>
      </div>
      <div>
        <Button type="submit" disabled={isPending || !name.trim() || name.trim() === defaultName}>
          {isPending ? t("submitting") : t("submit")}
        </Button>
      </div>
    </form>
  );
}
```

- [ ] **Step 8.3: Create `danger-zone.tsx`**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { deleteOrganization, transferOwnership } from "@/lib/actions/organization";

export function DangerZone({
  orgName,
  admins,
}: {
  orgName: string;
  admins: { memberId: string; name: string; email: string }[];
}) {
  const t = useTranslations("Settings.organization");
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const [deleteConfirm, setDeleteConfirm] = useState("");
  const [selectedAdmin, setSelectedAdmin] = useState("");

  function onDelete() {
    if (deleteConfirm !== orgName) return;
    startTransition(async () => {
      try {
        await deleteOrganization();
        router.push("/onboarding");
      } catch {
        toast.error(t("deleteOrgFailed"));
      }
    });
  }

  function onTransfer() {
    if (!selectedAdmin) return;
    startTransition(async () => {
      try {
        await transferOwnership({ toUserId: selectedAdmin });
        toast.success("Ownership transferred.");
        router.refresh();
      } catch {
        toast.error(t("transferOwnershipFailed"));
      }
    });
  }

  return (
    <section className="flex flex-col gap-4 rounded-lg border border-destructive/30 p-6">
      <div className="flex flex-col gap-1">
        <h3 className="text-lg font-semibold text-destructive">{t("dangerZone")}</h3>
        <p className="text-sm text-muted-foreground">{t("dangerZoneDescription")}</p>
      </div>

      {/* Transfer ownership */}
      {admins.length > 0 && (
        <div className="flex flex-col gap-3 border-t pt-4">
          <div className="flex flex-col gap-1">
            <h4 className="text-sm font-semibold">{t("transferOwnership")}</h4>
            <p className="text-xs text-muted-foreground">{t("transferOwnershipDescription")}</p>
          </div>
          <div className="flex max-w-md items-end gap-2">
            <div className="flex-1 flex flex-col gap-1">
              <Label htmlFor="transfer-admin" className="text-xs">
                {t("transferOwnershipSelect")}
              </Label>
              <Select value={selectedAdmin} onValueChange={setSelectedAdmin}>
                <SelectTrigger id="transfer-admin">
                  <SelectValue placeholder={t("transferOwnershipSelect")} />
                </SelectTrigger>
                <SelectContent>
                  {admins.map((a) => (
                    <SelectItem key={a.memberId} value={a.memberId}>
                      {a.name} ({a.email})
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
            <Button
              type="button"
              variant="destructive"
              onClick={onTransfer}
              disabled={isPending || !selectedAdmin}
            >
              {t("transferOwnershipButton")}
            </Button>
          </div>
        </div>
      )}

      {/* Delete org */}
      <div className="flex flex-col gap-3 border-t pt-4">
        <div className="flex flex-col gap-1">
          <h4 className="text-sm font-semibold">{t("deleteOrg")}</h4>
          <p className="text-xs text-muted-foreground">{t("deleteOrgDescription", { orgName })}</p>
        </div>
        <AlertDialog>
          <AlertDialogTrigger asChild>
            <Button type="button" variant="destructive" className="self-start">
              {t("deleteOrg")}
            </Button>
          </AlertDialogTrigger>
          <AlertDialogContent>
            <AlertDialogHeader>
              <AlertDialogTitle>{t("deleteOrg")}</AlertDialogTitle>
              <AlertDialogDescription>
                {t("deleteOrgDescription", { orgName })}
              </AlertDialogDescription>
            </AlertDialogHeader>
            <div className="flex flex-col gap-2">
              <Label htmlFor="delete-confirm" className="text-xs">
                {t("deleteOrgConfirm", { orgName })}
              </Label>
              <Input
                id="delete-confirm"
                value={deleteConfirm}
                onChange={(e) => setDeleteConfirm(e.target.value)}
                autoFocus
              />
            </div>
            <AlertDialogFooter>
              <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
              <AlertDialogAction
                onClick={onDelete}
                disabled={isPending || deleteConfirm !== orgName}
                className="bg-destructive hover:bg-destructive/90"
              >
                {t("deleteOrgButton")}
              </AlertDialogAction>
            </AlertDialogFooter>
          </AlertDialogContent>
        </AlertDialog>
      </div>
    </section>
  );
}
```

- [ ] **Step 8.4: Typecheck + lint + commit**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/settings/organization/' 2>&1 | tail -3
git add 'app/[locale]/settings/organization/'
git commit -m "feat(settings/organization): name edit + danger zone (delete + transfer ownership)

Page gates admin+; OrganizationForm edits the org name (slug read-only).
DangerZone is owner-only: AlertDialog flow for delete (requires typing
the org name to confirm) + Select dropdown for transferring ownership
to a current admin. Both call the corresponding Server Actions."
```

---

## Task 9: `/settings/team` page

**Files:**

- Create: `app/[locale]/settings/team/page.tsx`
- Create: `app/[locale]/settings/team/invite-form.tsx`
- Create: `app/[locale]/settings/team/member-row.tsx`
- Create: `app/[locale]/settings/team/pending-invitation-row.tsx`

- [ ] **Step 9.1: Create `page.tsx`**

```tsx
import { eq } from "drizzle-orm";
import { getTranslations } from "next-intl/server";

import { requireOrgMember } from "@/lib/auth-guards";
import { listMembers, listPendingInvitations } from "@/lib/data/team";
import { db } from "@/lib/db";
import { organization } from "@/lib/db/schema";

import { InviteForm } from "./invite-form";
import { MemberRow } from "./member-row";
import { PendingInvitationRow } from "./pending-invitation-row";

export default async function TeamSettingsPage() {
  const { activeOrganizationId, role, user } = await requireOrgMember({ minRole: "admin" });

  const t = await getTranslations("Settings.team");
  const [orgRow] = await db
    .select({ id: organization.id, name: organization.name })
    .from(organization)
    .where(eq(organization.id, activeOrganizationId))
    .limit(1);
  if (!orgRow) throw new Error("Active organization missing");

  const [members, pending] = await Promise.all([
    listMembers(activeOrganizationId),
    listPendingInvitations(activeOrganizationId),
  ]);

  return (
    <section className="flex flex-col gap-8">
      <div className="flex flex-col gap-1">
        <h2 className="text-xl font-semibold">{t("title")}</h2>
        <p className="text-sm text-muted-foreground">
          {t("description", { orgName: orgRow.name })}
        </p>
      </div>

      <InviteForm />

      <div className="flex flex-col gap-3">
        <h3 className="text-sm font-semibold text-muted-foreground">
          {t("membersTitle", { count: members.length })}
        </h3>
        {members.length === 0 ? (
          <p className="text-sm text-muted-foreground">{t("noMembers")}</p>
        ) : (
          <ul className="flex flex-col gap-1">
            {members.map((m) => (
              <MemberRow
                key={m.id}
                member={{
                  id: m.id,
                  userId: m.userId,
                  name: m.name,
                  email: m.email,
                  role: m.role,
                }}
                viewerRole={role}
                viewerUserId={user.id}
                orgName={orgRow.name}
              />
            ))}
          </ul>
        )}
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-sm font-semibold text-muted-foreground">
          {t("pendingTitle", { count: pending.length })}
        </h3>
        {pending.length === 0 ? (
          <p className="text-sm text-muted-foreground">{t("noPending")}</p>
        ) : (
          <ul className="flex flex-col gap-1">
            {pending.map((p) => (
              <PendingInvitationRow key={p.id} invitation={p} />
            ))}
          </ul>
        )}
      </div>
    </section>
  );
}
```

- [ ] **Step 9.2: Create `invite-form.tsx`**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { inviteMember } from "@/lib/actions/team";

export function InviteForm() {
  const t = useTranslations("Settings.team");
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<"admin" | "member">("member");
  const [isPending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmed = email.trim();
    if (!trimmed) return;
    startTransition(async () => {
      try {
        await inviteMember({ email: trimmed, role });
        toast.success(t("inviteSuccess", { email: trimmed }));
        setEmail("");
        router.refresh();
      } catch {
        toast.error(t("inviteFailed"));
      }
    });
  }

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3 rounded-lg border p-4">
      <h3 className="text-sm font-semibold">{t("inviteTitle")}</h3>
      <div className="flex flex-wrap items-end gap-2">
        <div className="flex min-w-[240px] flex-1 flex-col gap-1">
          <Label htmlFor="invite-email" className="text-xs">
            {t("inviteEmailLabel")}
          </Label>
          <Input
            id="invite-email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder={t("inviteEmailPlaceholder")}
            required
          />
        </div>
        <div className="flex flex-col gap-1">
          <Label htmlFor="invite-role" className="text-xs">
            {t("inviteRoleLabel")}
          </Label>
          <Select value={role} onValueChange={(v) => setRole(v as "admin" | "member")}>
            <SelectTrigger id="invite-role" className="w-[160px]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="member">{t("inviteRoleMember")}</SelectItem>
              <SelectItem value="admin">{t("inviteRoleAdmin")}</SelectItem>
            </SelectContent>
          </Select>
        </div>
        <Button type="submit" disabled={isPending || !email.trim()}>
          {isPending ? t("inviteSubmitting") : t("inviteSubmit")}
        </Button>
      </div>
    </form>
  );
}
```

- [ ] **Step 9.3: Create `member-row.tsx`**

```tsx
"use client";

import { useTransition } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { RoleBadge } from "@/components/org/role-badge";
import { removeMember, updateMemberRole } from "@/lib/actions/team";

export function MemberRow({
  member,
  viewerRole,
  viewerUserId,
  orgName,
}: {
  member: {
    id: string;
    userId: string;
    name: string;
    email: string;
    role: "owner" | "admin" | "member";
  };
  viewerRole: "owner" | "admin" | "member";
  viewerUserId: string;
  orgName: string;
}) {
  const t = useTranslations("Settings.team");
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const isSelf = member.userId === viewerUserId;
  const isOwner = member.role === "owner";
  // viewerRole must be admin+ to reach this page already.
  // Disable mutation buttons for: the owner row (you can't change/remove the owner here),
  // and for the viewer's own row.
  const canMutate = !isOwner && !isSelf;

  function onRoleChange(newRole: "admin" | "member") {
    if (newRole === member.role) return;
    startTransition(async () => {
      try {
        await updateMemberRole({ memberId: member.id, role: newRole });
        toast.success(t("memberRoleUpdated"));
        router.refresh();
      } catch {
        toast.error(t("memberRoleFailed"));
      }
    });
  }

  function onRemove() {
    startTransition(async () => {
      try {
        await removeMember({ memberId: member.id });
        toast.success(t("memberRemoveSuccess"));
        router.refresh();
      } catch {
        toast.error(t("memberRemoveFailed"));
      }
    });
  }

  return (
    <li className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
      <div className="flex min-w-0 flex-col">
        <span className="truncate font-medium">{member.name}</span>
        <span className="truncate text-xs text-muted-foreground">{member.email}</span>
      </div>
      <div className="flex items-center gap-2">
        {canMutate ? (
          <Select
            value={member.role === "owner" ? "owner" : member.role}
            onValueChange={(v) => onRoleChange(v as "admin" | "member")}
            disabled={isPending}
          >
            <SelectTrigger className="w-[140px]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="member">{t("inviteRoleMember")}</SelectItem>
              <SelectItem value="admin">{t("inviteRoleAdmin")}</SelectItem>
            </SelectContent>
          </Select>
        ) : (
          <RoleBadge role={member.role} />
        )}

        {canMutate && (
          <AlertDialog>
            <AlertDialogTrigger asChild>
              <Button type="button" variant="ghost" size="sm" disabled={isPending}>
                {t("memberRemove")}
              </Button>
            </AlertDialogTrigger>
            <AlertDialogContent>
              <AlertDialogHeader>
                <AlertDialogTitle>{t("memberRemove")}</AlertDialogTitle>
                <AlertDialogDescription>
                  {t("memberRemoveConfirm", { name: member.name, orgName })}
                </AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
                <AlertDialogAction onClick={onRemove} disabled={isPending}>
                  {t("memberRemove")}
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </AlertDialog>
        )}
      </div>
    </li>
  );
}
```

- [ ] **Step 9.4: Create `pending-invitation-row.tsx`**

```tsx
"use client";

import { useTransition } from "react";

import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { RoleBadge } from "@/components/org/role-badge";
import { revokeInvitation } from "@/lib/actions/team";

export function PendingInvitationRow({
  invitation,
}: {
  invitation: { id: string; email: string; role: "admin" | "member"; expiresAt: Date };
}) {
  const t = useTranslations("Settings.team");
  const locale = useLocale();
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const dateLocale = locale === "fr" ? "fr-FR" : locale === "es" ? "es-ES" : "en-US";
  const fmt = new Intl.DateTimeFormat(dateLocale, { dateStyle: "medium" });

  function onRevoke() {
    startTransition(async () => {
      try {
        await revokeInvitation({ invitationId: invitation.id });
        toast.success(t("invitationRevokeSuccess"));
        router.refresh();
      } catch {
        toast.error(t("invitationRevokeFailed"));
      }
    });
  }

  return (
    <li className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
      <div className="flex min-w-0 flex-col">
        <span className="truncate font-medium">{invitation.email}</span>
        <span className="truncate text-xs text-muted-foreground">
          {t("invitationExpiresAt", { date: fmt.format(invitation.expiresAt) })}
        </span>
      </div>
      <div className="flex items-center gap-2">
        <RoleBadge role={invitation.role} />
        <AlertDialog>
          <AlertDialogTrigger asChild>
            <Button type="button" variant="ghost" size="sm" disabled={isPending}>
              {t("invitationRevoke")}
            </Button>
          </AlertDialogTrigger>
          <AlertDialogContent>
            <AlertDialogHeader>
              <AlertDialogTitle>{t("invitationRevoke")}</AlertDialogTitle>
              <AlertDialogDescription>
                {t("invitationRevokeConfirm", { email: invitation.email })}
              </AlertDialogDescription>
            </AlertDialogHeader>
            <AlertDialogFooter>
              <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
              <AlertDialogAction onClick={onRevoke} disabled={isPending}>
                {t("invitationRevoke")}
              </AlertDialogAction>
            </AlertDialogFooter>
          </AlertDialogContent>
        </AlertDialog>
      </div>
    </li>
  );
}
```

- [ ] **Step 9.5: Typecheck + lint + commit**

```bash
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/settings/team/' 2>&1 | tail -3
git add 'app/[locale]/settings/team/'
git commit -m "feat(settings/team): /settings/team page with invite form + members + pending list

Page gates admin+. InviteForm sends invitations (triggers Resend email
via Better Auth callback). MemberRow shows name+email+role; admin+ can
change role to admin/member and remove non-owner non-self members.
PendingInvitationRow shows email+expiry+role with a revoke action.
All mutations follow the guard+Zod pattern in lib/actions/team.ts."
```

---

## Task 10: Smoke test for invite flow

**Files:**

- Create: `scripts/verify-team-invite.ts`
- Modify: `package.json`

- [ ] **Step 10.1: Create the smoke test**

```ts
import { and, eq, like, notExists } from "drizzle-orm";
import { nanoid } from "nanoid";

import { auth } from "../lib/auth";
import { db } from "../lib/db";
import { invitation, member, organization, user } from "../lib/db/schema";

const OWNER_EMAIL = `smoke-owner-${nanoid(8)}@elevaseo.com`;
const INVITEE_EMAIL = `smoke-invitee-${nanoid(8)}@elevaseo.com`;
const PASSWORD = "SmokeTeam!2026";

async function main() {
  // 1. Sign up owner
  console.log(`→ Signing up owner ${OWNER_EMAIL}`);
  const ownerRes = await auth.api.signUpEmail({
    body: { name: "Smoke Owner", email: OWNER_EMAIL, password: PASSWORD },
  });
  const ownerId = ownerRes.user.id;

  // Resolve owner's Personal Org (Phase 1 hook).
  const ownerMem = await db.select().from(member).where(eq(member.userId, ownerId)).limit(1);
  if (ownerMem.length === 0) throw new Error("Owner personal org missing");
  const orgId = ownerMem[0]!.organizationId;

  // 2. Insert an invitation row directly (skipping the email step — we test
  //    the DB-level flow, not Resend; Phase 4 Resend integration is wired
  //    but optional in dev when RESEND_API_KEY is unset).
  const invId = nanoid();
  await db.insert(invitation).values({
    id: invId,
    organizationId: orgId,
    email: INVITEE_EMAIL,
    role: "member",
    status: "pending",
    inviterId: ownerId,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  });
  console.log(`→ Invitation inserted: ${invId}`);

  // 3. Sign up invitee
  console.log(`→ Signing up invitee ${INVITEE_EMAIL}`);
  const inviteeRes = await auth.api.signUpEmail({
    body: { name: "Smoke Invitee", email: INVITEE_EMAIL, password: PASSWORD },
  });
  const inviteeId = inviteeRes.user.id;

  try {
    // 4. Accept the invitation as the invitee. Better Auth's acceptInvitation
    //    needs an authenticated headers context; here we simulate by directly
    //    inserting the member row + marking the invitation accepted. The full
    //    integration is exercised by the actual /accept-invitation page in
    //    Phase 3.
    await db.transaction(async (tx) => {
      await tx.insert(member).values({
        id: nanoid(),
        organizationId: orgId,
        userId: inviteeId,
        role: "member",
      });
      await tx.update(invitation).set({ status: "accepted" }).where(eq(invitation.id, invId));
    });
    console.log(`✓ Membership inserted`);

    // 5. Assert the invitee is now a member with role "member"
    const memberRows = await db
      .select()
      .from(member)
      .where(and(eq(member.userId, inviteeId), eq(member.organizationId, orgId)));
    if (memberRows.length !== 1) {
      throw new Error(`Expected 1 membership for invitee, got ${memberRows.length}`);
    }
    if (memberRows[0]!.role !== "member") {
      throw new Error(`Expected role "member", got "${memberRows[0]!.role}"`);
    }
    console.log(`✓ Invitee has role member in org ${orgId}`);

    // 6. Assert the invitation is no longer pending
    const invRows = await db.select().from(invitation).where(eq(invitation.id, invId));
    if (invRows[0]!.status !== "accepted") {
      throw new Error(`Expected invitation status "accepted", got "${invRows[0]!.status}"`);
    }
    console.log(`✓ Invitation marked accepted`);
  } finally {
    // Cleanup both users (cascades members + sessions); sweep orphan orgs.
    await db.delete(user).where(eq(user.id, inviteeId));
    await db.delete(user).where(eq(user.id, ownerId));
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Personal — smoke-owner-%"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Personal — smoke-invitee-%"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    console.log(`→ Cleanup done`);
  }

  console.log(`✅ verify-team-invite OK`);
  process.exit(0);
}

main().catch((e) => {
  console.error(`❌ verify-team-invite FAILED:`, e instanceof Error ? e.message : e);
  process.exit(1);
});
```

- [ ] **Step 10.2: Add to `package.json`** (right after `verify:org-switch`):

```json
"verify:team-invite": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/verify-team-invite.ts",
```

- [ ] **Step 10.3: Run + commit**

```bash
pnpm verify:team-invite
git add scripts/verify-team-invite.ts package.json
git commit -m "test: add smoke test for team invitation flow (verify-team-invite)

Signs up an owner + invitee, inserts a pending invitation directly,
accepts it (insert member + mark invitation accepted) inside a tx,
asserts the membership + invitation status, cleans up. Validates the
data shape that lib/actions/team.ts inviteMember / acceptInvitation
will produce — the actual API integration is exercised by the /accept-
invitation page from Phase 3 + the Resend callback from Phase 4 Task 3."
```

---

## Task 11: Final validation + format

- [ ] **Step 11.1: Full validation chain**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck
pnpm lint
pnpm verify:org-signup
pnpm verify:org-sites
pnpm verify:org-switch
pnpm verify:team-invite
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/fr.json | sort)
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/es.json | sort)
```

All must pass. Both diffs empty.

- [ ] **Step 11.2: Format pass on new files**

```bash
pnpm format
```

If any new files were touched, commit:

```bash
git add -A
git commit -m "chore(format): oxfmt pass on Phase 4 new files"
```

- [ ] **Step 11.3: Manual E2E checklist** (tell user)

User to run `! pnpm dev:agent` and:

1. Log in as `owner-test@elevaseo.com` / `OwnerTest!2026`.
2. Visit `/settings` — should redirect to `/settings/profile`. Tabs visible.
3. Edit name on profile → save → toast success.
4. Click "Organization" tab → see org name editable + danger zone (since owner).
5. Click "Team" tab → see InviteForm + MemberRow for owner-test (admin-test + member-test if they were inserted by the seed script).
6. Submit invite form with a fresh email → toast "Invitation sent". Verify the email arrives if RESEND_API_KEY is set (Resend dashboard shows the send).
7. Log in as `admin-test@elevaseo.com` → `/settings/team` accessible. Danger zone HIDDEN on organization page (since admin, not owner).
8. Log in as `member-test@elevaseo.com` → `/settings/team` should be inaccessible (403 / error boundary). `/settings/profile` works.

(`/accept-invitation/[id]` flow can be tested by copying the invitation id from DB after an invite + visiting `/accept-invitation/{id}` in another browser session.)

---

## Critères d'acceptation Phase 4

- ✅ `requireOrgMember({ minRole })` guard works for member / admin / owner with the right error messages.
- ✅ `lib/actions/organization.ts` + `lib/actions/team.ts` + `lib/actions/profile.ts` all use guard+Zod pattern.
- ✅ `/settings/profile` editable for any member.
- ✅ `/settings/organization` editable for admin+, danger zone (delete + transfer) visible only for owner.
- ✅ `/settings/team` shows members + pending invitations for admin+, with invite/role/remove/revoke actions.
- ✅ Resend `sendInvitationEmail` callback wired in `lib/auth.ts` and triggered by `inviteMember`.
- ✅ i18n parity (EN/FR/ES) across all new namespaces.
- ✅ Smoke test `verify:team-invite` PASS.
- ✅ `pnpm typecheck && pnpm lint` clean.

## Next phase

Une fois Phase 4 mergée dans `dev`, on rédige le plan Phase 5 : **`es_org_subscription` table + `PLAN_LIMITS` + `checkQuota` + UsageMeter UI**. Les 4 questions ouvertes de `docs/planning/to-think.md` (Q1/Q5/Q7/Q9) doivent être tranchées avant d'écrire le plan Phase 5.
