# Profile (Shared · cross-portal)

**Context:** Cross-portal feature (not a single-portal module) · **Routes:** `manage.profile.*` + `main.profile.*` · **Used in:** both portals (admins via Manage, members via Main) from one shared component set.

## What it does
The signed-in user's own **profile edit page** — the same UI and backend serve **both portals**. One config-driven Vue component set renders the tabs plus a header:

- **Header** — avatar (profile photo) with change / remove, beside the user's name + email.
- **Personal** — Full Name, Phone, Email (change verified by an emailed 6-digit code), Identification (NRIC / Passport / Other, optional), Gender, Date of birth.
- **Address** — multiple addresses (label, lines, city/state/postcode/country), one marked primary.
- **Password** — change with the current password, or "forgot current password" → reset with an emailed 6-digit code.
- **Upload ID** — identity document (image or PDF); front + back for NRIC, a single page for Passport.
- **Membership** *(customer portal only)* — the member's own tier and the tiers they can move up to, with the Buy / WhatsApp-enquiry CTAs. This was its own page (`/membership`, a sidebar entry) until **2026-08-24**: a membership describes the ACCOUNT, so it belongs beside the rest of it rather than sitting among the products the membership unlocks. `/membership` still exists as a redirect to `/profile?tab=membership` — it is bookmarked, and the dashboard + Vibe Coding CTAs and `CheckoutController`'s cancel path all still reach for it.

Avatar and IC files are stored through the shared **[Media](/docs/modules_handbook/shared/media/readMe.md)** service; addresses use the shared **`Src\Common\Address`** polymorphic model. The fields are **identical for both portals**; the only per-portal difference is the extra **Membership** tab on the customer portal — which is exactly the one-line `config` override the components were built for.

## How it works
- **Shared components, two thin pages, two thin controllers.** The work lives in shared Vue components + one controller trait; each portal only binds its specifics (layout, page name, base URL).
  - **Frontend** — `Components/Profile/ProfileEditor.vue` (header + tab shell) composes `ProfileHeader`, `PersonalTab`, `AddressTab`, `PasswordTab`, `IdUploadTab`, `MembershipTab`. Two pages (`Pages/Manage/Profile/Edit.vue` → `ManageLayout`, `Pages/Main/Profile/Edit.vue` → `AppLayout`) just pass props + the config.
  - **Backend** — `HandlesProfile` (controller trait) holds every action; `Manage\ProfileController` and `Main\ProfileController` each declare the page (`Manage/Profile/Edit` vs `Main/Profile/Edit`), base URL (`/manage/profile` vs `/profile`) and portal id, and inject the three repositories the trait writes through (`UserRepository`, `AddressRepository`, `MediaService`).
  - **Portal-only props** — `HandlesProfile::profileExtraProps()` is an empty hook the shared props are unioned with (shared keys win, so an override can never redefine `baseUrl`). `Main\ProfileController` implements it with [`BuildPortalMembershipTiers`](/app/Actions/BuildPortalMembershipTiers.php) — one builder for the tier grid, so the page and the redirected `/membership` URL can never disagree about what is on offer.
- **The active tab is in the URL** (`?tab=`) as well as `sessionStorage`. An inbound link picks a tab that way (`/membership` → `/profile?tab=membership`), and every switch `replaceState`s the URL to match — without that, a form submit's `back()` reload would land on whichever tab the stale URL still named.
- **Config-driven (per-portal customisation).** `Components/Profile/profileConfig.js` exports `defaultProfileConfig` (`tabs` + `fields`). Both pages pass it unchanged today; to hide a tab/field in one portal, that page spreads and overrides it (e.g. `{ ...defaultProfileConfig, tabs: ['personal','address'] }`). `ProfileEditor` filters tabs by `tabs`; `PersonalTab` hides a field when `fields[name]` is false.
- **Personal.** `PersonalTab` submits `PUT {base}` → `HandlesProfile::update` maps the fields explicitly → `UserRepository::updateProfile()` writes **only** the linked `user_profile` (name, identity, gender, date of birth, phone) inside a transaction. It deliberately can't touch email / status / role, so it's safe to expose to the account owner. `UpdateProfileRequest` requires name + phone (identity & DOB optional, identity validated by type when given); the phone is normalised to digits and unique-checked ignoring the user's own row.
- **NRIC auto-fill.** When the identity type is **NRIC** and a full 12-digit number is entered, the front end derives **gender** (last digit — odd = male, even = female) and **date of birth** (first 6 digits `YYMMDD`, century inferred) and fills those fields — both stay manually editable.
- **Email — change verified by code.** Email is read-only by default. "Change" → enter a new address → a **6-digit code** is emailed to it (`POST {base}/email/code`, cached 10 min); entering the code applies the change (`PUT {base}/email`). The email — the sign-in identity — only changes after the code is confirmed.
- **Proving the key ALREADY on the account (no change).** A separate family from the change flows above: it edits nothing and only records the proof. Per key — `POST {base}/{email|phone}/verify-code` → `PUT {base}/{email|phone}/verify` — and, for the two together, `POST {base}/contact/verify-codes` → `PUT {base}/contact/verify`, which sends to **every outstanding key at once** (skipping any already proven — an SMS costs money) and proves each **independently** on one submit, so a correct code is stamped even when its sibling is wrong. Which keys are outstanding is read from the account, never the request. These are what an unverified account needs to regain the portal, and the **Extra Bonus overlay** on `/dashboard` is their one consumer — see the [Dashboard handbook](/docs/modules_handbook/main/dashboard/readMe.md) → *The Extra Bonus overlay*. A phone code rides SMS or WhatsApp (`channel`), via [`PasswordlessAuth`](/app/Services/Auth/PasswordlessAuth.php) like every other code in the app.
- **Password.** `PasswordTab` either changes the password with the **current** one (`PUT {base}/password`, validated by Laravel's `current_password` rule), or — "forgot current password" — emails a **6-digit code** (`POST {base}/password/code`) and resets without the old password (`PUT {base}/password/reset`). Both the email-change and password OTPs live in the cache (10 min, max 5 wrong tries) and are sent as Mailables — see the [Email](/docs/modules_handbook/shared/email/readMe.md) handbook. The active tab + OTP step are kept in `sessionStorage` so a submit's reload can't drop the user out of the flow.
- **Address.** `AddressTab` does full CRUD against `{base}/addresses[...]`. `AddressRepository` writes each change in a transaction and, when an address is set primary, demotes the user's other addresses so at most one stays primary. The controller resolves every address via `$user->addresses()->findOrFail($id)` — scoped to the owner, so a guessed id can't touch someone else's address (IDOR-safe). Addresses are a child table (no `uuid`), so the route id is the integer `id`.
- **Avatar.** `ProfileHeader` auto-saves the moment a photo is picked (`POST {base}/avatar`) and can remove it (`DELETE {base}/avatar`). Stored as Media collection `avatar` on the user's profile; **replacing hard-deletes** the previous one (file + row). The avatar is also shared globally (`HandleInertiaRequests` → `auth.user.avatar`) so both layouts show it in the top-nav instead of initials.
- **Upload ID.** `IdUploadTab` shows type-aware slots — **front + back** for NRIC/Other, a **single page** for Passport (driven by the saved `id_type`). Each side auto-saves when picked (`POST {base}/id-documents` with `id_front` / `id_back`; a passport uses `id_front` only) and removes a side (`DELETE {base}/id-documents/{side}`). Stored as Media collections `id_front` / `id_back`; **replacing hard-deletes** the old one. Images preview inline; PDFs show a file row with a view link.
- **Media URLs.** Stored files come back via `MediaService::displayUrl()` — a signed temporary URL for the private GCS disk, a plain URL for a public disk — and it never throws, so a missing file can't break the page.

### Reference flow — avatar upload
1. `ProfileHeader` posts the picked file to `POST /manage/profile/avatar` (or `/profile/avatar`).
2. `UploadAvatarRequest` validates it (jpg/png/webp ≤ 4 MB) — the HTTP boundary, since `MediaService` doesn't validate uploads.
3. `HandlesProfile::uploadAvatar` → `replaceProfileMedia($user, $file, 'avatar')`: `UserRepository::ensureProfile()` guarantees a profile row, the old `avatar` media is hard-deleted, then `MediaService::storeUpload($profile, $file, ['collection' => 'avatar'])` stores the new file on GCS and creates the `Media` row (owned by the profile).
4. The redirect re-renders the page; `profileProps()` reads the profile's media keyed by collection and returns fresh `displayUrl()`s, and the shared `auth.user.avatar` updates so the nav reflects it everywhere.

ID upload is the same path with collections `id_front` / `id_back` and per-side auto-save.

## Data model
No new tables — Profile composes existing ones:

- **`user_profiles`** (`Src\People\UserProfile`) — `full_name`, **`id_type` + `id_number`** (identity document — NRIC / Passport / Other via `ID_TYPES`; both nullable, no KYC gate), `gender` (constant + `GENDERS`), `date_of_birth`, `phone` (unique, digits-only mutator). 1:1 with `User`.
- **`addresses`** (`Src\Common\Address`) — polymorphic, attached via `User::addresses()` (`morphMany`, `addressable`); `is_primary` marks the default.
- **`media`** (`Src\Common\Media`) — attached via `UserProfile::media()` (`morphMany`, `mediable`); collections `avatar`, `id_front`, `id_back`. See the [Media](/docs/modules_handbook/shared/media/readMe.md) handbook.

## Configuration / customisation
`resources/js/Components/Profile/profileConfig.js`:
```js
export const defaultProfileConfig = {
    // No `password` tab: sign-in is passwordless, so a password here would be
    // one the account can never actually use.
    tabs: ['personal', 'address', 'id'],
    fields: { full_name: true, id: true, email: true, phone: true, gender: true, date_of_birth: true },
};
```
A portal that needs something else overrides it in its own `Edit.vue` — which is exactly what the customer portal does for Membership (`Pages/Main/Profile/Edit.vue`):
```js
const config = {
    ...defaultProfileConfig,
    tabs: [...defaultProfileConfig.tabs, 'membership'],
};
```

> For local testing of avatar/IC uploads, the Media disk must be reachable — see the [Media](/docs/modules_handbook/shared/media/readMe.md) handbook (GCS env + the uniform-bucket gotcha; or `MEDIA_DISK=public` for a non-cloud setup).

## Related files
**Backend — Controllers & trait**
- [app/Http/Controllers/Concerns/HandlesProfile.php](/app/Http/Controllers/Concerns/HandlesProfile.php) — all shared actions (edit, update, addresses, avatar, IC) + props + media helpers.
- [app/Http/Controllers/Manage/ProfileController.php](/app/Http/Controllers/Manage/ProfileController.php) — Manage binding (page / base URL / portal + repo injection).
- [app/Http/Controllers/Main/ProfileController.php](/app/Http/Controllers/Main/ProfileController.php) — Main binding.

**Backend — Form Requests**
- [app/Http/Requests/Profile/UpdateProfileRequest.php](/app/Http/Requests/Profile/UpdateProfileRequest.php) — Personal tab (name/IC/phone required; phone normalised + unique-ignore-self).
- [app/Http/Requests/Profile/Addresses/StoreRequest.php](/app/Http/Requests/Profile/Addresses/StoreRequest.php) · [UpdateRequest.php](/app/Http/Requests/Profile/Addresses/UpdateRequest.php) — address validation.
- [app/Http/Requests/Profile/UploadAvatarRequest.php](/app/Http/Requests/Profile/UploadAvatarRequest.php) — avatar (jpg/png/webp ≤ 4 MB).
- [app/Http/Requests/Profile/UploadIdRequest.php](/app/Http/Requests/Profile/UploadIdRequest.php) — ID front/back (jpg/png/pdf ≤ 8 MB).
- [app/Http/Requests/Profile/ChangeEmailRequest.php](/app/Http/Requests/Profile/ChangeEmailRequest.php) · [ConfirmEmailChangeRequest.php](/app/Http/Requests/Profile/ConfirmEmailChangeRequest.php) — email change (new address; 6-digit code).
- [app/Http/Requests/Profile/ConfirmContactVerificationRequest.php](/app/Http/Requests/Profile/ConfirmContactVerificationRequest.php) — the combined submit that proves both outstanding keys at once; each code is required only while its key is unproven (read from the account, never the request).
- [app/Http/Requests/Profile/UpdatePasswordRequest.php](/app/Http/Requests/Profile/UpdatePasswordRequest.php) · [ResetPasswordCodeRequest.php](/app/Http/Requests/Profile/ResetPasswordCodeRequest.php) — password change / code reset.

**Backend — Email (OTP codes)**
- [app/Mail/VerifyEmailChangeMail.php](/app/Mail/VerifyEmailChangeMail.php) · [app/Mail/PasswordResetCodeMail.php](/app/Mail/PasswordResetCodeMail.php) — the 6-digit code emails. See the [Email](/docs/modules_handbook/shared/email/readMe.md) handbook.

**Backend — Models & Repositories**
- [src/People/UserProfile.php](/src/People/UserProfile.php) — `id_type` + `id_number` (`ID_TYPES`), `GENDERS`, phone mutator, `media()` morph.
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — `updateProfile()`, `ensureProfile()`, `updateEmail()`, `updatePassword()`.
- [src/Common/Repositories/AddressRepository.php](/src/Common/Repositories/AddressRepository.php) — address create/update/delete + primary handling.
- [src/Common/Address.php](/src/Common/Address.php) — polymorphic address model.

**Backend — Membership tab**
- [app/Actions/BuildPortalMembershipTiers.php](/app/Actions/BuildPortalMembershipTiers.php) — the tier props (price, benefits, T&C, `is_current`, `can_buy`, WhatsApp enquiry URL). Buying still posts to `main.portal.checkout.store` — see [Payments](/docs/modules_handbook/manage/payments/purchase-histories/readMe.md).

**Backend — Shared props**
- [app/Http/Middleware/HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — `auth.user.avatar` for the nav.

**Frontend — Pages**
- [resources/js/Pages/Manage/Profile/Edit.vue](/resources/js/Pages/Manage/Profile/Edit.vue) · [resources/js/Pages/Main/Profile/Edit.vue](/resources/js/Pages/Main/Profile/Edit.vue) — thin wrappers (layout + config).

**Frontend — Shared components**
- [resources/js/Components/Profile/ProfileEditor.vue](/resources/js/Components/Profile/ProfileEditor.vue) — header + tab shell.
- [resources/js/Components/Profile/ProfileHeader.vue](/resources/js/Components/Profile/ProfileHeader.vue) — avatar card (auto-save / remove).
- [resources/js/Components/Profile/PersonalTab.vue](/resources/js/Components/Profile/PersonalTab.vue) · [AddressTab.vue](/resources/js/Components/Profile/AddressTab.vue) · [PasswordTab.vue](/resources/js/Components/Profile/PasswordTab.vue) · [IdUploadTab.vue](/resources/js/Components/Profile/IdUploadTab.vue) · [MembershipTab.vue](/resources/js/Components/Profile/MembershipTab.vue) (customer portal only).
- [resources/js/Components/Profile/profileConfig.js](/resources/js/Components/Profile/profileConfig.js) — tab/field config.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) · [resources/js/Layouts/AppLayout.vue](/resources/js/Layouts/AppLayout.vue) — nav avatar.

**Migrations**
- [database/migrations/2026_06_09_000001_add_ic_number_to_user_profiles_table.php](/database/migrations/2026_06_09_000001_add_ic_number_to_user_profiles_table.php) — adds the identity column.
- [database/migrations/2026_06_09_000002_replace_ic_number_with_id_fields_on_user_profiles.php](/database/migrations/2026_06_09_000002_replace_ic_number_with_id_fields_on_user_profiles.php) — renames it to `id_number` + adds `id_type`.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.profile.*` · [routes/main.php](/routes/main.php) — `main.profile.*`.

**See also:** [Media](/docs/modules_handbook/shared/media/readMe.md) (avatar + ID storage) · [Email](/docs/modules_handbook/shared/email/readMe.md) (verification codes & links) · `Src\Common\Address` (addresses).
