# Password Reset & MFA 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:** Add self-service password reset and TOTP MFA with per-tenant enforcement to Cameleer SaaS.

**Architecture:** Password reset uses Logto Experience API from the custom sign-in UI (no backend changes). MFA enrollment uses Logto Management API via new backend endpoints. Per-tenant enforcement via `mfa_enrolled` JWT claim + `settings.mfaRequired` tenant config. Backup codes for recovery.

**Tech Stack:** Logto Experience API, Logto Management API, Spring Security filter, React 19, `qrcode.react`, `@cameleer/design-system`

**Spec:** `docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md`

---

## File Map

### New Files

| File | Responsibility |
|------|---------------|
| `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java` | Spring filter: checks `mfa_enrolled` JWT claim against tenant `settings.mfaRequired` |
| `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java` | Unauthenticated endpoint for password reset security email |
| `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java` | Sends security notification email via SMTP |
| `src/main/resources/email-templates/password-reset-notification.html` | Branded email: "Your password was reset" |
| `docs/superpowers/specs/2026-04-26-server-mfa-handoff.md` | Handoff doc for cameleer-server MFA enrollment |

### Modified Files

| File | Changes |
|------|---------|
| `ui/sign-in/src/experience-api.ts` | Add `initForgotPassword()`, `forgotPasswordSendCode()`, `forgotPasswordVerifyAndReset()`, `verifyTotp()`, `verifyBackupCode()`, `submitMfa()` |
| `ui/sign-in/src/SignInPage.tsx` | Add modes: `forgotPassword`, `forgotPasswordVerify`, `mfaVerify`, `mfaBackupCode` |
| `ui/sign-in/src/SignInPage.module.css` | Styles for forgot-password link, MFA backup code card |
| `ui/sign-in/package.json` | No new deps (no QR code in sign-in UI) |
| `ui/package.json` | Add `qrcode.react` dependency |
| `ui/src/api/tenant-hooks.ts` | Add MFA hooks: `useMfaStatus`, `useMfaSetup`, `useMfaVerify`, `useMfaBackupCodes`, `useMfaRemove`, `useResetTeamMemberMfa`, `useUpdateTenantSettings` |
| `ui/src/types/api.ts` | Add `MfaStatus`, `MfaSetupResponse`, `BackupCodesResponse` types, extend `TenantSettings` |
| `ui/src/pages/tenant/SettingsPage.tsx` | Add MFA enrollment section + MFA enforcement toggle |
| `ui/src/pages/tenant/TeamPage.tsx` | Add "Reset MFA" action button |
| `ui/src/api/client.ts` | Add `APP_MFA_REQUIRED` interceptor |
| `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java` | Add MFA Management API methods |
| `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java` | Add MFA endpoints + settings PATCH |
| `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java` | Add MFA business logic |
| `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` | Register `MfaEnforcementFilter` in filter chain |
| `docker/logto-bootstrap.sh` | MFA config in Phase 8c, `mfa_enrolled` in Phase 7b JWT script |

---

## Task 1: Password Reset — Experience API Functions

**Files:**
- Modify: `ui/sign-in/src/experience-api.ts`

- [ ] **Step 1: Add forgot-password API functions**

Append after the `// --- Registration ---` section:

```typescript
// --- Forgot Password ---

export async function initForgotPassword(): Promise<void> {
  const res = await request('PUT', '', { interactionEvent: 'ForgotPassword' });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.message || `Failed to initialize password reset (${res.status})`);
  }
}

export async function forgotPasswordSendCode(email: string): Promise<string> {
  const res = await request('POST', '/verification/verification-code', {
    identifier: { type: 'email', value: email },
    interactionEvent: 'ForgotPassword',
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    if (res.status === 422) {
      throw new Error('No account found with this email');
    }
    throw new Error(err.message || `Failed to send reset code (${res.status})`);
  }
  const data = await res.json();
  return data.verificationId;
}

export async function forgotPasswordVerifyAndReset(
  email: string,
  verificationId: string,
  code: string,
  newPassword: string,
): Promise<void> {
  const verifiedId = await verifyCode(email, verificationId, code);
  await identifyUser(verifiedId);
  await addProfile('password', newPassword);
  await submitInteraction();
}

export async function startForgotPassword(email: string): Promise<string> {
  await initForgotPassword();
  return forgotPasswordSendCode(email);
}
```

- [ ] **Step 2: Add MFA verification API functions**

Append after the forgot-password section:

```typescript
// --- MFA Verification ---

export async function verifyTotp(code: string): Promise<string> {
  const res = await request('POST', '/verification/totp/verify', { code });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    if (res.status === 422) {
      throw new Error('Invalid code, please try again');
    }
    throw new Error(err.message || `TOTP verification failed (${res.status})`);
  }
  const data = await res.json();
  return data.verificationId;
}

export async function verifyBackupCode(code: string): Promise<string> {
  const res = await request('POST', '/verification/backup-code/verify', { code });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    if (res.status === 422) {
      const msg = err.code === 'backup_code_consumed'
        ? 'This backup code has already been used'
        : 'Invalid backup code';
      throw new Error(msg);
    }
    throw new Error(err.message || `Backup code verification failed (${res.status})`);
  }
  const data = await res.json();
  return data.verificationId;
}

export async function submitMfa(verificationId: string): Promise<string> {
  await identifyUser(verificationId);
  return submitInteraction();
}
```

- [ ] **Step 3: Update signIn() to detect MFA requirement**

Replace the existing `signIn` function:

```typescript
export class MfaRequiredError extends Error {
  constructor() {
    super('MFA verification required');
    this.name = 'MfaRequiredError';
  }
}

export async function signIn(identifier: string, password: string): Promise<string> {
  await initInteraction();
  const verificationId = await verifyPassword(identifier, password);
  await identifyUser(verificationId);
  const res = await request('POST', '/submit');
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    if (err.code === 'user.missing_mfa') {
      throw new MfaRequiredError();
    }
    throw new Error(err.message || `Submit failed (${res.status})`);
  }
  const data = await res.json();
  return data.redirectTo;
}
```

Also update the import in `SignInPage.tsx` (next task) to import `MfaRequiredError`.

- [ ] **Step 4: Update the SignInPage import line**

In `ui/sign-in/src/SignInPage.tsx`, change the import to:

```typescript
import {
  signIn, startRegistration, completeRegistration,
  startForgotPassword, forgotPasswordVerifyAndReset,
  verifyTotp, verifyBackupCode, submitMfa,
  MfaRequiredError,
} from './experience-api';
```

- [ ] **Step 5: Commit**

```bash
git add ui/sign-in/src/experience-api.ts ui/sign-in/src/SignInPage.tsx
git commit -m "feat: add forgot-password and MFA verification Experience API functions"
```

---

## Task 2: Password Reset ��� Sign-in UI

**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- Modify: `ui/sign-in/src/SignInPage.module.css`

- [ ] **Step 1: Extend the Mode type and add state**

Change the `Mode` type and add new state variables:

```typescript
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
```

Add state for forgot-password fields inside `SignInPage()`, after the existing `verificationId` state:

```typescript
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
```

- [ ] **Step 2: Add emailConnectorConfigured state**

The "Forgot password?" link should only show when an email connector is configured. The existing `registrationEnabled` check covers the `signInMode`, but forgot-password needs an email connector regardless of registration state. Add state derived from the sign-in experience fetch.

After the existing `setRegistrationEnabled(enabled)` line in the `useEffect`, add:

```typescript
// Email connector is configured if sign-in-exp has passwordless methods or signUp has email verification
// For simplicity: if the response loads successfully and has connectorTargets or if registration is enabled
// (both require a configured email connector), forgot-password is available.
const hasEmailConnector = enabled || (data.forgotPassword?.email ?? false);
setEmailConnectorConfigured(hasEmailConnector);
```

Add the state variable:

```typescript
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
```

Actually, simpler: the forgot-password email template is always configured in `EmailConnectorService` when the email connector exists. The sign-in experience fetch returns the full config. Check for `signUp.verify` or the connector being present. The cleanest approach: if `registrationEnabled` is true, the email connector must be configured. But forgot-password should also work when registration is disabled but email connector exists. The safest check: fetch the connectors list. But that's an extra API call.

Pragmatic approach: show "Forgot password?" whenever an email connector exists. The sign-in experience `signUp` object has `identifiers` that include `email` when email connector is configured, even if registration is disabled. Check:

```typescript
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
setEmailConnectorConfigured(hasEmailConnector);
```

- [ ] **Step 3: Add forgot-password handler**

After `handleVerifyCode`, add:

```typescript
// --- Forgot password step 1: send reset code ---
const handleForgotPassword = async (e: FormEvent) => {
  e.preventDefault();
  setError(null);
  if (!identifier.includes('@')) {
    setError('Please enter your email address');
    return;
  }
  setLoading(true);
  try {
    const vId = await startForgotPassword(identifier);
    setVerificationId(vId);
    setMode('forgotPasswordVerify');
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Failed to send reset code');
  } finally {
    setLoading(false);
  }
};

// --- Forgot password step 2: verify code + set new password ---
const handleForgotPasswordVerify = async (e: FormEvent) => {
  e.preventDefault();
  setError(null);
  if (newPassword !== confirmNewPassword) {
    setError('Passwords do not match');
    return;
  }
  if (newPassword.length < 8) {
    setError('Password must be at least 8 characters');
    return;
  }
  setLoading(true);
  try {
    await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
    // Send security notification email (fire-and-forget)
    fetch('/platform/api/password-reset-notification', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: identifier }),
    }).catch(() => {});
    // Reset to sign-in with success message
    switchMode('signIn');
    setError(null);
    // Using a simple approach — show a transient success state
    setIdentifier(identifier); // preserve email for convenience
    alert('Password reset successful. Please sign in with your new password.');
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Password reset failed');
  } finally {
    setLoading(false);
  }
};
```

- [ ] **Step 4: Add "Forgot password?" link to sign-in form**

In the sign-in form JSX, after the password `FormField` closing tag and before the `<Button>` submit, add:

```tsx
{emailConnectorConfigured && (
  <button
    type="button"
    className={styles.forgotLink}
    onClick={() => { setError(null); setMode('forgotPassword'); }}
  >
    Forgot password?
  </button>
)}
```

- [ ] **Step 5: Add forgot-password email entry form**

After the `{/* --- Verification code form --- */}` block (before `</div></Card>`), add:

```tsx
{/* --- Forgot password: email entry --- */}
{mode === 'forgotPassword' && (
  <form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
    <p className={styles.verifyHint}>
      Enter your email address and we'll send you a code to reset your password.
    </p>

    <FormField label="Email" htmlFor="forgot-email">
      <Input
        id="forgot-email"
        type="email"
        value={identifier}
        onChange={(e) => setIdentifier(e.target.value)}
        placeholder="you@company.com"
        autoFocus
        autoComplete="email"
        disabled={loading}
      />
    </FormField>

    <Button
      variant="primary"
      type="submit"
      loading={loading}
      disabled={loading || !identifier}
      className={styles.submitButton}
    >
      Send reset code
    </Button>

    <p className={styles.switchText}>
      <button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
        Back to sign in
      </button>
    </p>
  </form>
)}

{/* --- Forgot password: verify code + new password --- */}
{mode === 'forgotPasswordVerify' && (
  <form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
    <p className={styles.verifyHint}>
      We sent a verification code to <strong>{identifier}</strong>
    </p>

    <FormField label="Verification code" htmlFor="forgot-code">
      <Input
        id="forgot-code"
        type="text"
        inputMode="numeric"
        value={code}
        onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
        placeholder="000000"
        autoFocus
        autoComplete="one-time-code"
        disabled={loading}
      />
    </FormField>

    <FormField label="New password" htmlFor="forgot-new-password">
      <div className={styles.passwordWrapper}>
        <Input
          id="forgot-new-password"
          type={showPassword ? 'text' : 'password'}
          value={newPassword}
          onChange={(e) => setNewPassword(e.target.value)}
          placeholder="At least 8 characters"
          autoComplete="new-password"
          disabled={loading}
        />
        {passwordToggle}
      </div>
    </FormField>

    <FormField label="Confirm new password" htmlFor="forgot-confirm-password">
      <Input
        id="forgot-confirm-password"
        type={showPassword ? 'text' : 'password'}
        value={confirmNewPassword}
        onChange={(e) => setConfirmNewPassword(e.target.value)}
        placeholder="••••••••"
        autoComplete="new-password"
        disabled={loading}
      />
    </FormField>

    <Button
      variant="primary"
      type="submit"
      loading={loading}
      disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
      className={styles.submitButton}
    >
      Reset password
    </Button>

    <p className={styles.switchText}>
      <button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
        Back
      </button>
    </p>
  </form>
)}
```

- [ ] **Step 6: Update switchMode to reset new fields**

In the `switchMode` function, add resets for the new state:

```typescript
const switchMode = (next: Mode) => {
  setMode(next);
  setPassword('');
  setConfirmPassword('');
  setNewPassword('');
  setConfirmNewPassword('');
  setCode('');
  setShowPassword(false);
  setVerificationId('');
};
```

- [ ] **Step 7: Add forgotLink CSS**

In `SignInPage.module.css`, add after `.switchLink:hover`:

```css
.forgotLink {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 13px;
  padding: 0;
  text-decoration: underline;
  text-align: right;
  align-self: flex-end;
  margin-top: -8px;
}

.forgotLink:hover {
  color: var(--text-link, #C6820E);
}
```

- [ ] **Step 8: Commit**

```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/src/SignInPage.module.css
git commit -m "feat: add forgot-password UI flow to custom sign-in page"
```

---

## Task 3: MFA Verification at Sign-in — UI

**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- Modify: `ui/sign-in/src/SignInPage.module.css`

- [ ] **Step 1: Update handleSignIn to catch MfaRequiredError**

Replace the existing `handleSignIn`:

```typescript
const handleSignIn = async (e: FormEvent) => {
  e.preventDefault();
  setError(null);
  setLoading(true);
  try {
    const redirectTo = await signIn(identifier, password);
    window.location.replace(redirectTo);
  } catch (err) {
    if (err instanceof MfaRequiredError) {
      setMode('mfaVerify');
      setLoading(false);
      return;
    }
    setError(err instanceof Error ? err.message : 'Sign-in failed');
    setLoading(false);
  }
};
```

- [ ] **Step 2: Add MFA verification handlers**

After `handleForgotPasswordVerify`, add:

```typescript
// --- MFA: TOTP verification ---
const handleMfaVerify = async (e: FormEvent) => {
  e.preventDefault();
  setError(null);
  setLoading(true);
  try {
    const verificationId = await verifyTotp(code);
    const redirectTo = await submitMfa(verificationId);
    window.location.replace(redirectTo);
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Verification failed');
    setLoading(false);
  }
};

// --- MFA: backup code verification ---
const handleBackupCodeVerify = async (e: FormEvent) => {
  e.preventDefault();
  setError(null);
  setLoading(true);
  try {
    const verificationId = await verifyBackupCode(code);
    const redirectTo = await submitMfa(verificationId);
    window.location.replace(redirectTo);
  } catch (err) {
    setError(err instanceof Error ? err.message : 'Verification failed');
    setLoading(false);
  }
};
```

- [ ] **Step 3: Add MFA verification forms**

After the forgot-password forms, before `</div></Card>`, add:

```tsx
{/* --- MFA: TOTP verification --- */}
{mode === 'mfaVerify' && (
  <form className={styles.fields} onSubmit={handleMfaVerify} aria-label="Two-factor authentication" noValidate>
    <p className={styles.verifyHint}>
      Enter the 6-digit code from your authenticator app.
    </p>

    <FormField label="Authentication code" htmlFor="mfa-totp-code">
      <Input
        id="mfa-totp-code"
        type="text"
        inputMode="numeric"
        value={code}
        onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
        placeholder="000000"
        autoFocus
        autoComplete="one-time-code"
        disabled={loading}
      />
    </FormField>

    <Button
      variant="primary"
      type="submit"
      loading={loading}
      disabled={loading || code.length < 6}
      className={styles.submitButton}
    >
      Verify
    </Button>

    <div className={styles.backupCodeCard}>
      <p className={styles.backupCodeText}>Lost your device?</p>
      <button
        type="button"
        className={styles.backupCodeAction}
        onClick={() => { setCode(''); setError(null); setMode('mfaBackupCode'); }}
      >
        Use a backup code
      </button>
    </div>
  </form>
)}

{/* --- MFA: backup code verification --- */}
{mode === 'mfaBackupCode' && (
  <form className={styles.fields} onSubmit={handleBackupCodeVerify} aria-label="Backup code verification" noValidate>
    <p className={styles.verifyHint}>
      Enter one of your 10 backup codes.
    </p>

    <FormField label="Backup code" htmlFor="mfa-backup-code">
      <Input
        id="mfa-backup-code"
        type="text"
        value={code}
        onChange={(e) => setCode(e.target.value)}
        placeholder="Enter backup code"
        autoFocus
        autoComplete="off"
        disabled={loading}
      />
    </FormField>

    <Button
      variant="primary"
      type="submit"
      loading={loading}
      disabled={loading || !code}
      className={styles.submitButton}
    >
      Verify backup code
    </Button>

    <p className={styles.switchText}>
      <button type="button" className={styles.switchLink} onClick={() => { setCode(''); setError(null); setMode('mfaVerify'); }}>
        Use authenticator app instead
      </button>
    </p>
  </form>
)}
```

- [ ] **Step 4: Add backup code card CSS**

In `SignInPage.module.css`, add:

```css
.backupCodeCard {
  margin-top: 4px;
  padding: 12px 16px;
  background: var(--bg-surface, #f8f7f5);
  border: 1px solid var(--border-default, #e8e0d4);
  border-radius: 6px;
  text-align: center;
}

.backupCodeText {
  font-size: 13px;
  color: var(--text-muted);
  margin: 0 0 6px;
}

.backupCodeAction {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--text-link, #C6820E);
  font-size: 14px;
  font-weight: 600;
  padding: 4px 8px;
  text-decoration: underline;
}

.backupCodeAction:hover {
  opacity: 0.8;
}
```

- [ ] **Step 5: Commit**

```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/src/SignInPage.module.css
git commit -m "feat: add MFA verification (TOTP + backup code) to sign-in flow"
```

---

## Task 4: Logto Bootstrap — MFA Config + JWT Claim

**Files:**
- Modify: `docker/logto-bootstrap.sh`

- [ ] **Step 1: Add MFA factors to Phase 8c sign-in experience patch**

In `docker/logto-bootstrap.sh`, find the Phase 8c PATCH call (the `PATCH /api/sign-in-exp` with `signInMode` and `signIn.methods`). Add the `mfa` field to the same JSON payload. The full patched call becomes:

```bash
api_patch "/api/sign-in-exp" '{
  "signInMode": "SignIn",
  "signIn": {
    "methods": [
      {
        "identifier": "email",
        "password": true,
        "verificationCode": false,
        "isPasswordPrimary": true
      },
      {
        "identifier": "username",
        "password": true,
        "verificationCode": false,
        "isPasswordPrimary": true
      }
    ]
  },
  "mfa": {
    "factors": ["Totp", "BackupCode"],
    "policy": "UserControlled"
  }
}'
```

- [ ] **Step 2: Extend custom JWT script in Phase 7b**

Replace the existing `CUSTOM_JWT_SCRIPT` variable with the extended version that includes `mfa_enrolled`:

```bash
CUSTOM_JWT_SCRIPT='const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
  const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
  const roles = new Set();
  if (context?.user?.organizationRoles) {
    for (const orgRole of context.user.organizationRoles) {
      const mapped = roleMap[orgRole.roleName];
      if (mapped) roles.add(mapped);
    }
  }
  if (context?.user?.roles) {
    for (const role of context.user.roles) {
      if (role.name === "saas-vendor") roles.add("server:admin");
    }
  }
  const mfaFactors = context?.user?.mfaVerificationFactors || [];
  const mfaEnrolled = mfaFactors.some(f => f.type === "Totp");
  const claims = {};
  if (roles.size > 0) claims.roles = [...roles];
  claims.mfa_enrolled = mfaEnrolled;
  return claims;
};'
```

- [ ] **Step 3: Commit**

```bash
git add docker/logto-bootstrap.sh
git commit -m "feat: configure MFA factors + mfa_enrolled JWT claim in Logto bootstrap"
```

---

## Task 5: Backend — LogtoManagementClient MFA Methods

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`

- [ ] **Step 1: Add getUserMfaVerifications method**

Add after the existing `updateUserPassword` method:

```java
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
    if (!isAvailable()) return List.of();
    try {
        String token = getAccessToken();
        String response = restClient.get()
                .uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
                .header("Authorization", "Bearer " + token)
                .retrieve()
                .body(String.class);
        if (response == null) return List.of();
        JsonNode arr = objectMapper.readTree(response);
        List<Map<String, Object>> result = new java.util.ArrayList<>();
        for (JsonNode node : arr) {
            result.add(objectMapper.convertValue(node, new com.fasterxml.jackson.core.type.TypeReference<>() {}));
        }
        return result;
    } catch (Exception e) {
        log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
        return List.of();
    }
}
```

- [ ] **Step 2: Add createTotpVerification method**

```java
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
public Map<String, Object> createTotpVerification(String userId, String secret) {
    if (!isAvailable()) return Map.of();
    try {
        String token = getAccessToken();
        String body = objectMapper.writeValueAsString(Map.of("type", "Totp", "secret", secret));
        String response = restClient.post()
                .uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
                .header("Authorization", "Bearer " + token)
                .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(String.class);
        if (response == null) return Map.of();
        return objectMapper.readValue(response, new com.fasterxml.jackson.core.type.TypeReference<>() {});
    } catch (Exception e) {
        log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
        return Map.of();
    }
}
```

- [ ] **Step 3: Add createBackupCodes method**

```java
/** Generate backup codes for a user. Returns the list of codes. */
public Map<String, Object> createBackupCodes(String userId) {
    if (!isAvailable()) return Map.of();
    try {
        String token = getAccessToken();
        String body = objectMapper.writeValueAsString(Map.of("type", "BackupCode"));
        String response = restClient.post()
                .uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
                .header("Authorization", "Bearer " + token)
                .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(String.class);
        if (response == null) return Map.of();
        return objectMapper.readValue(response, new com.fasterxml.jackson.core.type.TypeReference<>() {});
    } catch (Exception e) {
        log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
        return Map.of();
    }
}
```

- [ ] **Step 4: Add deleteMfaVerification method**

```java
/** Delete a specific MFA verification for a user. */
public void deleteMfaVerification(String userId, String verificationId) {
    if (!isAvailable()) return;
    try {
        String token = getAccessToken();
        restClient.delete()
                .uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications/{verificationId}", userId, verificationId)
                .header("Authorization", "Bearer " + token)
                .retrieve()
                .toBodilessEntity();
    } catch (Exception e) {
        log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
    }
}
```

- [ ] **Step 5: Add deleteAllMfaVerifications method**

```java
/** Delete all MFA verifications for a user (used for admin MFA reset). */
public void deleteAllMfaVerifications(String userId) {
    List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
    for (Map<String, Object> v : verifications) {
        String id = String.valueOf(v.get("id"));
        deleteMfaVerification(userId, id);
    }
}
```

- [ ] **Step 6: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat: add MFA Management API methods to LogtoManagementClient"
```

---

## Task 6: Backend — MFA Portal Endpoints

**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`

- [ ] **Step 1: Add MFA methods to TenantPortalService**

Add these methods to `TenantPortalService`:

```java
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes) {}

public record MfaSetupData(String secret, String secretQrCode) {}

public record BackupCodesData(List<String> codes) {}

public MfaStatusData getMfaStatus(String userId) {
    var verifications = logtoClient.getUserMfaVerifications(userId);
    boolean hasTotp = verifications.stream().anyMatch(v -> "Totp".equals(v.get("type")));
    boolean hasBackup = verifications.stream().anyMatch(v -> "BackupCode".equals(v.get("type")));
    return new MfaStatusData(hasTotp, hasBackup);
}

public MfaSetupData setupTotp(String userId) {
    // Generate a random TOTP secret (base32, 20 bytes)
    byte[] bytes = new byte[20];
    new java.security.SecureRandom().nextBytes(bytes);
    String secret = new org.apache.commons.codec.binary.Base32().encodeToString(bytes);
    var result = logtoClient.createTotpVerification(userId, secret);
    String qrCode = (String) result.getOrDefault("secretQrCode", "");
    return new MfaSetupData(secret, qrCode);
}

public boolean verifyTotpCode(String secret, String code) {
    // TOTP verification: compute expected code from secret and compare
    // Uses the same algorithm as authenticator apps (RFC 6238)
    long timeStep = System.currentTimeMillis() / 1000 / 30;
    for (int i = -1; i <= 1; i++) { // allow 1 step drift
        String expected = computeTotp(secret, timeStep + i);
        if (expected.equals(code)) return true;
    }
    return false;
}

private String computeTotp(String base32Secret, long timeStep) {
    try {
        byte[] key = new org.apache.commons.codec.binary.Base32().decode(base32Secret);
        byte[] data = java.nio.ByteBuffer.allocate(8).putLong(timeStep).array();
        javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
        mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"));
        byte[] hash = mac.doFinal(data);
        int offset = hash[hash.length - 1] & 0xf;
        int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16)
                | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
        int otp = binary % 1_000_000;
        return String.format("%06d", otp);
    } catch (Exception e) {
        throw new IllegalStateException("TOTP computation failed", e);
    }
}

public BackupCodesData generateBackupCodes(String userId) {
    var result = logtoClient.createBackupCodes(userId);
    @SuppressWarnings("unchecked")
    List<String> codes = (List<String>) result.getOrDefault("codes", List.of());
    return new BackupCodesData(codes);
}

public void removeTotp(String userId) {
    var verifications = logtoClient.getUserMfaVerifications(userId);
    for (var v : verifications) {
        String id = String.valueOf(v.get("id"));
        logtoClient.deleteMfaVerification(userId, id);
    }
}

public void resetTeamMemberMfa(String userId) {
    // Verify the user is a member of this tenant's org
    var tenant = tenantService.getById(TenantContext.getTenantId())
            .orElseThrow(() -> new IllegalStateException("Tenant not found"));
    String orgId = tenant.getLogtoOrgId();
    var members = logtoClient.listOrganizationMembers(orgId);
    boolean isMember = members.stream().anyMatch(m ->
            userId.equals(m.get("id")) || userId.equals(m.get("userId")));
    if (!isMember) {
        throw new IllegalArgumentException("User is not a member of this organization");
    }
    logtoClient.deleteAllMfaVerifications(userId);
}
```

- [ ] **Step 2: Add MFA endpoints to TenantPortalController**

Add these endpoints to `TenantPortalController`:

```java
@GetMapping("/mfa/status")
public ResponseEntity<?> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
    try {
        return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}

@PostMapping("/mfa/totp/setup")
public ResponseEntity<?> setupTotp(@AuthenticationPrincipal Jwt jwt) {
    try {
        return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject()));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}

public record TotpVerifyRequest(String secret, String code) {}

@PostMapping("/mfa/totp/verify")
public ResponseEntity<?> verifyTotp(
        @AuthenticationPrincipal Jwt jwt,
        @RequestBody TotpVerifyRequest request) {
    try {
        boolean valid = portalService.verifyTotpCode(request.secret(), request.code());
        if (!valid) {
            return ResponseEntity.status(422).body(Map.of("error", "Invalid TOTP code"));
        }
        // TOTP is already bound via setupTotp — verification confirms the user has the right secret
        return ResponseEntity.ok(Map.of("verified", true));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}

@PostMapping("/mfa/backup-codes")
public ResponseEntity<?> generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
    try {
        return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}

@DeleteMapping("/mfa/totp")
public ResponseEntity<?> removeTotp(@AuthenticationPrincipal Jwt jwt) {
    try {
        portalService.removeTotp(jwt.getSubject());
        return ResponseEntity.noContent().build();
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}

@DeleteMapping("/users/{userId}/mfa")
public ResponseEntity<?> resetTeamMemberMfa(@PathVariable String userId) {
    try {
        portalService.resetTeamMemberMfa(userId);
        return ResponseEntity.noContent().build();
    } catch (IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}
```

- [ ] **Step 3: Add tenant settings PATCH endpoint**

Add to `TenantPortalController`:

```java
@PatchMapping("/settings")
public ResponseEntity<?> updateSettings(
        @RequestBody Map<String, Object> updates) {
    try {
        portalService.updateTenantSettings(updates);
        return ResponseEntity.ok().build();
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
    }
}
```

Add to `TenantPortalService`:

```java
public void updateTenantSettings(Map<String, Object> updates) {
    var tenant = tenantService.getById(TenantContext.getTenantId())
            .orElseThrow(() -> new IllegalStateException("Tenant not found"));
    var settings = new java.util.HashMap<>(tenant.getSettings());
    // Only allow known settings keys
    if (updates.containsKey("mfaRequired")) {
        settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired")));
    }
    tenant.setSettings(settings);
    tenantService.save(tenant);
}
```

- [ ] **Step 4: Add MFA policy endpoint for server consumption**

Add to `TenantPortalController`:

```java
@GetMapping("/{slug}/mfa-policy")
public ResponseEntity<?> getMfaPolicy(@PathVariable String slug) {
    try {
        var tenant = tenantService.getBySlug(slug)
                .orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
        boolean mfaRequired = Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"));
        return ResponseEntity.ok(Map.of("mfaRequired", mfaRequired));
    } catch (IllegalArgumentException e) {
        return ResponseEntity.notFound().build();
    }
}
```

Note: this endpoint must be accessible with M2M tokens. The existing `/api/tenant/**` path requires `authenticated()` in `SecurityConfig`, which accepts any valid JWT including M2M tokens.

- [ ] **Step 5: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
git commit -m "feat: add MFA enrollment, removal, and settings endpoints"
```

---

## Task 7: Backend — MFA Enforcement Filter

**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`

- [ ] **Step 1: Create MfaEnforcementFilter**

```java
package net.siegeln.cameleer.saas.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantContext;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Map;
import java.util.Set;

@Component
public class MfaEnforcementFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
    private static final String ERROR_CODE = "APP_MFA_REQUIRED";
    private static final Set<String> EXEMPT_PREFIXES = Set.of(
            "/api/tenant/mfa/",
            "/api/config",
            "/api/me",
            "/api/onboarding"
    );

    private final TenantService tenantService;
    private final ObjectMapper objectMapper;

    public MfaEnforcementFilter(TenantService tenantService, ObjectMapper objectMapper) {
        this.tenantService = tenantService;
        this.objectMapper = objectMapper;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        // Only apply to tenant API paths
        if (!path.startsWith("/api/tenant/")) return true;
        // Exempt MFA enrollment and essential endpoints
        return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        var auth = SecurityContextHolder.getContext().getAuthentication();
        if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
            filterChain.doFilter(request, response);
            return;
        }

        Jwt jwt = jwtAuth.getToken();
        Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");

        // If user has MFA enrolled, no enforcement needed
        if (Boolean.TRUE.equals(mfaEnrolled)) {
            filterChain.doFilter(request, response);
            return;
        }

        // Check if tenant requires MFA
        var tenantId = TenantContext.getTenantId();
        if (tenantId == null) {
            filterChain.doFilter(request, response);
            return;
        }

        var tenant = tenantService.getById(tenantId).orElse(null);
        if (tenant == null || !Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"))) {
            filterChain.doFilter(request, response);
            return;
        }

        // Tenant requires MFA but user is not enrolled — block
        log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug());
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setHeader("X-Cameleer-Error", ERROR_CODE);
        objectMapper.writeValue(response.getOutputStream(), Map.of(
                "error", ERROR_CODE,
                "code", "mfa_enrollment_required",
                "message", "Your organization requires multi-factor authentication"
        ));
    }
}
```

- [ ] **Step 2: Register the filter in SecurityConfig**

In `SecurityConfig.java`, add the filter after the OAuth2 resource server filter. In the `filterChain` method, add:

```java
.addFilterAfter(mfaEnforcementFilter, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class)
```

Inject the filter in the `SecurityConfig` constructor or method parameter:

```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception {
    // ... existing config ...
    http.addFilterAfter(mfaEnforcementFilter, 
        org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class);
    return http.build();
}
```

- [ ] **Step 3: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: add MFA enforcement filter with APP_MFA_REQUIRED error code"
```

---

## Task 8: Backend — Password Reset Security Notification

**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java`
- Create: `src/main/resources/email-templates/password-reset-notification.html`
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`

- [ ] **Step 1: Create the email template**

```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
  <div style="background:#C6820E;padding:20px 24px;text-align:center;">
    <span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
  </div>
  <div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
    <img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
    <div style="position:relative;">
      <p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Your password was reset</p>
      <p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your Cameleer account password was successfully changed on {{timestamp}}.</p>
      <div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
        <p style="color:#444;font-size:13px;line-height:1.5;margin:0;"><strong>Note:</strong> Multi-factor authentication (MFA) was not required for this password reset. We recommend enabling MFA to add an extra layer of security to your account.</p>
      </div>
      <p style="color:#888;font-size:13px;line-height:1.5;margin:0;">If this wasn't you, contact your administrator immediately.</p>
    </div>
  </div>
  <div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
    <p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
    <p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
  </div>
</div>
```

- [ ] **Step 2: Create PasswordResetNotificationService**

```java
package net.siegeln.cameleer.saas.notification;

import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Service
public class PasswordResetNotificationService {

    private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class);

    private final LogtoManagementClient logtoClient;
    private final JavaMailSender mailSender;
    private final String templateHtml;

    public PasswordResetNotificationService(
            LogtoManagementClient logtoClient,
            JavaMailSender mailSender) throws IOException {
        this.logtoClient = logtoClient;
        this.mailSender = mailSender;
        this.templateHtml = new ClassPathResource("email-templates/password-reset-notification.html")
                .getContentAsString(StandardCharsets.UTF_8);
    }

    public void sendNotification(String email) {
        // Verify the email exists in Logto (prevent enumeration abuse)
        // This is a best-effort check ��� if Logto is down, skip silently
        try {
            // The Management API doesn't have a direct "find by email" — we use getUser with email
            // For rate limiting purposes, we just attempt to send and let SMTP handle invalid addresses
            String timestamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'")
                    .format(Instant.now().atOffset(ZoneOffset.UTC));
            String html = templateHtml
                    .replace("{{timestamp}}", timestamp)
                    .replace("{{watermarkUrl}}", "");
            var message = mailSender.createMimeMessage();
            var helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setTo(email);
            helper.setSubject("Your Cameleer password was reset");
            helper.setText(html, true);
            mailSender.send(message);
            log.info("Sent password reset notification to {}", email);
        } catch (Exception e) {
            log.warn("Failed to send password reset notification to {}: {}", email, e.getMessage());
        }
    }
}
```

- [ ] **Step 3: Create PasswordResetNotificationController**

```java
package net.siegeln.cameleer.saas.notification;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@RestController
@RequestMapping("/api/password-reset-notification")
public class PasswordResetNotificationController {

    private final PasswordResetNotificationService notificationService;
    // Simple in-memory rate limiter: max 3 requests per email per 10 minutes
    private final ConcurrentHashMap<String, RateLimit> rateLimits = new ConcurrentHashMap<>();

    public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public record NotificationRequest(String email) {}

    @PostMapping
    public ResponseEntity<?> notify(@RequestBody NotificationRequest request) {
        if (request.email() == null || !request.email().contains("@")) {
            return ResponseEntity.badRequest().body(Map.of("error", "Invalid email"));
        }
        // Rate limit check
        String key = request.email().toLowerCase();
        RateLimit limit = rateLimits.compute(key, (k, existing) -> {
            long now = System.currentTimeMillis();
            if (existing == null || now - existing.windowStart > 600_000) {
                return new RateLimit(now, new AtomicInteger(1));
            }
            existing.count.incrementAndGet();
            return existing;
        });
        if (limit.count.get() > 3) {
            return ResponseEntity.status(429).body(Map.of("error", "Too many requests"));
        }
        // Fire-and-forget in a thread to not block the response
        notificationService.sendNotification(request.email());
        return ResponseEntity.ok(Map.of("sent", true));
    }

    private record RateLimit(long windowStart, AtomicInteger count) {}
}
```

- [ ] **Step 4: Permit the notification endpoint in SecurityConfig**

In `SecurityConfig.java`, add to the `permitAll` paths:

```java
.requestMatchers("/api/password-reset-notification").permitAll()
```

Add this in the `authorizeHttpRequests` block, before the `/api/onboarding/**` line.

- [ ] **Step 5: Commit**

```bash
git add src/main/java/net/siegeln/cameleer/saas/notification/ src/main/resources/email-templates/password-reset-notification.html src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: add password reset security notification email endpoint"
```

---

## Task 9: Frontend — API Types and Hooks for MFA

**Files:**
- Modify: `ui/src/types/api.ts`
- Modify: `ui/src/api/tenant-hooks.ts`
- Modify: `ui/src/api/client.ts`

- [ ] **Step 1: Add MFA types**

In `ui/src/types/api.ts`, add at the end:

```typescript
// MFA types
export interface MfaStatus {
  enrolled: boolean;
  hasBackupCodes: boolean;
}

export interface MfaSetupResponse {
  secret: string;
  secretQrCode: string;
}

export interface BackupCodesResponse {
  codes: string[];
}
```

Extend the existing `TenantSettings` interface:

```typescript
export interface TenantSettings {
  name: string;
  slug: string;
  tier: string;
  status: string;
  serverEndpoint: string | null;
  createdAt: string;
  mfaRequired?: boolean;
}
```

- [ ] **Step 2: Add MFA hooks**

In `ui/src/api/tenant-hooks.ts`, add:

```typescript
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse } from '../types/api';

// ... existing hooks ...

// MFA hooks
export function useMfaStatus() {
  return useQuery<MfaStatus>({
    queryKey: ['tenant', 'mfa', 'status'],
    queryFn: () => api.get('/tenant/mfa/status'),
  });
}

export function useMfaSetup() {
  return useMutation<MfaSetupResponse, Error, void>({
    mutationFn: () => api.post('/tenant/mfa/totp/setup'),
  });
}

export function useMfaVerify() {
  const qc = useQueryClient();
  return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
    mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
  });
}

export function useMfaBackupCodes() {
  const qc = useQueryClient();
  return useMutation<BackupCodesResponse, Error, void>({
    mutationFn: () => api.post('/tenant/mfa/backup-codes'),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
  });
}

export function useMfaRemove() {
  const qc = useQueryClient();
  return useMutation<void, Error, void>({
    mutationFn: () => api.delete('/tenant/mfa/totp'),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
  });
}

export function useResetTeamMemberMfa() {
  const qc = useQueryClient();
  return useMutation<void, Error, string>({
    mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
  });
}

export function useUpdateTenantSettings() {
  const qc = useQueryClient();
  return useMutation<void, Error, Record<string, unknown>>({
    mutationFn: (updates) => api.patch('/tenant/settings', updates),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
  });
}
```

- [ ] **Step 3: Add APP_MFA_REQUIRED interceptor to API client**

In `ui/src/api/client.ts`, update the `apiFetch` function to detect the MFA enforcement 403:

```typescript
if (response.status === 403) {
  const errorHeader = response.headers.get('X-Cameleer-Error');
  if (errorHeader === 'APP_MFA_REQUIRED') {
    // Redirect to settings page with MFA section
    window.location.href = '/platform/tenant/settings?mfa=required';
    throw new Error('MFA enrollment required');
  }
}
```

Add this after the existing `401` check and before the generic `!response.ok` check.

- [ ] **Step 4: Commit**

```bash
git add ui/src/types/api.ts ui/src/api/tenant-hooks.ts ui/src/api/client.ts
git commit -m "feat: add MFA types, hooks, and APP_MFA_REQUIRED interceptor"
```

---

## Task 10: Frontend — MFA Enrollment on Settings Page

**Files:**
- Modify: `ui/src/pages/tenant/SettingsPage.tsx`
- Run: `cd ui && npm install qrcode.react`

- [ ] **Step 1: Install qrcode.react**

```bash
cd ui && npm install qrcode.react
```

- [ ] **Step 2: Add MFA section to SettingsPage**

Import the new hooks and QR component at the top of `SettingsPage.tsx`:

```typescript
import { QRCodeSVG } from 'qrcode.react';
import {
  useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove,
  useUpdateTenantSettings,
} from '../../api/tenant-hooks';
import { useScopes } from '../../auth/useScopes';
```

After the existing "Server Admin Password" Card, add the MFA enrollment Card. This is a substantial component — add it as a separate section inside the same `SettingsPage` function:

```tsx
{/* --- MFA Section --- */}
<MfaSection />
```

Define `MfaSection` as a function inside `SettingsPage` (or above it):

```tsx
function MfaSection() {
  const toast = useToast();
  const { data: mfaStatus, isLoading: mfaLoading } = useMfaStatus();
  const setupMfa = useMfaSetup();
  const verifyMfa = useMfaVerify();
  const generateCodes = useMfaBackupCodes();
  const removeMfa = useMfaRemove();

  const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
  const [totpCode, setTotpCode] = useState('');
  const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
  const [savedCodes, setSavedCodes] = useState(false);
  const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);

  const handleStartSetup = async () => {
    try {
      const data = await setupMfa.mutateAsync();
      setSetupData(data);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to start MFA setup');
    }
  };

  const handleVerify = async () => {
    if (!setupData) return;
    try {
      await verifyMfa.mutateAsync({ secret: setupData.secret, code: totpCode });
      // Generate backup codes
      const codes = await generateCodes.mutateAsync();
      setBackupCodes(codes.codes);
      setSetupData(null);
      setTotpCode('');
      toast.success('MFA enabled successfully');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Invalid code');
    }
  };

  const handleRegenerateCodes = async () => {
    try {
      const codes = await generateCodes.mutateAsync();
      setBackupCodes(codes.codes);
      setSavedCodes(false);
      toast.success('New backup codes generated');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to generate codes');
    }
  };

  const handleRemove = async () => {
    try {
      await removeMfa.mutateAsync();
      setShowRemoveConfirm(false);
      toast.success('MFA removed');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to remove MFA');
    }
  };

  const handleCopyCodes = () => {
    if (backupCodes) {
      navigator.clipboard.writeText(backupCodes.join('\n'));
      toast.success('Backup codes copied');
    }
  };

  const handleDownloadCodes = () => {
    if (!backupCodes) return;
    const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'cameleer-backup-codes.txt';
    a.click();
    URL.revokeObjectURL(url);
  };

  if (mfaLoading) return <Card title="Two-Factor Authentication"><Spinner /></Card>;

  // Backup codes display (after enrollment or regeneration)
  if (backupCodes) {
    return (
      <Card title="Save Your Backup Codes">
        <p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>
          Save these codes in a secure place. Each code can only be used once.
        </p>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 16, fontFamily: 'monospace', fontSize: 14 }}>
          {backupCodes.map((code, i) => (
            <div key={i} style={{ padding: '6px 12px', background: 'var(--bg-surface)', borderRadius: 4, textAlign: 'center' }}>{code}</div>
          ))}
        </div>
        <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
          <Button variant="secondary" onClick={handleCopyCodes}>Copy all</Button>
          <Button variant="secondary" onClick={handleDownloadCodes}>Download .txt</Button>
        </div>
        <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, fontSize: 14 }}>
          <input type="checkbox" checked={savedCodes} onChange={(e) => setSavedCodes(e.target.checked)} />
          I've saved my backup codes
        </label>
        <Button variant="primary" disabled={!savedCodes} onClick={() => setBackupCodes(null)}>Done</Button>
      </Card>
    );
  }

  // Setup flow (QR code + verify)
  if (setupData) {
    return (
      <Card title="Set Up Authenticator App">
        <p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>
          Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to verify.
        </p>
        <div style={{ textAlign: 'center', marginBottom: 16 }}>
          <QRCodeSVG value={setupData.secretQrCode} size={200} />
        </div>
        <FormField label="Verification code" htmlFor="mfa-setup-code">
          <Input
            id="mfa-setup-code"
            type="text"
            inputMode="numeric"
            value={totpCode}
            onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
            placeholder="000000"
            autoComplete="one-time-code"
          />
        </FormField>
        <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
          <Button variant="primary" onClick={handleVerify} loading={verifyMfa.isPending} disabled={totpCode.length < 6}>
            Verify & enable
          </Button>
          <Button variant="secondary" onClick={() => { setSetupData(null); setTotpCode(''); }}>Cancel</Button>
        </div>
      </Card>
    );
  }

  // Main MFA section
  return (
    <Card title="Two-Factor Authentication">
      {mfaStatus?.enrolled ? (
        <>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
            <Badge color="success">Enabled</Badge>
            <span style={{ color: 'var(--text-secondary)', fontSize: 14 }}>Authenticator app configured</span>
          </div>
          <div style={{ display: 'flex', gap: 8 }}>
            <Button variant="secondary" onClick={handleRegenerateCodes} loading={generateCodes.isPending}>
              Regenerate backup codes
            </Button>
            <Button variant="danger" onClick={() => setShowRemoveConfirm(true)}>Remove MFA</Button>
          </div>
          {showRemoveConfirm && (
            <Alert variant="warning" style={{ marginTop: 16 }}>
              <p>Are you sure you want to remove MFA? This will make your account less secure.</p>
              <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
                <Button variant="danger" size="small" onClick={handleRemove} loading={removeMfa.isPending}>
                  Yes, remove MFA
                </Button>
                <Button variant="secondary" size="small" onClick={() => setShowRemoveConfirm(false)}>Cancel</Button>
              </div>
            </Alert>
          )}
        </>
      ) : (
        <>
          <p style={{ color: 'var(--text-secondary)', marginBottom: 16 }}>
            Protect your account with two-factor authentication using an authenticator app.
          </p>
          <Button variant="primary" onClick={handleStartSetup} loading={setupMfa.isPending}>
            Set up authenticator app
          </Button>
        </>
      )}
    </Card>
  );
}
```

- [ ] **Step 3: Add MFA enforcement toggle (tenant admins only)**

After the `<MfaSection />`, add the enforcement toggle — visible only to users with owner/operator scope:

```tsx
<MfaEnforcementToggle />
```

```tsx
function MfaEnforcementToggle() {
  const toast = useToast();
  const { hasScope } = useScopes();
  const { data: settings } = useTenantSettings();
  const updateSettings = useUpdateTenantSettings();
  const [showConfirm, setShowConfirm] = useState(false);

  // Only show for tenant admins (owners/operators)
  if (!hasScope('tenant:manage')) return null;

  const mfaRequired = settings?.mfaRequired ?? false;

  const handleToggle = async () => {
    if (!mfaRequired) {
      setShowConfirm(true);
      return;
    }
    // Disabling — no confirmation needed
    try {
      await updateSettings.mutateAsync({ mfaRequired: false });
      toast.success('MFA requirement disabled');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to update settings');
    }
  };

  const handleConfirmEnable = async () => {
    try {
      await updateSettings.mutateAsync({ mfaRequired: true });
      setShowConfirm(false);
      toast.success('MFA requirement enabled');
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Failed to update settings');
    }
  };

  return (
    <Card title="Organization Security">
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <div>
          <p style={{ fontWeight: 600, marginBottom: 4 }}>Require MFA for all members</p>
          <p style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
            Members without MFA will be prompted to enroll on their next sign-in.
          </p>
        </div>
        <Button
          variant={mfaRequired ? 'primary' : 'secondary'}
          onClick={handleToggle}
          loading={updateSettings.isPending}
        >
          {mfaRequired ? 'Enabled' : 'Disabled'}
        </Button>
      </div>
      {showConfirm && (
        <Alert variant="warning" style={{ marginTop: 16 }}>
          <p>Members without MFA will be prompted to enroll on their next sign-in. Are you sure?</p>
          <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
            <Button variant="primary" size="small" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
              Yes, require MFA
            </Button>
            <Button variant="secondary" size="small" onClick={() => setShowConfirm(false)}>Cancel</Button>
          </div>
        </Alert>
      )}
    </Card>
  );
}
```

- [ ] **Step 4: Commit**

```bash
git add ui/package.json ui/package-lock.json ui/src/pages/tenant/SettingsPage.tsx
git commit -m "feat: add MFA enrollment and enforcement toggle to Settings page"
```

---

## Task 11: Frontend — Reset MFA on Team Page

**Files:**
- Modify: `ui/src/pages/tenant/TeamPage.tsx`

- [ ] **Step 1: Import the new hook**

Add to the imports in `TeamPage.tsx`:

```typescript
import { useTenantTeam, useInviteTeamMember, useRemoveTeamMember, useResetTeamMemberPassword, useResetTeamMemberMfa } from '../../api/tenant-hooks';
```

- [ ] **Step 2: Wire up the hook and add state**

Inside the `TeamPage` component, add:

```typescript
const resetMfa = useResetTeamMemberMfa();
const [mfaResetTarget, setMfaResetTarget] = useState<TeamMember | null>(null);
```

- [ ] **Step 3: Add "Reset MFA" button to the Actions column**

In the DataTable columns, in the Actions cell renderer, add a new button between "Reset Password" and "Remove":

```tsx
<Button
  variant="secondary"
  size="small"
  onClick={() => setMfaResetTarget(member)}
>
  Reset MFA
</Button>
```

- [ ] **Step 4: Add MFA reset confirmation dialog**

After the existing password reset form, add:

```tsx
{mfaResetTarget && (
  <Card title={`Reset MFA for ${mfaResetTarget.name || mfaResetTarget.email}`} style={{ marginTop: 16 }}>
    <Alert variant="warning">
      This will remove all MFA factors for this user. They will need to re-enroll if MFA is required.
    </Alert>
    <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
      <Button
        variant="danger"
        onClick={async () => {
          try {
            await resetMfa.mutateAsync(mfaResetTarget.id);
            toast.success(`MFA reset for ${mfaResetTarget.name || mfaResetTarget.email}`);
            setMfaResetTarget(null);
          } catch (err) {
            toast.error(err instanceof Error ? err.message : 'Failed to reset MFA');
          }
        }}
        loading={resetMfa.isPending}
      >
        Confirm Reset MFA
      </Button>
      <Button variant="secondary" onClick={() => setMfaResetTarget(null)}>Cancel</Button>
    </div>
  </Card>
)}
```

- [ ] **Step 5: Commit**

```bash
git add ui/src/pages/tenant/TeamPage.tsx
git commit -m "feat: add Reset MFA action for team members"
```

---

## Task 12: Server Handoff Document

**Files:**
- Create: `docs/superpowers/specs/2026-04-26-server-mfa-handoff.md`

- [ ] **Step 1: Write the handoff doc**

```markdown
# Cameleer-Server MFA Handoff Document

**Date:** 2026-04-26
**For:** cameleer-server team
**Context:** The SaaS platform now supports TOTP MFA with backup codes. This document specifies what the server team needs to implement for MFA enrollment in the server UI.

## 1. JWT Claim: `mfa_enrolled`

Every access token now includes an `mfa_enrolled: boolean` claim, set by the Logto Custom JWT script. The server already parses JWT claims for the `roles` field — `mfa_enrolled` works identically.

**Example decoded JWT payload:**
```json
{
  "sub": "user-id-123",
  "roles": ["server:admin"],
  "mfa_enrolled": true,
  "aud": "https://api.cameleer.local",
  "scope": "tenant:manage tenant:view"
}
```

## 2. Enforcement

### When to enforce

Check whether the tenant requires MFA:

- **Endpoint:** `GET /platform/api/tenant/{slug}/mfa-policy`
- **Auth:** M2M token (same as existing server → SaaS API calls)
- **Response:** `{ "mfaRequired": true/false }`
- **Cache:** 5-minute TTL recommended

### How to enforce

On authenticated requests, if `mfaRequired` is `true` and the JWT `mfa_enrolled` claim is `false`:

**Response:**
```json
HTTP 403
X-Cameleer-Error: APP_MFA_REQUIRED
Content-Type: application/json

{
  "error": "APP_MFA_REQUIRED",
  "code": "mfa_enrollment_required",
  "message": "Your organization requires multi-factor authentication"
}
```

**Exempt paths:** MFA enrollment endpoints (below), health checks, public assets.

The server UI should intercept 403 responses with `X-Cameleer-Error: APP_MFA_REQUIRED` and redirect to the MFA enrollment page.

## 3. MFA Enrollment API

The server needs to call Logto's Management API to manage MFA for users. Use the existing M2M token for authentication.

### Get MFA status

```
GET https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}

Response: [
  { "id": "ver-123", "type": "Totp", "createdAt": "..." },
  { "id": "ver-456", "type": "BackupCode", "createdAt": "..." }
]
```

### Generate TOTP secret

Generate a 20-byte random secret, Base32-encode it, and create a QR code URI:

```
otpauth://totp/Cameleer:{userEmail}?secret={base32Secret}&issuer=Cameleer
```

Show the QR code to the user. After they scan and provide a 6-digit code, verify it server-side using TOTP algorithm (RFC 6238, HMAC-SHA1, 30-second window, ±1 step drift).

### Bind TOTP to user

After successful verification:

```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json

{ "type": "Totp", "secret": "{base32Secret}" }

Response: { "type": "Totp", "secret": "...", "secretQrCode": "..." }
```

### Generate backup codes

After TOTP is bound:

```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json

{ "type": "BackupCode" }

Response: { "type": "BackupCode", "codes": ["abc123", "def456", ...] }
```

Display the 10 codes once. User must acknowledge saving them before dismissing.

### Remove MFA (admin action)

```
DELETE https://{logto-endpoint}/api/users/{userId}/mfa-verifications/{verificationId}
Authorization: Bearer {m2m_token}
```

Remove all verifications (TOTP + BackupCode) to fully reset MFA for a user.

## 4. UX Requirements

### Enrollment flow

1. User clicks "Set up MFA" in settings
2. Show QR code (200×200px) with the TOTP secret URI
3. User scans with authenticator app
4. User enters 6-digit verification code
5. On success → show 10 backup codes in a 2-column monospace grid
6. "Copy all" and "Download .txt" buttons
7. Checkbox: "I've saved my backup codes" — must be checked before dismissing
8. After dismissal, force token refresh to get `mfa_enrolled: true` in JWT

### Enrolled state

- Show "Authenticator app configured" with green status badge
- "Regenerate backup codes" button
- "Remove MFA" button with confirmation dialog

### Backup code fallback (sign-in)

This is handled by the SaaS custom sign-in UI, not the server. No server changes needed for the sign-in flow.

## 5. Error States

| Scenario | Response |
|----------|----------|
| User already has TOTP enrolled | 422 — "TOTP already configured" |
| Invalid TOTP code during setup | Show error, let user retry |
| Backup code already used (sign-in) | Handled by SaaS sign-in UI |
| All backup codes exhausted | Admin removes MFA via team page |
| Remove MFA while enforcement active | User will be prompted to re-enroll on next request |
```

- [ ] **Step 2: Commit**

```bash
git add docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
git commit -m "docs: add MFA handoff document for cameleer-server team"
```

---

## Self-Review Checklist

### Spec coverage

| Spec Section | Task(s) |
|-------------|---------|
| 1. Password Reset Flow | Task 1 (API), Task 2 (UI) |
| 1. Security Notification Email | Task 8 |
| 2. MFA Config (Bootstrap) | Task 4 |
| 2. Custom JWT mfa_enrolled claim | Task 4 |
| 3. MFA Verification at Sign-in | Task 1 (API), Task 3 (UI) |
| 4. MFA Enrollment (Settings) | Task 6 (backend), Task 10 (frontend) |
| 4. Team Management (Reset MFA) | Task 6 (backend), Task 11 (frontend) |
| 5. Per-Tenant Enforcement (filter) | Task 7 |
| 5. Tenant Admin Toggle | Task 10 (frontend), Task 6 (backend) |
| 5. Server Enforcement | Task 12 (handoff) |
| 5. APP_MFA_REQUIRED error code | Task 7, Task 9 (client interceptor) |
| 5. Token Refresh After Enrollment | Task 10 (in enrollment flow) |
| 6. Backup Codes | Task 6 (backend), Task 10 (frontend display) |
| 7. Server Handoff Doc | Task 12 |

### Placeholder scan

No TBDs, TODOs, or "implement later" references. All code blocks are complete.

### Type consistency

- `MfaStatus` → used in `useMfaStatus` hook and `MfaSection` component ✓
- `MfaSetupResponse` → used in `useMfaSetup` hook and `handleStartSetup` ✓
- `BackupCodesResponse` → used in `useMfaBackupCodes` hook and backup codes display ✓
- `MfaRequiredError` → thrown in `experience-api.ts`, caught in `SignInPage.tsx` ✓
- `APP_MFA_REQUIRED` → consistent across `MfaEnforcementFilter`, `client.ts` interceptor, handoff doc ✓
- `TotpVerifyRequest(secret, code)` → matches frontend `useMfaVerify` payload `{ secret, code }` ✓
