Profile Management
On this page
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.
Profile Photo#
Gated by MagicStarterConfig.hasProfilePhotoFeatures() (magic_starter.features.profile_photos).
Upload a profile photo via Http.upload():
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:
final success = await MagicStarterProfileController.instance.doDeleteProfilePhoto();
This sends DELETE /user/profile-photo and restores the auth state afterward.
Email & Password#
Updating Profile Information#
doUpdateProfile() sends PUT /user/profile with the updated fields. Only non-empty optional fields are included in the payload:
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.
Changing Password#
doUpdatePassword() sends PUT /user/password with current_password, password, and password_confirmation:
final success = await controller.doUpdatePassword(
currentPassword: form['current_password'],
password: form['password'],
passwordConfirmation: form['password_confirmation'],
);
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.
Email Verification#
Gated by MagicStarterConfig.hasEmailVerificationFeatures() (magic_starter.features.email_verification).
Send or resend a verification email:
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:
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.
Session Management#
Gated by MagicStarterConfig.hasSessionsFeatures() (magic_starter.features.sessions).
View active sessions:
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:
final success = await controller.doRevokeSession(
tokenId: session['id'],
proof: proof,
);
Revoke all other sessions except the current one:
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 covers how the right one is produced for the signed-in account.
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:
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.
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
/timezonesAPI - Language — locale selection from
MagicStarter.localeOptions
These fields are conditionally included in the doUpdateProfile() payload — only non-empty values are sent:
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:
// 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.
Delete Account#
Account deletion requires a step-up proof and uses Laravel method spoofing:
// 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.
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.
Two-Factor Management#
Two-factor enable, disable, confirm, and recovery code management are handled by MagicStarterProfileController. See the Authentication - Two-Factor Authentication section for full details.
Key profile-side getters:
// 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:
final controller = MagicStarterProfileController.instance;
Key patterns:
withoutNotifying()— wraps async actions to suppressnotifyListeners()during form-level loading. The_suppressNotificationsflag is set totruebefore the action and restored in afinallyblock.- Return type — all mutation methods return
bool(orMap?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:
MagicStarter.view.make('profile.settings');
The view uses multiple techniques for fine-grained reactivity:
ValueListenableBuilder— for controllerValueNotifierfields (e.g., session list, 2FA state).- Section-level
ValueNotifier<bool>— local loading indicators (_photoLoading,_emailVerificationLoading) for independent section spinners. - Multiple
MagicFormDatainstances — 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:
if (MagicStarterConfig.hasProfilePhotoFeatures()) ...[
_buildPhotoSection(),
],
if (MagicStarterConfig.hasSessionsFeatures()) ...[
_buildSessionsSection(),
],
Related links: