# Profile Management

- [Introduction](#introduction)
- [Profile Photo](#profile-photo)
- [Email & Password](#email--password)
    - [Updating Profile Information](#updating-profile-information)
    - [Changing Password](#changing-password)
- [Email Verification](#email-verification)
- [Session Management](#session-management)
- [Timezone Selection](#timezone-selection)
- [Extended Profile](#extended-profile)
- [Newsletter](#newsletter)
- [Delete Account](#delete-account)
- [Two-Factor Management](#two-factor-management)
- [Gate Abilities](#gate-abilities)
- [Controller](#controller)
- [View](#view)

## Introduction

The profile module provides a unified settings page where authenticated users manage their account information, security settings, and preferences. Every section is independently feature-gated — the host app enables only what it needs.

`MagicStarterProfileController` is the central controller. It uses the `withoutNotifying()` pattern to prevent full-page rebuilds during form-level submissions, and section-level `ValueNotifier` fields for fine-grained reactivity.

> [!NOTE]
> Profile routes are registered under `MagicStarterConfig.profilePrefix()` (default `/settings`). All profile routes require the `EnsureAuthenticated` middleware and render inside `AppLayout`.

## Profile Photo

Gated by `MagicStarterConfig.hasProfilePhotoFeatures()` (`magic_starter.features.profile_photos`).

Upload a profile photo via `Http.upload()`:

```dart
final success = await MagicStarterProfileController.instance.doUpdateProfilePhoto(
  file: magicFile,
);
```

The controller sends a multipart `POST` to `/user/profile-photo` with the file under the `photo` key. On success, it calls `Auth.restore()` to refresh the user model (which includes the new `profile_photo_url`).

Delete the current photo:

```dart
final success = await MagicStarterProfileController.instance.doDeleteProfilePhoto();
```

This sends `DELETE /user/profile-photo` and restores the auth state afterward.

> [!TIP]
> Both photo operations call `Auth.restore()` on success so the UI immediately reflects the change without a manual refresh.

## Email & Password

### Updating Profile Information

`doUpdateProfile()` sends `PUT /user/profile` with the updated fields. Only non-empty optional fields are included in the payload:

```dart
final success = await controller.withoutNotifying(
  () => controller.doUpdateProfile(
    name: form['name'],
    email: form['email'],
    phone: form['phone'],
    timezone: form['timezone'],
    language: form['language'],
  ),
);
```

The `withoutNotifying()` wrapper suppresses full-page `notifyListeners()` calls while the form-level `MagicFormData.process()` drives the submit button's loading state. This prevents the entire settings page from rebuilding during a single section's save.

On success, `Auth.restore()` refreshes the user model and a toast confirmation is shown. When `language` is present, the controller hands it to `MagicStarterManager.applyLocale()`, which switches the app to it after the current frame.

> [!NOTE]
> `MagicStarterServiceProvider` separately applies a signed-in user's saved `locale` attribute on every sign-in and `Auth.restore()`, gated by `magic_starter.localization.apply_user_locale` (default `true`); see [Locale Application](https://anilcancakir.com/magic/docs/starter/v1.0/architecture/manager.md#locale-application). The two mechanisms share `applyLocale()`'s pending target rather than racing: a profile save's own `Auth.restore()` wakes the provider's listener too, and whichever of the two reaches `applyLocale()` first for a given locale is the one that switches, so one save still issues exactly one `Lang.setLocale()` call.

### Changing Password

`doUpdatePassword()` sends `PUT /user/password` with `current_password`, `password`, and `password_confirmation`:

```dart
final success = await controller.doUpdatePassword(
  currentPassword: form['current_password'],
  password: form['password'],
  passwordConfirmation: form['password_confirmation'],
);
```

> [!IMPORTANT]
> Password changes require the current password for verification. The confirmation dialog in the view uses inline error handling via `setState()` — it never auto-closes on error.

An account created through a provider has no password to change (`has_password` is `false`): the backend answers `PUT /user/password` with 422 `password_not_set`. The password page renders a **Set password** form for it instead; see [Set a Password](https://anilcancakir.com/magic/docs/starter/v1.0/basics/set-password.md).

## Email Verification

Gated by `MagicStarterConfig.hasEmailVerificationFeatures()` (`magic_starter.features.email_verification`).

Send or resend a verification email:

```dart
await MagicStarterProfileController.instance.sendEmailVerification();
```

This calls `POST /email/verification-notification`. On success, a toast message confirms the email was sent.

Check the current verification status:

```dart
final isVerified = controller.isEmailVerified;
```

The getter reads `email_verified_at` from the authenticated user model — it returns `true` only when the field is a non-null, non-empty string.

> [!NOTE]
> The verification link is handled by the backend. When the user clicks the link in their email, the backend marks the account as verified. Call `Auth.restore()` to pull the updated status.

## Session Management

Gated by `MagicStarterConfig.hasSessionsFeatures()` (`magic_starter.features.sessions`).

View active sessions:

```dart
final sessions = await MagicStarterProfileController.instance.getSessions();
```

Returns a list of session maps from `GET /sessions`, each containing device info, IP address, and last activity timestamp.

Revoke a specific session:

```dart
final success = await controller.doRevokeSession(
  tokenId: session['id'],
  proof: proof,
);
```

Revoke all other sessions except the current one:

```dart
final success = await controller.doRevokeOtherSessions(
  proof: proof,
);
```

Both revocation methods use Laravel method spoofing (`{'_method': 'DELETE'}` in an `Http.post()` call) and require a step-up `proof` for sudo-mode confirmation: `{'password': ...}`, `{'code': ...}`, `{'confirmation_token': ...}`, or `{}` for a guest. [Confirming Identity](https://anilcancakir.com/magic/docs/starter/v1.0/basics/identity-confirmation.md) covers how the right one is produced for the signed-in account.

> [!TIP]
> Session revocation is destructive. Always confirm the user's identity (`confirmIdentity`) before calling these methods.

## Timezone Selection

Gated by `MagicStarterConfig.hasTimezoneFeatures()` (`magic_starter.features.timezones`). Also visible when `MagicStarterConfig.hasExtendedProfileFeatures()` is enabled — the config helper `hasTimezoneOrExtendedProfileFeatures()` checks both.

Timezone data is fetched via a **debounced async API search**, not from local data:

```dart
final response = await Http.get('/timezones', query: {'search': query});
```

The timezone select widget debounces user input before firing the API request. Results are displayed as a searchable dropdown. The selected timezone identifier is included in the profile update payload.

> [!IMPORTANT]
> The API returns timezone entries with an `identifier` field. Entries with null values are filtered out on the client side. This is a known backend behavior — the null-safety checks are critical.

## Extended Profile

Gated by `MagicStarterConfig.hasExtendedProfileFeatures()` (`magic_starter.features.extended_profile`).

When enabled, the profile form includes additional fields:

- **Phone** — optional phone number
- **Timezone** — async search via `/timezones` API
- **Language** — locale selection from `MagicStarter.localeOptions`

These fields are conditionally included in the `doUpdateProfile()` payload — only non-empty values are sent:

```dart
final data = <String, dynamic>{
  'name': name,
  'email': email,
  if (phone != null && phone.isNotEmpty) 'phone': phone,
  if (timezone != null && timezone.isNotEmpty) 'timezone': timezone,
  if (language != null && language.isNotEmpty) 'language': language,
};
```

## Newsletter

Gated by `MagicStarterConfig.hasNewsletterFeatures()` (`magic_starter.features.newsletter`).

`MagicStarterNewsletterController` manages subscription state independently from the profile controller:

```dart
// Fetch current subscription status
await MagicStarterNewsletterController.instance.getNewsletterStatus();

// Update subscription
await MagicStarterNewsletterController.instance.updateNewsletterSubscription(
  subscribe: true,
);
```

The controller calls `GET /user/newsletter` and `PUT /user/newsletter` respectively. It follows the standard `MagicStateMixin` pattern — check `controller.isSuccess` or `controller.hasErrors` after each call.

> [!TIP]
> Customize the newsletter label shown in the UI via `MagicStarter.useNewsletterLabel('Subscribe to our updates')`.

## Delete Account

Account deletion requires a step-up proof and uses Laravel method spoofing:

```dart
// confirmIdentity is the starter's own helper (see Confirming Identity).
final proof = await confirmIdentity(context);
if (proof == null) return; // the user cancelled

final success = await MagicStarterProfileController.instance.doDeleteAccount(
  proof: proof,
  immediately: false, // true deletes now instead of after the grace period
);
```

The starter's delete screens (the Security sessions page and the long-form profile settings view) first ask which one the user wants: "Delete after the grace period (cancel by signing in)" or "Delete now, permanently", the second styled as the modal theme's danger button. Dismissing the choice deletes nothing; either answer then goes through the identity confirmation above.

`proof` is `{'password': ...}` for an account with a password, `{'code': ...}` or `{'confirmation_token': ...}` for one without, and `{}` for a guest; see [Confirming Identity](https://anilcancakir.com/magic/docs/starter/v1.0/basics/identity-confirmation.md).

The controller sends `POST /user` with `{'_method': 'DELETE', ...proof}`, plus `'immediately': true` only for the second choice. By default the backend **schedules** the deletion and answers `202` with `data.deletion_scheduled_at` and a sentence that says how to cancel: every token is revoked at once, the account is purged after a grace period (30 days by default), and signing in again within it cancels the deletion. On success the controller shows that sentence as a toast, calls `Auth.logout()` and navigates to the login page. The next sign-in during the grace period shows the "Account deletion cancelled" toast. The user resource carries `deletion_scheduled_at` (`MagicStarterAuthUser.deletionScheduledAt`).

With `immediately: true` the backend applies the same refusals and step-up, then purges the account now rather than after the grace period, and answers `202` with `data.immediate: true`. The controller handles it the same way: the server's sentence as a toast, `Auth.logout()`, the login page. There is nothing to cancel by signing in afterwards.

A refusal keeps the user signed in and shows its own sentence: `owns_shared_teams`, `team_has_active_subscription` and `subscription_active` (cancel the subscription first), `step_up_required` (the proof was missing or spent), and a wrong password.

> [!IMPORTANT]
> Either way the user is signed out immediately; the scheduled deletion is the one that can still be cancelled. Always confirm identity first. The dialog handles inline errors via `setState()` and never auto-closes on API failure.

## Two-Factor Management

Two-factor enable, disable, confirm, and recovery code management are handled by `MagicStarterProfileController`. See the [Authentication - Two-Factor Authentication](https://anilcancakir.com/magic/docs/starter/v1.0/basics/authentication.md#two-factor-authentication) section for full details.

Key profile-side getters:

```dart
// Check if 2FA is currently enabled
final enabled = controller.isTwoFactorEnabled;
```

## Gate Abilities

The service provider auto-registers 9 `starter.*` Gate abilities that control section visibility in the profile view. These abilities let the host app fine-tune which sections a specific user can see, beyond the global feature flags:

| Ability | Controls |
|---------|----------|
| `starter.update-profile-photo` | Profile photo upload/remove section |
| `starter.update-email` | Email field in profile information |
| `starter.update-phone` | Phone and country code in extended profile |
| `starter.update-password` | Password change section |
| `starter.verify-email` | Email verification banner |
| `starter.manage-two-factor` | Two-factor authentication section |
| `starter.manage-newsletter` | Newsletter preferences section |
| `starter.logout-sessions` | Logout/revoke buttons in browser sessions |
| `starter.delete-account` | Account deletion section |

Feature flags control whether the feature exists; Gate abilities control whether a specific user can access it.

## Controller

`MagicStarterProfileController` is accessed via the singleton:

```dart
final controller = MagicStarterProfileController.instance;
```

Key patterns:

- **`withoutNotifying()`** — wraps async actions to suppress `notifyListeners()` during form-level loading. The `_suppressNotifications` flag is set to `true` before the action and restored in a `finally` block.
- **Return type** — all mutation methods return `bool` (or `Map?` for 2FA enable) so the calling view can branch without reading controller state.
- **`Auth.restore()`** — called after every successful mutation that changes the user model (profile update, photo change, password change).

## View

The profile settings view is registered under the key `profile.settings` and rendered inside `AppLayout`:

```dart
MagicStarter.view.make('profile.settings');
```

The view uses multiple techniques for fine-grained reactivity:

- **`ValueListenableBuilder`** — for controller `ValueNotifier` fields (e.g., session list, 2FA state).
- **Section-level `ValueNotifier<bool>`** — local loading indicators (`_photoLoading`, `_emailVerificationLoading`) for independent section spinners.
- **Multiple `MagicFormData` instances** — each section (profile info, password, extended) has its own form. Field names must not collide across forms.
- **Feature-gated spreads** — sections are conditionally rendered at build time:

```dart
if (MagicStarterConfig.hasProfilePhotoFeatures()) ...[
  _buildPhotoSection(),
],
if (MagicStarterConfig.hasSessionsFeatures()) ...[
  _buildSessionsSection(),
],
```

---

**Related links:**

- [Authentication](https://magic.fluttersdk.com/packages/starter/authentication)
- [Configuration](https://magic.fluttersdk.com/packages/starter/configuration)
- [Teams](https://magic.fluttersdk.com/packages/starter/teams)
