<!--
	covers: commands/UsersController.php components/User.php config/components/user.php controllers/UsersController.php models/Customer.php models/CustomerCompany.php models/Right.php models/User.php models/UserToken.php
	verified: 54fd0a6
-->
# Users and access

One `user` table serves three roles: site visitor with an account, back-office administrator, and super
administrator. `Customer` is a separate thing again.

## `User`

[`models/User.php`](../../models/User.php), implements `yii\web\IdentityInterface`.

| Column group | Columns |
|--------------|---------|
| Person | `firstName`, `lastName`, `email`, `newEmail` (nullable), `phone` |
| Credentials | `password`, `authKey`, `accessToken` (all nullable) |
| State | `confirmed`, `lastLoginAt` |
| Admin | `admin`, `superAdmin`, `rights` (text, JSON array, default `'[]'`) |

Relations: `tokens`, `customers`, `proposalRequests`, `rentStatusChanges`, `contacts`,
`newsletterSubscriptions`.

**`rights` is a JSON array in a text column**, not a join table. There is no `user_right` table and no RBAC
component — `yii\rbac` is not used at all.

`newEmail` holds a pending address change until the confirmation token is used; `email` only changes at that
point.

`User::initData()` seeds the default administrator from the `USER_DEFAULT_ADMIN_*` constants.

## `app\components\User`

[`components/User.php`](../../components/User.php) extends `yii\web\User` with two things:

```php
public function login( IdentityInterface $identity, $duration = 0 ) : bool {
    if( parent::login( $identity, $duration ) && $this->identity ) {
        $this->identity->lastLoginAt = date( 'Y-m-d H:i:s' );
        $this->identity->save( false );      // no validation
        return true;
    }
    return false;
}

public function hasRight( string $right ) : bool {
    $right = str_replace( 'DASHBOARD_', '', $right );
    return $this->identity?->admin && ( $this->identity?->superAdmin || in_array( $right, $this->rights ) );
}
```

`hasRight()` is the whole authorization model:

1. **`admin` is a hard prerequisite.** A visitor account can never hold a right.
2. **`superAdmin` short-circuits everything.**
3. Otherwise the right string must be in the user's `rights` array — a plain `in_array`, no hierarchy, no
   wildcards.
4. A leading `DASHBOARD_` is stripped, so dashboard-scoped rights collapse onto their base name.

`getAllRights()` builds the assignable list by walking the dashboard icon config and reflecting the admin
controllers — see [`models/Right.php`](../../models/Right.php), which is a `yii\base\Model`, **not** an
ActiveRecord. There is no rights table; the catalogue is derived from the code at render time.

Right names come from the controller class name, so they are only as stable as the class names. See
[ADMIN-MODULE.md](ADMIN-MODULE.md#rights).

## `UserToken`

Single-use, expiring hashes for every confirmation flow.

| Column | |
|--------|--|
| `userId`, `type`, `hash` (unique), `expiry` (unix timestamp), `usedAt` |

```php
TYPE_CONFIRM_REGISTER          = 'register'
TYPE_CONFIRM_SUBSCRIBE         = 'subscribe'
TYPE_CONFIRM_UNSUBSCRIBE       = 'unsubscribe'
TYPE_CONFIRM_PASSWORD_REMINDER = 'passwordReminder'
TYPE_CONFIRM_EMAIL_CHANGE      = 'emailChange'
TYPE_CONFIRM_PROFILE_DELETE    = 'profileDelete'
```

> Until 2026-08-08 the stored value was misspelled `'emailChane'`. It worked, because the constant was used
> on both the write and the read side — but a query written against the correct spelling matched nothing.
> `m260808_143000_user_token_email_change_type.php` migrates the stored rows; the constant and the data
> changed together, invalidating any token pending at deploy time.

`createWithType( $type, $userId, $expiry )` mints one; `findByParams( $hash, $userId, $type )` resolves it.

## Frontend account area

[`controllers/UsersController.php`](../../controllers/UsersController.php), 526 LOC — the biggest frontend
controller. Every route is localized:

| Route | Path (hu) |
|-------|-----------|
| `users/login` | `{login}` — `bejelentkezes` |
| `users/register` | `{register}` — `regisztracio` |
| `users/activate` | `{register}/{activate}/<hash>` |
| `users/account` | `{account}` — `fiokom` |
| `users/profile` | `{profile}` — `adatmodositas` |
| `users/password-reminder`, `users/password-change` | `{passwordReminder}`, `{passwordChange}/<hash>` |
| `users/email-change` | `{emailChange}/<hash>` |
| `users/proposal-requests`, `users/proposal-request` | `{proposalRequests}` |
| `users/rents`, `users/rent` | `{rents}` — `berleseim` |
| `users/payments` | `{payments}` — `szamlaim` |

Form models in [`models/forms/`](../../models/forms): `Login`, `Register`, `Profile`, `PasswordChange`,
`PasswordReminder`. They extend `Form`, not `ActiveRecord`.

Each has its own Encore entry (`usersAccount`, `usersRegister`, `usersActivate`, `usersPasswordChange`,
`usersEmailChange`) — but **not `usersLogin`, `usersProfile`, `usersRents` or `usersPayments`**, which
therefore load only `common`. That is intentional where those pages need no page-specific JS; adding
behaviour to one of them means adding the entry.

`SESSION_TIMEOUT` and `USER_AUTH_TIMEOUT` are both 24 hours. `site/ping-session` exists to keep a session
alive from the browser.

## `Customer` vs `User`

They are **not the same record**.

| | `User` | `Customer` |
|---|---|---|
| Is | a login account | a party to a rental contract |
| Has | credentials, rights | address, ID card data, company links |
| Needed for | the account area, the admin | every `Rent` and `Payment` |

`Customer.userId` links them, optionally. A walk-in customer created by the back office has no `User`;
a registered visitor who never rents has no `Customer`.

`CustomerCompany` joins a customer to a company; `Company` is the renting-out entity with its own tax rate,
address and invoicing settings.

## Admin access

Three-step gate ([`modules/admin/controllers/BaseController.php:252`](../../modules/admin/controllers/BaseController.php)):

1. `user.identity->admin` must be true — otherwise **log out** and redirect to `/users/login`;
2. `checkRights( $actionId )` — otherwise redirect to the dashboard;
3. `dashboard/index` is exempt from step 2.

## Traps

1. **`rights` is a JSON blob.** You cannot query "who can edit storages" in SQL without a `LIKE`.
2. **Right names follow controller class names.** Renaming an admin controller revokes access for everyone
   but super administrators, silently.
3. **`login()` saves with `save( false )`.** An invalid user record still logs in and updates
   `lastLoginAt`.
4. **Token type values are stored strings.** One of them was misspelled for a year — see above. A query
   against a hard-coded type string is a query against data, not against the constant.
5. **`admin` gates `hasRight()` entirely.** Granting rights to a non-admin user does nothing, and the admin
   UI will not show it as a problem.
6. **Four account pages have no Encore entry**, so JS added to their views will not run.
