diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6424de7..9772d7f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.5.0) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.5.1) in the same pass. Nothing else is required. This is a big release, with nearly seventy database steps in one go, so take a backup before you update. @@ -47,6 +47,8 @@ A client no longer has an hourly rate, net terms or SLAs of its own. They come f Nobody can sign up for the client portal after the update - it is off until you turn it on in Admin > Settings > Client portal and let a client's people do it on its Contacts > Invites (see New Features, Client portal invites and signing up). Inviting people works straight away. Contacts given portal access before are as they were. +Agents already set up are as they were after the update, and nobody is invited until you invite them. From now on New User invites by default: they are emailed a link to choose their own password, and the email with a password in it is gone (see New Features, Agent invites, and Breaking Changes, Users). + Get started, the walk through what is left to set up after installing, isn't shown on an install that is updated - nothing is added to the dashboard and nobody is taken to it on signing in. It is in Admin > Get started for anyone who wants it (see New Features, Get started). Every account is given a kind - checking, savings, credit card, cash or payment processor - guessed from its name: Visa, Amex or card is a credit card, savings or reserve is savings, cash or petty cash is cash, and Stripe, PayPal, Square or merchant is a payment processor. An account given a liability type back when accounts had types (2023) is a credit card, so what is on it reads as owed. Anything else is checking. Change any that came out wrong with Edit on the Accounts page. An account's balance now leaves out archived payments, income and expenses everywhere, so the Accounts page can show less or more than before (see Bug Fixes). No account is reconciled after the update. The first statement you reconcile on each can start from the bank's balance on the day it starts, bringing everything before it forward as reconciled, or from the account's opening balance (see New Features, Accounts and transactions). Transfers already made out of a cash account count as having taken everything in it up to then to the bank, so the first deposit you make after the update only offers the checks and cash taken in since the last one. @@ -233,6 +235,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Vendors: deleting a vendor that has expenses, recurring expenses or payment provider fees archives it instead, so your books keep it. Deleting any vendor now also clears it from the assets, licenses, tickets and domains that named it. - Locations: a client's primary location cannot be archived or deleted. Make another location primary first. Bulk archive skips primary locations. - Users: passwords must be at least 8 characters, and an email address can only belong to one account, agent or client portal. Existing accounts keep working. The rules apply when an account is added or changed. +- Users: New User no longer emails a password. It invites by default - they are emailed a link to join and choose their own password, so nobody else ever knows it (see New Features, Agent invites). Set a password now still sets one, for you to give them, and nothing is emailed: the Email them their sign in details switch and the email with the password in it are gone. Get started > Your team invites them too when email is set up, and still shows the password it makes once when it isn't. A script that posts `add_user` with a `password` and no `sign_in` still sets that password; `send_email` is ignored. +- Agent user limit: an invite waiting counts toward it until it is accepted, cancelled or runs out, so there is a seat for them when they accept. With every seat taken, cancel an invite to free one. - Users: Incident Response now resets agent passwords only, where it used to reset client portal logins too. It also forgets every agent's remembered browsers, so MFA is asked for again. The new passwords are shown once on a plain page, ready to read out or copy. - Your account: changing your email or password, or turning MFA off, now asks for your current password. Your old address gets a notice when the email changes, and you get an email when your password changes or MFA is turned off. - Agent user limit, for hosting providers: an optional `$config_agent_user_limit` in config.php caps the number of active agent users. It is not set by default, so nothing changes unless you add it. Disabled and archived agents do not count toward it. @@ -1052,7 +1056,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Client replied, to the agent the ticket is assigned to: who replied, in the portal or by email, with what they wrote. A portal reply used to say only to check ITFlow. - SLA at risk and missed, to the SLA alert address (Admin > SLAs) and the assigned agent: which target, when it is or was due and who has it, in amber for at risk and red once missed. An agent who is also the alert address gets one. - Shared links: a login sent back through a link you made, with a button to review it. - - Account emails: a new agent's sign in details with a Sign in button (and that the email has their password in it), a change to their account, a sign in from a new device and a wrong MFA code. The sign in alerts say when, the browser and system in words, and the IP address, with a button to their sign in security. + - Account emails: an invite to join, with an Accept the invite button (see Agent invites), a change to their account, a sign in from a new device and a wrong MFA code. The sign in alerts say when, the browser and system in words, and the IP address, with a button to their sign in security. - The test email in Admin > Mail comes in the new look, saying which mailbox it was sent from. - Client portal: the email confirming a saved card is in the billing emails' look, from the invoice mailbox, naming the card and when it expires. - Client portal emails - an invite, from you or from a primary contact, Forgot password, a changed password, and two-step sign-in turned on or off - all come from the system mailbox in the same look. @@ -1204,6 +1208,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The page says the same whatever address is put in - it never says whether someone has an account, or which client a domain belongs to. Someone who signs in already is emailed that they have an account, with a link to set a new password. Nothing is sent for an address at no client that lets people, one whose portal access was turned off, or a domain two such clients have. One email per address every 5 minutes, at most 20 sign-ups from one internet address in 10 minutes, and robots that fill in a hidden field are turned away. Each ask is in the audit log under sign-ins, with why when nothing was sent. - Links to finish signing up last 24 hours. Turning signing up off for a client stops the links already sent for it. The nightly run clears sign-up links nobody used a week after they run out, and invites cancelled more than 90 days ago. - The emails are in Admin > Mail > Previews: Invite to join, from you or from a colleague, Invite accepted, Finish signing up, Signing up with an account already, and Someone signed up. +- **Agent invites** (new) + - New User invites them by default. You set their name, email, role, whether MFA is required, the clients they see and a picture as before, and under Sign in, Invite them is picked. They are emailed a link to join: the page it opens says who invited them and as what, they check their name and choose their own password, and from then they are an agent and sign in on the usual page. Nobody else ever knows their password - it also unlocks their copy of the credential vault - so no password is passed on, written down or emailed. Set a password now is still there, for you to give them one yourself. + - With no email set up, Invite them gives you the link to send them yourself - by chat, text or your own email. It is shown once, at the top of Users. + - Users has an Invited view: who is invited, their role, whether the link is still good or has run out, MFA, the clients they will see, and who invited them and when it was emailed - with Send again, Get the link (a new one, so the one sent before stops working) and Cancel invite, a search, the role filter and sorting. Inviting someone already invited changes their invite to what you set now and sends a new link. + - The link works once, for 7 days. When they join, whoever invited them is notified in ITFlow, the audit log has it in their name, and their quick look says whose invite they joined from. A link that has run out, been used or cancelled says so, and who to ask. If the login key is required, the page sends them to sign in with it. + - An invite waiting holds a seat under the agent user limit, so there is one for them when they accept, and a role with invites waiting can't be archived. + - Get started > Your team invites them too, when email is set up, and lists who is invited. + - The email is in Admin > Mail > Previews, as Invite to join under Your team's accounts. The nightly run clears invites cancelled, or run out, more than 90 days ago. - **Get started** (new) - On a new install, the first admin to sign in after setup is taken through what is left to set up, a step at a time: Cron, Email, Backups, Billing, Support, Your team, Starter content and Your first client. Each step is a short form of its own and saves on its own, with Skip for now - It's just me, on Your team. Billing goes with the accounting module and Support with ticketing, so a step whose module is off isn't there. - A step ticks itself off from the settings themselves, however they were set - here or on the settings page: cron heard from and on, a way to send email and an address to send it as, nightly backups on, an hourly rate, default response times, a second agent, ticket or document templates or canned responses, a client. The steps along the top show which are done, skipped or still to do, and any can be gone back to. @@ -1212,7 +1224,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Backups: the backup key, to show and copy, with a tick to say a copy is kept somewhere else before nightly backups are turned on - when they are taken, the database or everything, and how many to keep and for how long. - Billing: the default hourly rate and net terms, which a client is billed on when its contract doesn't set them, a tax rate for new lines when there is no default (made and made the default), invoice and quote numbering, and - if you like - Stripe, with its keys put to Stripe before they are saved. - Support: business days and hours, the standard response times for each priority as the default SLAs (Urgent answered in 1 business hour, High 2, Medium 4, Low 8, with no time to resolve by - one can be added in Admin > SLAs; an SLA with the same response time and none to resolve by is used when there is one, and only priorities with none get them), ticket numbering and the client portal on or off. Open tickets are stamped afresh with the hours and response times saved. - - Your team: everyone who signs in, and Add someone - a name, an email and a role. ITFlow makes their password: they are emailed their sign-in details, as Admin > Users does, or, with no email set up yet, it is shown to you once to give them. They change it once signed in. + - Your team: everyone who signs in and everyone invited, and Add someone - a name, an email and a role. With email set up they are invited, as New User in Admin > Users does, and choose their own password from the link (see Agent invites). With no email set up yet, ITFlow makes their password and shows it to you once to give them, and they change it once signed in. - Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add - and Pick which under each, as on that page (see Starter Content, Pick which). Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first. - The dashboard shows Finish setting up, with how far it has got, a chip for each step and Carry on, to admins until every step is done or skipped. Its × stops it showing there. Admin > Get started opens it any time, with how many steps are done or skipped beside it, and a summary at the end lists every step with where it stands. - Until every step is done or skipped, the top of Get started has Do it later and Skip all. Do it later goes to the dashboard and leaves each step as it is, for Finish setting up to carry on from. Skip all, once you say yes, skips every step still to do, and the dashboard stops showing Finish setting up - each step is still in Admin > Get started, and doing one ticks it off as ever. @@ -1347,7 +1359,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - New Ticket and recurring tickets: once the window had been opened more than once, Add Task added a task row for every time it had been opened. - Forms with tabs, like a ticket template's: with a required field left empty on a tab that wasn't showing, Save did nothing and said nothing. That tab now opens, on the field. - Email on the contacts list queued an email to a contact with no email address, which then failed in the mail queue. Email on the clients list included archived contacts. Both took any From address posted to them. -- Emails: a saved card's confirmation, a new agent's sign in details and the email about a change to an agent's account were only sent when an SMTP host was filled in, so they were skipped when mail goes through Microsoft 365 or Google with no host. The saved card's email began "Were writing". +- Emails: a saved card's confirmation and the email about a change to an agent's account were only sent when an SMTP host was filled in, so they were skipped when mail goes through Microsoft 365 or Google with no host. The saved card's email began "Were writing". - Contacts: an agent without Support saw a contact's Assets, Documents, Files and Services tabs, which led to pages they were refused. A document's or file's linked assets, licenses, services, domains and certificates were listed to them too. - All Client Docs listed Credentials to agents who can't open it. - Notifications: shared-link notifications reached agents without Support, with a link they couldn't open. @@ -1526,7 +1538,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Ticket visit files are built without zapcal, so `libs/zapcal` is no longer used by anything. - Certificates: `certificateCheckAndSave()` runs the host check for the form, the API and the refresher. The refresher runs daily in a time-boxed batch, writes history and notifies when an OK certificate turns bad. Domains: `domainLookup()` and `domainApplyLookup()`, and the refresher takes 50 a run. - Recurring tickets: cron skips paused ones and raises a missed run once. -- Agent user limit: `getAgentUserLimit()`, `getActiveAgentUserCount()`, `agentUserLimitReached()` and `agentUserLimitMessage()` in `functions/app.php`. These are checked when adding, activating or restoring an agent. +- Agent user limit: `getAgentUserLimit()`, `getActiveAgentUserCount()`, `getAgentSeatsTaken($except_invite_id = 0)` (active agents and invites waiting in date), `agentUserLimitReached($except_invite_id = 0)` and `agentUserLimitMessage()` (which names the invites waiting) in `functions/app.php`. These are checked when adding, inviting, activating or restoring an agent, and when an invite is accepted. - Users: - `userSetPassword()` writes the hash and the vault key from the same untrimmed password. - `userCheckIdentity()` validates the email format and checks it is unique across agent and client portal accounts. @@ -2071,6 +2083,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `functions/contract.php`: new `contractDefaultHints()` (the placeholders and SLA option text of `contract_form.php` and `contract_template_form.php`) and `contractStandardTermsAdd($client_id, $rate, $net_terms, $slas = [], $start = null, $notes = '')` (the contract id, or 0 when nothing differs from the defaults), used by `import_clients_csv` and `api/v1/clients/create.php`, which returns `contract_id`. `api/v1/clients/update.php` returns `not_kept` when `client_rate` or `client_net_terms` is sent. - Clients: `client_add.php` and `client_edit.php` lose the `rate`, `net_terms` and `sla_*` fields, and `add_client` and `edit_client` no longer save them or SLA rows; new `agent/modals/client/client_terms_note.php` (`$terms_note_client_id`, 0 for a new client) is their Billing tab's note. Removed: `agent/modals/client/client_bulk_edit_hourly_rate.php` and `client_bulk_edit_net_terms.php`, with the `bulk_edit_client_hourly_rate` and `bulk_edit_client_net_terms` handlers. `clientExportRows()`: the client dataset's `hourly_rate`, `after_hours_rate`, `net_terms` and new `terms_from` (with the billing section), and the `slas` dataset's rows (each priority's SLA with a new `from`), are what the client is billed on. Demo data gives each demo client a contract (`demoBuildContract()`) instead of a rate, terms and SLA rows of its own, and its removal keeps an SLA a remaining contract or template sets. - Contracts from the client: `contractClientPickReady()` (contracts there, accounting on, Sales at Modify), `contractFromTemplateAdd($client_id, $template_id, $start, $owner_id, $notes)` ([contract id, error] - active from `$start`, its term carried on to today when it renews itself, the client's primary contact) and `contractFromClientForm($client_id, $post)` (`contract_template_id` and `contract_start`, logged as Contract Create; [id, name, error]), in `functions/contract.php`. New `agent/modals/client/client_contract_pick.php` (`$contract_pick_edit`) on the Billing tab of `client_add.php` and `client_edit.php`; `add_client` and `edit_client` call `contractFromClientForm()` after saving, and say when the contract couldn't be made. New `agent/modals/client/client_bulk_add_contract.php` and `bulk_add_client_contract` (POST `client_ids[]`, `contract_template_id`, `contract_start`; Client and Sales at Modify; logged as Contract Bulk Create). New `contractInForceSql($alias, $date)` in `functions/contract_billing.php`. `agent/clients.php` takes `contract` (`any`, `none` or a template id) and `sort=contract` (the name of the contract in force that started last). +- Agent invites: database 3.5.1 adds `user_invites` - `invite_email`, `invite_name`, `invite_role_id`, `invite_force_mfa`, `invite_client_access` (text, JSON `{"allow": [client_id], "deny": [...]}`, NULL for none), `invite_avatar` (a file in `uploads/users/invites//`), `invite_token_hash` (the SHA-256 of the link's 48-hex token), `invite_vault` (the vault's master key sealed under the token as `setupFirstUserSpecificKey()` seals it under a password - wiped once accepted, cancelled or run out), `invite_expires_at`, `invite_sent_at`, `invite_sent_count`, `invite_created_by`, `invite_created_at`, `invite_accepted_at`, `invite_user_id` and `invite_cancelled_at`, keyed on the address and the user. + - New `functions/user_invite.php`, loaded by `functions.php`: `USER_INVITE_LINK_DAYS` (7), `userInvitesReady()` (3.5.1 has run - until then New User sets a password and nothing else changes), `userInviteMailReady()`, `userInviteRow()`, `userInviteFor($email)` (the one waiting), `userInviteState()`, `userInviteStateLabel()`, `userInviteClientAccess()`, `userInviteBy()`, `userInviteUrl()`, `userInvitesHoldingSeats($except_invite_id)`, `userInvitesWithRole()`, `userInviteSessionVaultKey()` (the master key from the admin's session, '' when it can't open it), `userInviteNewToken()` (a new token, hash and seal - '' and nothing changed without the vault), `userInviteByLink()` (hash_equals), `userInviteSaveAvatar()`, `userInviteRemoveAvatar()`, `userInviteAvatar()`, `userInviteSave()` (one waiting for the address is updated, keeping its picture), `userInviteSend($invite_id, $mail, &$error)` (a new link, emailed or only made), `userInviteShowLink()` and `userInviteTakeLink()` (the link to pass on, kept in the session to show once), `userInviteMail()`, `userInviteCancel()`, `userInviteAccept()` (spends the link with one guarded UPDATE first; makes the user, `user_settings`, client rules and picture; logs `User` Create in the new agent's name; notifies whoever sent it), `userInviteJoined()`, `userInvitesWaiting()` and `userInvitesNightly()`, which `cron/nightly_tasks.php` runs and logs. + - New `/join.php` (`?invite=&token=`, posts `agent_join`, `name`, `new_password`, `confirm_password`; Referrer-Policy no-referrer, refused over plain HTTP when `$config_https_only` is set), then `$_SESSION['login_message']` and on to `/login.php`, with `?key=` when the login key is required. + - `add_user` takes `sign_in` (`invite` or `password`): `invite` keeps the invite and emails it, or with mail not set up shows the link once, and goes to `users.php?show=invited`; without `sign_in`, a `password` sent sets it, as before. Setting a password for an address with an invite waiting cancels the invite. New GET handlers with the token: `resend_user_invite`, `link_user_invite` and `cancel_user_invite`, logged as `User` Invite and Cancel. `getting_started_team` invites when `userInviteMailReady()`. + - `userMailNewAccount()` is removed, and with it the `user_new` preview and `send_email` on New User. `emailPreviewUserInviteCatalog()` adds `user_invite`. New `userAvatarStore($file, $dir, &$error)` in `functions/user.php`, which `userSaveAvatar()` now uses. `roleArchiveBlocker()` refuses a role with invites waiting. `gettingStartedSteps()` counts the team step done with an invite waiting. + - `admin/users.php` adds `show=invited`, its own list from `user_invites` sorted by the same headers (`user_name`, `user_email`, `role_name`, `user_status` - when it runs out - and `user_created_at`). `user_form.php` adds the `sign_in` choice for a new user (`data-user-sign-in`, `data-user-password-field`), and `user_add.php`'s button `data-user-submit` takes its label from it. ### Library Updates diff --git a/admin/database_updates/3.5.1.php b/admin/database_updates/3.5.1.php new file mode 100644 index 000000000..492278734 --- /dev/null +++ b/admin/database_updates/3.5.1.php @@ -0,0 +1,46 @@ +/ + */ + +defined('FROM_DB_UPDATER') || die("Direct file access is not allowed"); + + mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `user_invites` ( + `invite_id` int(11) NOT NULL AUTO_INCREMENT, + `invite_email` varchar(200) NOT NULL, + `invite_name` varchar(200) NOT NULL, + `invite_role_id` int(11) NOT NULL DEFAULT 0, + `invite_force_mfa` tinyint(1) NOT NULL DEFAULT 0, + `invite_client_access` text DEFAULT NULL, + `invite_avatar` varchar(200) DEFAULT NULL, + `invite_token_hash` char(64) NOT NULL DEFAULT '', + `invite_vault` varchar(255) NOT NULL DEFAULT '', + `invite_expires_at` datetime NOT NULL, + `invite_sent_at` datetime DEFAULT NULL, + `invite_sent_count` int(11) NOT NULL DEFAULT 0, + `invite_created_by` int(11) NOT NULL DEFAULT 0, + `invite_created_at` datetime NOT NULL DEFAULT current_timestamp(), + `invite_accepted_at` datetime DEFAULT NULL, + `invite_user_id` int(11) NOT NULL DEFAULT 0, + `invite_cancelled_at` datetime DEFAULT NULL, + PRIMARY KEY (`invite_id`), + KEY `invite_email` (`invite_email`), + KEY `invite_user_id` (`invite_user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); diff --git a/admin/getting_started/team.php b/admin/getting_started/team.php index 0fa7c78d6..dfb55f0e9 100644 --- a/admin/getting_started/team.php +++ b/admin/getting_started/team.php @@ -1,11 +1,12 @@ Users does, or - - * with no email set up yet - shown once for you to give them. Their own copy - * of the vault key is made under it, so they set their own once signed in. + * Get started - Your team: who signs in now and who is invited, and someone + * to add - a name, an email and a role (getting_started_team). With email set + * up they are invited, as New User in Admin > Users does: a link to choose + * their own password (functions/user_invite.php). Without, ITFlow makes + * their password and shows it once for you to give them, their own copy of + * the vault key made under it, and they change it once signed in. */ defined('FROM_GETTING_STARTED') || die("Direct file access is not allowed"); @@ -24,7 +25,8 @@ $gs_role_options = ''; foreach ($gs_roles as $gs_role_id => $gs_role) { $gs_role_options .= ''; } -$gs_mail_ready = emailReady(); +$gs_invites = userInvitesWaiting(); +$gs_invite_ready = userInvitesReady() && userInviteMailReady(); $gs_limit = agentUserLimitReached(); ?> @@ -39,7 +41,23 @@ $gs_limit = agentUserLimitReached(); + +
  • + +
    +
    + +
    +
    +
    + +
  • + + +

    Send an invite again, get its link or cancel it in Admin > Users, Invited.

    +
    @@ -59,11 +77,11 @@ $gs_limit = agentUserLimitReached(); 'help' => 'What they can see and change - roles are in Admin > Roles. A role that isn\'t an admin one sees every client, until you limit them in Admin > Users.']) ?> - -

    They are emailed their sign-in details, with a password ITFlow makes for them to change.

    + +

    They are emailed an invite - a link to choose their own password and sign in, which works for days.

    -

    Email isn't set up yet, so ITFlow makes their password and shows it to you once - for you to give them, and them to change.

    +

    and shows it to you once - for you to give them, and them to change.

    - + diff --git a/admin/modals/user/user.php b/admin/modals/user/user.php index 9529317ee..2569c5b26 100644 --- a/admin/modals/user/user.php +++ b/admin/modals/user/user.php @@ -91,7 +91,11 @@ ob_start();
    Added
    -
    +
    + + · joined from invite + +
    Archived
    diff --git a/admin/modals/user/user_add.php b/admin/modals/user/user_add.php index fac32ba25..f4b2a848c 100644 --- a/admin/modals/user/user_add.php +++ b/admin/modals/user/user_add.php @@ -1,8 +1,9 @@ diff --git a/admin/modals/user/user_form.php b/admin/modals/user/user_form.php index 8de11e20d..b82c940a6 100644 --- a/admin/modals/user/user_form.php +++ b/admin/modals/user/user_form.php @@ -9,6 +9,11 @@ * Client access - which clients a role without admin is limited to or kept * from. An admin sees every client, so for an admin role that tab just says * so (any rules are kept, in case the role changes back). + * + * New User invites them by default (sign_in invite - functions/user_invite.php): + * they choose their own password from a link, emailed or, with no mail set + * up, shown to the admin to pass on. Set a password now (sign_in password) + * is the other way - for the admin to give them. */ isset($mysqli) || die("Direct file access is not allowed"); @@ -20,6 +25,8 @@ $form_roles = userRoles($form_role_id); $form_has_mfa = trim((string) ($form['user_token'] ?? '')) !== ''; $form_has_avatar = trim((string) ($form['user_avatar'] ?? '')) !== ''; $form_access = $form_user_id ? userClientAccess([$form_user_id])[$form_user_id] : ['allow' => [], 'deny' => []]; +$form_invites = $is_new && userInvitesReady(); +$form_invite_mail = $form_invites && userInviteMailReady(); // Every client - and an archived one only when it has a rule, so saving does not drop it. Those with a rule first. $form_clients = []; @@ -119,39 +126,42 @@ $value = function ($key) use ($form) {
    -
    + +
    + +
    + + + + +
    +
    + + They're emailed a link to choose their own password, so nobody else ever knows it. It works for days - until they use it, they're under Invited. + + Mail isn't set up, so you get the link to send them yourself - they choose their own password with it. It works for days. + +
    + +
    + +
    >
    data-user-password> + placeholder="" minlength="" autocomplete="new-password" data-user-password>
    -
    - - Give it to them yourself, or have it emailed - either way, they should change it once they're in. - - Set, they're emailed that it changed. It also unlocks their copy of the credential vault, so it is set here rather than reset by email. - -
    + +
    Set, they're emailed that it changed. It also unlocks their copy of the credential vault, so it is set here rather than reset by email.
    + +
    For you to give them yourself - nothing is emailed. They should change it once they're in.
    +
    - -
    - -
    - - -
    The address, the password and where to sign in.
    -
    - -
    Mail isn't set up, so nothing is emailed - give them their password yourself.
    - -
    - -
    @@ -303,6 +313,31 @@ $value = function ($key) use ($form) { }); }); + // New User: invite them - no password here - or set one now + var signIn = root.querySelectorAll('[data-user-sign-in]'); + if (signIn.length) { + var passwordField = root.querySelector('[data-user-password-field]'); + var submit = form.querySelector('[data-user-submit]'); + var signInChanged = function () { + var checked = root.querySelector('[data-user-sign-in]:checked'); + var invite = !checked || checked.value === 'invite'; + passwordField.hidden = invite; + password.required = !invite; + password.disabled = invite; + root.querySelectorAll('[data-user-sign-in-note]').forEach(function (note) { + note.hidden = note.dataset.userSignInNote !== (invite ? 'invite' : 'password'); + }); + if (submit) { + submit.querySelector('[data-user-submit-label]').textContent = invite ? submit.dataset.labelInvite : submit.dataset.labelCreate; + submit.querySelector('i').className = 'fas me-2 ' + (invite ? 'fa-paper-plane' : 'fa-check'); + } + }; + signIn.forEach(function (radio) { + radio.addEventListener('change', signInChanged); + }); + signInChanged(); + } + // Client access: an admin sees everything, so the rules are put away var role = root.querySelector('[data-user-role]'); var adminNote = root.querySelector('[data-user-access-admin]'); diff --git a/admin/post/getting_started.php b/admin/post/getting_started.php index 6e3216aae..3140f60ba 100644 --- a/admin/post/getting_started.php +++ b/admin/post/getting_started.php @@ -321,13 +321,16 @@ if (isset($_POST['getting_started_team'])) { validateCSRFToken(); - if (agentUserLimitReached()) { - flashAlert(escapeHtml(agentUserLimitMessage()), 'error'); + $name = trim((string) ($_POST['team_name'] ?? '')); + $email = trim((string) ($_POST['team_email'] ?? '')); + + // An invite waiting for them holds a seat already - it is this one + $open = userInviteFor($email); + if (agentUserLimitReached($open && userInviteState($open) === 'pending' ? intval($open['invite_id']) : 0)) { + flashAlert(agentUserLimitMessage(), 'error'); redirect(gettingStartedUrl('team')); } - $name = trim((string) ($_POST['team_name'] ?? '')); - $email = trim((string) ($_POST['team_email'] ?? '')); $errors = []; if ($name === '') { $errors['team_name'] = 'Their name, as it shows on tickets.'; @@ -355,21 +358,36 @@ if (isset($_POST['getting_started_team'])) { } [$name, $email] = $identity; - // Their password - made here, emailed or shown once, for them to change + // With mail, an invite - they choose their own password from the link + if (userInvitesReady() && userInviteMailReady()) { + $saved = userInviteSave(['name' => $name, 'email' => $email, 'role_id' => $role['role_id'], 'force_mfa' => 0, 'access' => []]); + $send_error = ''; + if (!userInviteSend($saved['invite_id'], true, $send_error)) { + if (!$saved['again']) { + mysqli_query($mysqli, "DELETE FROM user_invites WHERE invite_id = " . intval($saved['invite_id'])); + } + flashAlert($send_error, 'error'); + redirect(gettingStartedUrl('team')); + } + logAudit("User", "Invite", "$session_name invited " . escapeSql("$name ($email) as {$role['role_name']}") . " in Get started" . ($saved['again'] ? ', again' : '')); + flashAlert('' . escapeHtml($name) . ' is invited - an email with a link to join is on the way to ' . escapeHtml($email) . '' + . (gettingStartedSteps()['cron']['done'] ? '' : ', once cron is running') . '. They choose their own password'); + redirect(gettingStartedUrl('team')); + } + + // Without, their password - made here and shown once, for you to give them and them to change $password = generateReadablePassword(); mysqli_query($mysqli, "INSERT INTO users SET user_name = '" . escapeSql($name) . "', user_email = '" . escapeSql($email) . "', user_password = '', user_role_id = " . intval($role['role_id'])); $user_id = intval(mysqli_insert_id($mysqli)); userSetPassword($user_id, $password); mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = 0"); - - $emailed = emailReady() && userMailNewAccount($name, $email, $password, 0); - logAudit("User", "Create", "$session_name created user " . escapeSql("$name ({$role['role_name']})") . " in Get started" . ($emailed ? ', their sign in details emailed to them' : ''), 0, $user_id); - - if ($emailed) { - flashAlert('' . escapeHtml($name) . ' is added - their sign-in details are on the way to ' . escapeHtml($email) . '' . (gettingStartedSteps()['cron']['done'] ? '' : ', once cron is running')); - } else { - flashAlert('' . escapeHtml($name) . ' is added. Their password is ' . escapeHtml($password) . ' - give it to them to sign in with and change. It isn\'t shown again', 'warning'); + if ($open) { + userInviteCancel($open['invite_id']); } + + logAudit("User", "Create", "$session_name created user " . escapeSql("$name ({$role['role_name']})") . " in Get started", 0, $user_id); + + flashAlert('' . escapeHtml($name) . ' is added. Their password is ' . escapeHtml($password) . ' - give it to them to sign in with and change. It isn\'t shown again', 'warning'); redirect(gettingStartedUrl('team')); } diff --git a/admin/post/users.php b/admin/post/users.php index 4eea8c5ab..0675033c5 100644 --- a/admin/post/users.php +++ b/admin/post/users.php @@ -9,6 +9,11 @@ * picture that really is one. An admin changes their own account from their * Account pages, not here, and the last active admin cannot be demoted, * disabled or archived - there would be nobody left to undo it. + * + * New User invites them by default (functions/user_invite.php) - they choose + * their own password from the link - or sets a password now, for the admin + * to give them. An invite waiting is sent again, its link got to pass on, or + * cancelled from the Invited view. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); @@ -25,30 +30,73 @@ if (isset($_POST['add_user'])) { validateCSRFToken(); - if (agentUserLimitReached()) { - flashAlert(agentUserLimitMessage(), 'error'); - redirect(); - } - $error = ''; $identity = userCheckIdentity($_POST, 0, $error); $role = $identity ? userCheckRole($_POST['role'] ?? 0, 0, $error) : null; - $password = (string) ($_POST['password'] ?? ''); - if ($role && ($problem = userPasswordProblem($password))) { - $error = $problem; - } - if (!$identity || !$role || $error !== '') { + if (!$identity || !$role) { flashAlert($error, 'error'); redirect(); } [$name, $email] = $identity; $role_id = intval($role['role_id']); + $force_mfa = !empty($_POST['force_mfa']) ? 1 : 0; + + // An invite - they choose their own password - or a password set now. Without the + // choice (an older form, a script), a password sent is one to set. + $password = (string) ($_POST['password'] ?? ''); + $how = ($_POST['sign_in'] ?? ($password !== '' ? 'password' : 'invite')) === 'invite' && userInvitesReady() ? 'invite' : 'password'; + + // An invite waiting for them holds a seat already - it is this one + $open = userInviteFor($email); + if (agentUserLimitReached($open && userInviteState($open) === 'pending' ? intval($open['invite_id']) : 0)) { + flashAlert(agentUserLimitMessage(), 'error'); + redirect(); + } + + if ($how === 'invite') { + + $saved = userInviteSave(['name' => $name, 'email' => $email, 'role_id' => $role_id, 'force_mfa' => $force_mfa, + 'access' => intval($role['role_is_admin']) ? [] : ($_POST['client_permission'] ?? [])], $_FILES['avatar'] ?? null); + $invite_id = $saved['invite_id']; + $mail = userInviteMailReady(); + $send_error = ''; + $sent = userInviteSend($invite_id, $mail, $send_error); + if (!$sent) { + if (!$saved['again']) { + userInviteRemoveAvatar($invite_id); + mysqli_query($mysqli, "DELETE FROM user_invites WHERE invite_id = $invite_id"); + } + flashAlert($send_error, 'error'); + redirect(); + } + + logAudit("User", "Invite", "$session_name invited " . escapeSql("$name ($email) as {$role['role_name']}") . ($saved['again'] ? ' again' : '') . ($mail ? '' : ' - a link to pass on')); + + $notes = []; + if ($saved['again']) { + $notes[] = 'the invite they had is changed to this, and its link no longer works'; + } + if ($saved['avatar_error'] !== '') { + $notes[] = $saved['avatar_error'] . ' The invite went without one'; + } + if ($mail) { + flashAlert("Invited $name - an email with a link to join is on the way to $email. It works for " . USER_INVITE_LINK_DAYS . " days" . ($notes ? ' - ' . implode('; ', $notes) : ''), $saved['avatar_error'] !== '' ? 'warning' : 'success'); + } else { + userInviteShowLink($invite_id, $sent['url']); + } + redirect('users.php?show=invited'); + + } + + if ($problem = userPasswordProblem($password)) { + flashAlert($problem, 'error'); + redirect(); + } mysqli_query($mysqli, "INSERT INTO users SET user_name = '" . escapeSql($name) . "', user_email = '" . escapeSql($email) . "', user_password = '', user_role_id = $role_id"); $user_id = mysqli_insert_id($mysqli); userSetPassword($user_id, $password); - $force_mfa = !empty($_POST['force_mfa']) ? 1 : 0; mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = $force_mfa"); if (!intval($role['role_is_admin'])) { @@ -61,24 +109,68 @@ if (isset($_POST['add_user'])) { $notes[] = $avatar_error . ' The user was created without one'; } - // Their sign in details by email - the password goes as typed, so it is theirs to change - if (!empty($_POST['send_email']) && emailReady()) { - if (userMailNewAccount($name, $email, $password, $force_mfa)) { - $notes[] = 'their sign in details were emailed to them'; - } else { - $notes[] = 'the email with their sign in details could not be queued'; - logApp('Mail', 'error', 'Failed to queue the new account email for ' . escapeSql($email)); - } + // Set up here, the invite waiting for them is done with + if ($open && userInviteCancel($open['invite_id'])) { + $notes[] = 'the invite waiting for them is cancelled'; } logAudit("User", "Create", "$session_name created user " . escapeSql($name), 0, $user_id); - flashAlert("User $name created" . ($notes ? ' - ' . implode('; ', $notes) : ''), $avatar_error ? 'warning' : 'success'); + flashAlert("User $name created - give them their password yourself" . ($notes ? ' - ' . implode('; ', $notes) : ''), $avatar_error ? 'warning' : 'success'); redirect(); } +if (isset($_GET['resend_user_invite']) || isset($_GET['link_user_invite']) || isset($_GET['cancel_user_invite'])) { + + // An invite waiting: email it again, get its link to pass on (either makes a new link, + // so the old one stops working), or cancel it + validateCSRFToken(); + + $what = isset($_GET['resend_user_invite']) ? 'resend' : (isset($_GET['link_user_invite']) ? 'link' : 'cancel'); + $invite = userInviteRow($_GET[$what . '_user_invite'] ?? 0); + if (!$invite || !in_array(userInviteState($invite), ['pending', 'expired'], true)) { + flashAlert("That invite isn't waiting any more - it may have been accepted or cancelled", 'error'); + redirect(); + } + $invite_id = intval($invite['invite_id']); + $who = escapeSql("{$invite['invite_name']} ({$invite['invite_email']})"); + + if ($what === 'cancel') { + userInviteCancel($invite_id); + logAudit("User", "Cancel", "$session_name cancelled the invite to $who"); + flashAlert("Cancelled the invite to " . escapeHtml($invite['invite_email']) . " - its link no longer works"); + redirect(); + } + + // One that has run out holds no seat - sending it again takes one + if (userInviteState($invite) === 'expired' && agentUserLimitReached()) { + flashAlert(agentUserLimitMessage(), 'error'); + redirect(); + } + if ($what === 'resend' && !userInviteMailReady()) { + flashAlert("Mail isn't set up, so the invite can't be emailed - get its link and send it to them yourself", 'error'); + redirect(); + } + $send_error = ''; + $sent = userInviteSend($invite_id, $what === 'resend', $send_error); + if (!$sent) { + flashAlert($send_error, 'error'); + redirect(); + } + + if ($what === 'resend') { + logAudit("User", "Invite", "$session_name sent the invite to $who again"); + flashAlert("Sent the invite to " . escapeHtml($invite['invite_email']) . " again - it works for " . USER_INVITE_LINK_DAYS . " days, and the link in the last one no longer works"); + } else { + logAudit("User", "Invite", "$session_name got a new invite link for $who, to pass on"); + userInviteShowLink($invite_id, $sent['url']); + } + redirect(); + +} + if (isset($_POST['edit_user'])) { validateCSRFToken(); diff --git a/admin/users.php b/admin/users.php index f2cf54c16..509d07a02 100644 --- a/admin/users.php +++ b/admin/users.php @@ -5,6 +5,9 @@ * they are, their role, whether they can sign in and how (MFA, browsers that * skip the code), the clients they can see and when they last signed in. The * pills split them: everyone, admins, no MFA, disabled - and the archived. + * Invited is the people asked to join who aren't in yet: the invites waiting + * or run out (functions/user_invite.php), to send again, get the link to + * pass on, or cancel. A link made to pass on is shown once, at the top. * * The admin's own row is shown but not changed here: that is their Account * pages, so nobody locks themselves out by mistake. @@ -34,10 +37,20 @@ $shows = [ 'admins' => ['label' => 'Admins', 'icon' => 'fa-user-shield', 'where' => 'user_archived_at IS NULL AND role_is_admin = 1', 'title' => 'Every client and every setting'], 'nomfa' => ['label' => 'No MFA', 'icon' => 'fa-unlock', 'where' => "user_archived_at IS NULL AND user_status = 1 AND (user_token IS NULL OR user_token = '')", 'title' => 'Active, and signing in with a password alone'], 'disabled' => ['label' => 'Disabled', 'icon' => 'fa-ban', 'where' => 'user_archived_at IS NULL AND user_status <> 1', 'title' => 'Cannot sign in'], + 'invited' => ['label' => 'Invited', 'icon' => 'fa-paper-plane', 'where' => null, 'title' => 'Asked to join and not in yet - their invite waiting, or run out'], 'archived' => ['label' => 'Archived', 'icon' => 'fa-archive', 'where' => 'user_archived_at IS NOT NULL', 'title' => 'Gone - restore one with a new password'], ]; +if (!userInvitesReady()) { + unset($shows['invited']); +} $show = isset($shows[$_GET['show'] ?? '']) ? ($_GET['show'] ?? '') : ''; +// The invites - not users yet, so a list of their own +$invite_filters = "invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND (invite_name LIKE '%$q%' OR invite_email LIKE '%$q%' OR role_name LIKE '%$q%')" + . ($role_filter ? " AND invite_role_id = $role_filter" : ''); +$invite_joins = "LEFT JOIN user_roles ON role_id = invite_role_id + LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0"; + // Only these sort - an unknown column would take the page down $last_login_sql = "(SELECT MAX(log_id) FROM logs WHERE log_user_id = users.user_id AND log_type = 'Login' AND log_action = 'Success')"; $sortable_columns = [ @@ -48,40 +61,70 @@ $sortable_columns = [ 'last_login' => "$last_login_sql IS NULL, $last_login_sql $order, user_name ASC", 'user_created_at' => "user_created_at $order, user_name ASC", ]; -if (!isset($sortable_columns[$sort])) { +// ...and the same headers sort the invites: when it runs out for Sign in, when it was sent for Added +$invite_sortable_columns = [ + 'user_name' => "invite_name $order", + 'user_email' => "invite_email $order", + 'role_name' => "role_is_admin DESC, role_name $order, invite_name ASC", + 'user_status' => "invite_expires_at $order, invite_name ASC", + 'user_created_at' => "invite_created_at $order, invite_name ASC", +]; +if (!isset(($show === 'invited' ? $invite_sortable_columns : $sortable_columns)[$sort])) { $sort = 'user_name'; } -$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS users.user_id, user_name, user_email, user_status, user_token, user_avatar, user_created_at, user_archived_at, - user_role_id, role_name, role_is_admin, user_config_force_mfa - FROM users $joins - WHERE $filters AND {$shows[$show]['where']} - ORDER BY {$sortable_columns[$sort]}, users.user_id ASC - LIMIT $record_from, $record_to"); - -$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); - $users = []; -while ($row = mysqli_fetch_assoc($sql)) { - $users[intval($row['user_id'])] = $row; +$invites = []; +if ($show === 'invited') { + $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS user_invites.*, role_name, role_is_admin, inviter.user_name AS invite_by_name + FROM user_invites $invite_joins + WHERE $invite_filters + ORDER BY {$invite_sortable_columns[$sort]}, invite_id ASC + LIMIT $record_from, $record_to"); + $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); + while ($row = mysqli_fetch_assoc($sql)) { + $invites[] = $row; + } +} else { + $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS users.user_id, user_name, user_email, user_status, user_token, user_avatar, user_created_at, user_archived_at, + user_role_id, role_name, role_is_admin, user_config_force_mfa + FROM users $joins + WHERE $filters AND {$shows[$show]['where']} + ORDER BY {$sortable_columns[$sort]}, users.user_id ASC + LIMIT $record_from, $record_to"); + $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); + while ($row = mysqli_fetch_assoc($sql)) { + $users[intval($row['user_id'])] = $row; + } } $logins = userLastLogins(array_keys($users)); $access = userClientAccess(array_keys($users)); $remembered = userRememberCounts(array_keys($users)); // How many in each view, over what the search and role leave +$user_views = array_filter($shows, function ($s) { + return $s['where'] !== null; +}); $view_counts = []; $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($s) { return "COALESCE(SUM({$s['where']}), 0)"; -}, $shows)) . " FROM users $joins WHERE $filters")); -foreach (array_keys($shows) as $i => $key) { +}, $user_views)) . " FROM users $joins WHERE $filters")); +foreach (array_keys($user_views) as $i => $key) { $view_counts[$key] = intval($row[$i]); } +if (isset($shows['invited'])) { + $view_counts['invited'] = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites $invite_joins WHERE $invite_filters"))[0]); +} -// Agent user limit (set in config.php, 0 / unset = unlimited) +// Agent user limit (set in config.php, 0 / unset = unlimited) - an invite waiting holds a seat $agent_user_limit = getAgentUserLimit(); $agent_user_count = getActiveAgentUserCount(); -$agent_user_limit_reached = $agent_user_limit > 0 && $agent_user_count >= $agent_user_limit; +$agent_invites_holding = userInvitesHoldingSeats(); +$agent_user_limit_reached = $agent_user_limit > 0 && $agent_user_count + $agent_invites_holding >= $agent_user_limit; + +// An invite link to pass on - shown this once +$invite_link = userInviteTakeLink(); +$invite_mail_ready = userInviteMailReady(); $filter_url = function ($changes) { $params = array_merge($_GET, $changes); @@ -102,15 +145,16 @@ $csrf = $_SESSION['csrf_token'];

    Users 0) { ?> - - of seats + , of the agent user limit set for this installation"> + of seats

    - @@ -203,6 +247,136 @@ $csrf = $_SESSION['csrf_token'];
    + +
    +
    The invite link for
    +
    Send it to yourself - by chat, text or your own email. With it they choose their own password and join as . + It works for days and is only shown now - Get the link again makes a new one.
    +
    + + + +
    +
    + + + + +
    + + text-nowrap table-light"> + + + + + + + + + + + Role removed' + : '' . escapeHtml($invite['role_name']); + $invite_pill = '' . $invite_label['label'] . ''; + ?> + + + + + + + + + + + + + + + + + + + +
    Clients
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +
    + + + MFA required + +
    +
    +
    + + +
    ·
    +
    + + Emailed 1 ? " · $invite_sent times" : '' ?> + + A link to pass on + +
    +
    + +
    +
    + + +
    @@ -359,6 +533,8 @@ $csrf = $_SESSION['csrf_token'];
    + + 'users', @@ -366,7 +542,8 @@ $csrf = $_SESSION['csrf_token']; 'filtered' => $q !== '' || $role_filter, 'archived' => $show === 'archived', 'views' => ['show'], - 'view' => $show === 'nomfa' ? ['title' => 'Everyone active signs in with MFA', 'icon' => 'fa-check'] : emptyStatePill($shows, $show), + 'view' => $show === 'nomfa' ? ['title' => 'Everyone active signs in with MFA', 'icon' => 'fa-check'] + : ($show === 'invited' ? ['title' => 'No invites waiting', 'text' => 'Everyone invited has joined. Invite someone with New User.', 'icon' => 'fa-paper-plane'] : emptyStatePill($shows, $show)), ]; require_once "../includes/filter_footer.php"; ?> diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php index c4b78dfc4..5c0c5f591 100644 --- a/cron/nightly_tasks.php +++ b/cron/nightly_tasks.php @@ -238,6 +238,12 @@ if ($portal_invites_cleared) { logApp("Cron", "info", "Client portal: cleared $portal_invites_cleared sign-up or cancelled invite link(s) nobody used"); } +// AGENT INVITES - cancelled ones, and ones that ran out, after 90 days +$user_invites_cleared = userInvitesNightly(); +if ($user_invites_cleared) { + logApp("Cron", "info", "Users: cleared $user_invites_cleared cancelled or run-out agent invite(s)"); +} + // GET NOTIFICATIONS // DOMAINS EXPIRING diff --git a/db.sql b/db.sql index e38b7b44a..3bf49a7ea 100644 --- a/db.sql +++ b/db.sql @@ -4052,6 +4052,37 @@ CREATE TABLE `user_client_permissions` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `user_invites` +-- + +DROP TABLE IF EXISTS `user_invites`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_invites` ( + `invite_id` int(11) NOT NULL AUTO_INCREMENT, + `invite_email` varchar(200) NOT NULL, + `invite_name` varchar(200) NOT NULL, + `invite_role_id` int(11) NOT NULL DEFAULT 0, + `invite_force_mfa` tinyint(1) NOT NULL DEFAULT 0, + `invite_client_access` text DEFAULT NULL, + `invite_avatar` varchar(200) DEFAULT NULL, + `invite_token_hash` char(64) NOT NULL DEFAULT '', + `invite_vault` varchar(255) NOT NULL DEFAULT '', + `invite_expires_at` datetime NOT NULL, + `invite_sent_at` datetime DEFAULT NULL, + `invite_sent_count` int(11) NOT NULL DEFAULT 0, + `invite_created_by` int(11) NOT NULL DEFAULT 0, + `invite_created_at` datetime NOT NULL DEFAULT current_timestamp(), + `invite_accepted_at` datetime DEFAULT NULL, + `invite_user_id` int(11) NOT NULL DEFAULT 0, + `invite_cancelled_at` datetime DEFAULT NULL, + PRIMARY KEY (`invite_id`), + KEY `invite_email` (`invite_email`), + KEY `invite_user_id` (`invite_user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `user_role_permissions` -- diff --git a/functions.php b/functions.php index 40d0bfa34..81134c403 100644 --- a/functions.php +++ b/functions.php @@ -97,6 +97,7 @@ require_once __DIR__ . '/functions/accounts.php'; require_once __DIR__ . '/functions/reconcile.php'; require_once __DIR__ . '/functions/portal.php'; require_once __DIR__ . '/functions/portal_invite.php'; +require_once __DIR__ . '/functions/user_invite.php'; require_once __DIR__ . '/functions/getting_started.php'; require_once __DIR__ . '/functions/settings.php'; require_once __DIR__ . '/functions/mail_queue.php'; diff --git a/functions/app.php b/functions/app.php index f4cb39308..501ed3574 100644 --- a/functions/app.php +++ b/functions/app.php @@ -821,7 +821,8 @@ function getProductsForAutocomplete($mysqli, $client_id = 0): string * nobody inside the app can raise their own limit. * * An active agent is user_type 1, enabled and not archived. Disabled and archived - * users do not use a seat. + * users do not use a seat. An invite waiting and in date does + * (userInvitesHoldingSeats(), functions/user_invite.php) - it is a seat promised. */ function getAgentUserLimit(): int { @@ -839,16 +840,25 @@ function getActiveAgentUserCount(): int return intval($row[0]); } -function agentUserLimitReached(): bool +/** Seats taken: active agents, and invites waiting in date (leaving out $except_invite_id). */ +function getAgentSeatsTaken(int $except_invite_id = 0): int +{ + return getActiveAgentUserCount() + userInvitesHoldingSeats($except_invite_id); +} + +function agentUserLimitReached(int $except_invite_id = 0): bool { $limit = getAgentUserLimit(); - return $limit > 0 && getActiveAgentUserCount() >= $limit; + return $limit > 0 && getAgentSeatsTaken($except_invite_id) >= $limit; } function agentUserLimitMessage(): string { $limit = getAgentUserLimit(); + $invites = userInvitesHoldingSeats(); - return "Agent user limit reached - this installation allows $limit active agent " . ($limit == 1 ? "user" : "users") . ". Archive or disable a user first, or contact your provider to add more."; + return "Agent user limit reached - this installation allows $limit active agent " . ($limit == 1 ? "user" : "users") + . ($invites ? ", and $invites invite" . ($invites === 1 ? ' is' : 's are') . " waiting for one" : '') + . ". Archive or disable a user" . ($invites ? ' or cancel an invite' : '') . " first, or contact your provider to add more."; } diff --git a/functions/email_preview.php b/functions/email_preview.php index d58c61162..7e09c847d 100644 --- a/functions/email_preview.php +++ b/functions/email_preview.php @@ -380,12 +380,9 @@ function emailPreviewCatalog() { 'run' => function ($s) { calendarSendInvites(calendarEventRow($s['event']), 'cancel'); }], + ] + emailPreviewUserInviteCatalog() + [ // ---- Your team's accounts - 'user_new' => ['group' => 'Your team\'s accounts', 'label' => 'New account', 'to' => 'The new agent', 'when' => 'An admin adds an agent and ticks Email them their details', - 'run' => function ($s) { - userMailNewAccount($s['agent_name'], $s['agent_email'], 'Sample-Passw0rd-8', true); - }], 'user_changed' => ['group' => 'Your team\'s accounts', 'label' => 'Account changed', 'to' => 'The agent', 'when' => 'Their password, email or MFA changes', 'run' => function ($s) { userNotifyChange($s['agent_name'], $s['agent_email'], 'your password was changed'); @@ -542,6 +539,25 @@ function emailPreviewInviteCatalog() { ]; } +/* The agent invite for the catalog - once the 3.5.1 update has run. Riley, invited by you as a technician, MFA required. */ +function emailPreviewUserInviteCatalog() { + if (!userInvitesReady()) { + return []; + } + return [ + 'user_invite' => ['group' => 'Your team\'s accounts', 'label' => 'Invite to join', 'to' => 'Who is invited', 'when' => 'An admin invites someone from New User or Get started', + 'run' => function ($s) { + global $mysqli, $session_user_id; + [$name, $email] = EMAIL_PREVIEW_PEOPLE['newcomer']; + $role_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT role_id FROM user_roles WHERE role_archived_at IS NULL AND role_is_admin = 0 + ORDER BY role_name = 'Technician' DESC, role_id LIMIT 1"))[0] ?? 0); + mysqli_query($mysqli, "INSERT INTO user_invites SET invite_name = '" . escapeSql($name) . "', invite_email = '" . escapeSql($email) . "', invite_role_id = $role_id, + invite_force_mfa = 1, invite_expires_at = NOW() + INTERVAL " . USER_INVITE_LINK_DAYS . " DAY, invite_created_by = " . intval($session_user_id)); + userInviteMail(intval(mysqli_insert_id($mysqli)), bin2hex(random_bytes(24))); + }], + ]; +} + /* * Send yourself a copy of a preview - to $to (raw), its subject marked * [Preview], from the mailbox it would come from, without its calendar file diff --git a/functions/getting_started.php b/functions/getting_started.php index 8bea8a37a..7efba9c7a 100644 --- a/functions/getting_started.php +++ b/functions/getting_started.php @@ -161,8 +161,9 @@ function gettingStartedSteps() { break; case 'team': $agents = $count("SELECT COUNT(*) FROM users WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL"); - $done = $agents > 1; - $status = $agents === 1 ? 'Just you' : "$agents people"; + $invited = userInvitesHoldingSeats(); + $done = $agents > 1 || $invited > 0; + $status = ($agents === 1 ? 'Just you' : "$agents people") . ($invited ? ", $invited invited" : ''); break; case 'content': $templates = $count("SELECT (SELECT COUNT(*) FROM ticket_templates) + (SELECT COUNT(*) FROM canned_responses) + (SELECT COUNT(*) FROM document_templates)"); diff --git a/functions/roles.php b/functions/roles.php index 9e71527dd..66285f2a3 100644 --- a/functions/roles.php +++ b/functions/roles.php @@ -220,7 +220,7 @@ function roleAdminBlocker(array $row) { return ''; } -/* Why a role can't be archived - '' when it can. Not while active agents have it, nor your own. */ +/* Why a role can't be archived - '' when it can. Not while active agents have it or invites waiting are for it, nor your own. */ function roleArchiveBlocker(array $row) { global $session_user_role; if (intval($row['role_id']) === intval($session_user_role)) { @@ -230,6 +230,9 @@ function roleArchiveBlocker(array $row) { $n = intval($row['agents']); return $n . ' agent' . ($n === 1 ? ' has' : 's have') . ' it - give them another role first.'; } + if (($n = userInvitesWithRole($row['role_id'])) > 0) { + return $n . ' invite' . ($n === 1 ? ' is' : 's are') . ' waiting with it - cancel ' . ($n === 1 ? 'it' : 'them') . ' in Users, Invited, and invite again with another role.'; + } return ''; } diff --git a/functions/user.php b/functions/user.php index e5480e9a4..e17c799fa 100644 --- a/functions/user.php +++ b/functions/user.php @@ -223,25 +223,8 @@ function userResetMfa($user_id) { function userSaveAvatar($user_id, $file, &$error = '') { global $mysqli; $user_id = intval($user_id); - if (empty($file['tmp_name']) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { - return false; - } - $new_name = checkFileUpload($file, ['jpg', 'jpeg', 'gif', 'png', 'webp']); - $image = $new_name ? @getimagesize($file['tmp_name']) : false; - if (!$new_name || !$image || !in_array($image[2], [IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { - $error = 'The avatar has to be a JPG, PNG, GIF or WebP picture.'; - return false; - } - if ($file['size'] > 5 * 1024 * 1024) { - $error = 'The avatar can be up to 5 MB.'; - return false; - } - $dir = dirname(__DIR__) . "/uploads/users/$user_id"; - if (!is_dir($dir)) { - mkdir($dir, 0755, true); - } - if (!move_uploaded_file($file['tmp_name'], "$dir/$new_name")) { - $error = 'The avatar could not be saved.'; + $new_name = userAvatarStore($file, dirname(__DIR__) . "/uploads/users/$user_id", $error); + if ($new_name === '') { return false; } userRemoveAvatar($user_id); @@ -249,6 +232,35 @@ function userSaveAvatar($user_id, $file, &$error = '') { return true; } +/** + * Put an uploaded picture in $dir - a real image (checked, not trusted by + * its name), up to 5 MB. Its new file name, or '' with $error ('' too when + * nothing was uploaded). + */ +function userAvatarStore($file, $dir, &$error = '') { + if (empty($file['tmp_name']) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return ''; + } + $new_name = checkFileUpload($file, ['jpg', 'jpeg', 'gif', 'png', 'webp']); + $image = $new_name ? @getimagesize($file['tmp_name']) : false; + if (!$new_name || !$image || !in_array($image[2], [IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_WEBP], true)) { + $error = 'The avatar has to be a JPG, PNG, GIF or WebP picture.'; + return ''; + } + if ($file['size'] > 5 * 1024 * 1024) { + $error = 'The avatar can be up to 5 MB.'; + return ''; + } + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + if (!move_uploaded_file($file['tmp_name'], "$dir/$new_name")) { + $error = 'The avatar could not be saved.'; + return ''; + } + return $new_name; +} + /** Remove an agent's avatar - the file and the reference to it. */ function userRemoveAvatar($user_id) { global $mysqli; @@ -500,25 +512,6 @@ function userMailAppName() { return trim((string) ($config_app_name ?? '')) !== '' ? (string) $config_app_name : 'ITFlow'; } -/* - * A new agent's sign in details - their email, the password as it was set - * (theirs to change) and where to sign in. True when queued. - */ -function userMailNewAccount($name, $email, $password, $force_mfa) { - $company_name = (string) (billingCompany()['company_name'] ?? ''); - $app = userMailAppName(); - return (bool) emailQueue([['email' => $email, 'name' => $name, 'footnote' => "You get this because an account was set up for you in $company_name's $app."]], - "Your new $company_name $app account", [ - 'kind' => 'Your account', 'tone' => 'primary', 'preview' => "Your $app sign in details", - 'title' => "Your $app account is ready", - 'intro' => ["An $app account has been set up for you. Sign in with these details, then change your password." - . ($force_mfa ? ' Have an authenticator app ready - you will set up multi-factor authentication the first time.' : '')], - 'summary' => ['Email' => (string) $email, 'Password' => (string) $password], - 'button' => ['label' => 'Sign in', 'url' => userSignInUrl()], - 'after' => ['This email has your password in it - delete it once you have changed it.'], - ]); -} - /** An agent's own email, sent from the system - their account was changed ($what, raw). Queued; nothing when mail is not set up. */ function userNotifyChange($name, $email, $what) { $app = userMailAppName(); diff --git a/functions/user_invite.php b/functions/user_invite.php new file mode 100644 index 000000000..6c0ba3cdb --- /dev/null +++ b/functions/user_invite.php @@ -0,0 +1,583 @@ + Users) or Get started > Your + * team: who they are, their role, whether MFA is required, the clients they + * see and a picture are kept with the invite, and they get an email with a + * link (or the admin passes the link on). The link opens /join.php, where + * they choose their own password - nobody else ever knows it - and only + * then are they an agent. Until it is used, cancelled or runs out, an invite + * holds a seat under the agent user limit, but signs nobody in. + * + * The link carries a token of which only the SHA-256 is kept. It runs out + * after USER_INVITE_LINK_DAYS. Sending it again or getting the link makes a + * new token, so a link given out before stops working; cancelling stops it. + * + * Their copy of the credential vault: an agent's password unlocks their own + * copy of the vault's key. With an invite nobody has their password yet, so + * the admin's session seals the key under the link's token (invite_vault, + * the way it is sealed under a password), and accepting opens it with the + * token in the link and seals it again under the password they chose. Only + * the token's hash is kept, so the database alone can't open it. It is + * sealed again with every new token, and wiped once the invite is accepted, + * cancelled or has run out. + */ + +const USER_INVITE_LINK_DAYS = 7; + +/** Has the 3.5.1 update run? Until it has, there are no invites and New User sets a password. */ +function userInvitesReady() { + global $mysqli; + static $ready = null; + if ($ready === null) { + if (defined('CURRENT_DATABASE_VERSION')) { + $version = CURRENT_DATABASE_VERSION; + } else { + $result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1"); + $version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0'; + } + $ready = version_compare($version, '3.5.1', '>='); + } + return $ready; +} + +/** Can an invite be emailed - mail set up, with an address to send from? */ +function userInviteMailReady() { + return emailCapturing() || (emailReady() && filter_var(emailFrom()['email'], FILTER_VALIDATE_EMAIL)); +} + +/* + * ---------- an invite ---------- + */ + +/** An invite, with its role and who sent it - or null. */ +function userInviteRow($invite_id) { + global $mysqli; + $invite_id = intval($invite_id); + if (!$invite_id || !userInvitesReady()) { + return null; + } + return mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_invites.*, role_name, role_is_admin, role_archived_at, + inviter.user_name AS invite_by_name, inviter.user_status AS invite_by_status, inviter.user_archived_at AS invite_by_archived_at + FROM user_invites + LEFT JOIN user_roles ON role_id = invite_role_id + LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0 + WHERE invite_id = $invite_id LIMIT 1")) ?: null; +} + +/** The invite waiting for an address - in date or run out, not accepted or cancelled - or null. */ +function userInviteFor($email) { + global $mysqli; + if (!userInvitesReady()) { + return null; + } + $email_sql = escapeSql(trim((string) $email)); + $invite_id = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT invite_id FROM user_invites + WHERE invite_email = '$email_sql' AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL + ORDER BY invite_id DESC LIMIT 1"))[0] ?? 0); + return $invite_id ? userInviteRow($invite_id) : null; +} + +/** Where an invite stands: pending, expired, accepted or cancelled. */ +function userInviteState(array $row) { + if (!empty($row['invite_accepted_at'])) { + return 'accepted'; + } + if (!empty($row['invite_cancelled_at'])) { + return 'cancelled'; + } + if (strtotime((string) $row['invite_expires_at']) <= time()) { + return 'expired'; + } + return 'pending'; +} + +/** How a waiting invite stands, as a badge: Waiting (and how long it has left) or Ran out. ['label', 'title', 'tone'] */ +function userInviteStateLabel(array $row) { + if (userInviteState($row) === 'expired') { + return ['label' => 'Ran out', 'title' => 'The link ran out on ' . date('M j, Y', strtotime($row['invite_expires_at'])) . ' - send it again for a new one', 'tone' => 'warning']; + } + $left = strtotime((string) $row['invite_expires_at']) - time(); + $days = intval(floor($left / 86400)); + return ['label' => 'Waiting', 'title' => 'The link works for ' . ($days >= 1 ? $days . ' more day' . ($days === 1 ? '' : 's') : 'less than a day'), 'tone' => 'info']; +} + +/** The client rules an invite gives - ['allow' => [client_id => name], 'deny' => [...]], as userClientAccess(). */ +function userInviteClientAccess(array $row) { + global $mysqli; + $access = ['allow' => [], 'deny' => []]; + $rules = json_decode((string) ($row['invite_client_access'] ?? ''), true); + $ids = []; + foreach (['allow', 'deny'] as $type) { + foreach ((array) ($rules[$type] ?? []) as $client_id) { + if (intval($client_id)) { + $ids[intval($client_id)] = $type; + } + } + } + if ($ids) { + $sql = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients WHERE client_id IN (" . implode(',', array_keys($ids)) . ") ORDER BY client_name"); + while ($client = mysqli_fetch_assoc($sql)) { + $access[$ids[intval($client['client_id'])]][intval($client['client_id'])] = $client['client_name']; + } + } + return $access; +} + +/** Who sent an invite, in words - their name, or '' when they are gone. */ +function userInviteBy(array $row) { + return trim((string) ($row['invite_by_name'] ?? '')); +} + +/** The link that opens the join page for an invite and its token. */ +function userInviteUrl($invite_id, $token) { + global $config_base_url; + return "https://$config_base_url/join.php?" . http_build_query(['invite' => intval($invite_id), 'token' => (string) $token]); +} + +/* + * ---------- seats ---------- + */ + +/** + * Invites in date and waiting - each holds a seat under the agent user + * limit until it is used, cancelled or runs out. Leaving out $except_invite_id. + */ +function userInvitesHoldingSeats($except_invite_id = 0) { + global $mysqli; + if (!userInvitesReady()) { + return 0; + } + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites + WHERE invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND invite_expires_at > NOW() AND invite_id <> " . intval($except_invite_id)))[0]); +} + +/** Invites waiting - in date or run out - with a role. For a role that is to be archived. */ +function userInvitesWithRole($role_id) { + global $mysqli; + if (!userInvitesReady()) { + return 0; + } + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM user_invites + WHERE invite_role_id = " . intval($role_id) . " AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL"))[0]); +} + +/* + * ---------- the vault ---------- + */ + +/** The vault's key from the signed-in admin's session - '' when their session can't open it. */ +function userInviteSessionVaultKey() { + $ciphertext = (string) ($_SESSION['user_encryption_session_ciphertext'] ?? ''); + $iv = base64_decode((string) ($_SESSION['user_encryption_session_iv'] ?? '')); + $key = base64_decode((string) ($_COOKIE['user_encryption_session_key'] ?? '')); + if ($ciphertext === '' || $iv === '' || $key === '') { + return ''; + } + $master = openssl_decrypt($ciphertext, 'aes-128-cbc', $key, 0, $iv); + return is_string($master) ? $master : ''; +} + +/** + * A new link for an invite: its token kept as a SHA-256, running out + * USER_INVITE_LINK_DAYS from now, with the vault's key from the signed-in + * admin's session sealed under it. The token, for the link - or '' when the + * session can't open the vault, and nothing is changed. + */ +function userInviteNewToken($invite_id) { + global $mysqli; + $master = userInviteSessionVaultKey(); + if ($master === '') { + return ''; + } + $token = bin2hex(random_bytes(24)); + $hash = hash('sha256', $token); + $vault = mysqli_real_escape_string($mysqli, setupFirstUserSpecificKey($token, $master)); + mysqli_query($mysqli, "UPDATE user_invites SET invite_token_hash = '$hash', invite_vault = '$vault', invite_expires_at = NOW() + INTERVAL " . USER_INVITE_LINK_DAYS . " DAY + WHERE invite_id = " . intval($invite_id)); + return $token; +} + +/** + * The invite a link is for - its token right - with where it stands + * (userInviteState(), or gone for a link that matches nothing): + * ['row' => row or null, 'state' => ...]. + */ +function userInviteByLink($invite_id, $token) { + $token = is_string($token) ? $token : ''; + if (!preg_match('/^[a-f0-9]{48}$/', $token)) { + return ['row' => null, 'state' => 'gone']; + } + $row = userInviteRow($invite_id); + if (!$row || !hash_equals((string) $row['invite_token_hash'], hash('sha256', $token))) { + return ['row' => null, 'state' => 'gone']; + } + return ['row' => $row, 'state' => userInviteState($row)]; +} + +/* + * ---------- the picture ---------- + */ + +/** Where an invite's picture is kept until they join. */ +function userInviteAvatarDir($invite_id) { + return dirname(__DIR__) . '/uploads/users/invites/' . intval($invite_id); +} + +/** Take an uploaded picture for an invite, in place of any it had. True when set, false with $error when not. */ +function userInviteSaveAvatar($invite_id, $file, &$error = '') { + global $mysqli; + $invite_id = intval($invite_id); + $dir = userInviteAvatarDir($invite_id); + $old = (string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT invite_avatar FROM user_invites WHERE invite_id = $invite_id"))[0] ?? ''); + $new_name = userAvatarStore($file, $dir, $error); + if ($new_name === '') { + return false; + } + if ($old !== '' && basename($old) === $old && is_file("$dir/$old")) { + unlink("$dir/$old"); + } + mysqli_query($mysqli, "UPDATE user_invites SET invite_avatar = '" . escapeSql($new_name) . "' WHERE invite_id = $invite_id"); + return true; +} + +/** Remove an invite's picture, and its folder. */ +function userInviteRemoveAvatar($invite_id) { + global $mysqli; + $invite_id = intval($invite_id); + $dir = userInviteAvatarDir($invite_id); + if (is_dir($dir)) { + foreach (glob("$dir/*") ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + @rmdir($dir); + } + mysqli_query($mysqli, "UPDATE user_invites SET invite_avatar = NULL WHERE invite_id = $invite_id"); +} + +/** An invite's picture, or the initials of the name on it - as userAvatar(). */ +function userInviteAvatar(array $row, $size = '') { + $avatar = (string) ($row['invite_avatar'] ?? ''); + if ($avatar !== '' && basename($avatar) === $avatar) { + return ''; + } + return userAvatar(intval($row['invite_id'] ?? 0), $row['invite_name'] ?? '', '', $size); +} + +/* + * ---------- inviting ---------- + */ + +/** + * Keep an invite for someone, from New User or Get started - $in name and + * email (checked already), role_id, force_mfa and access (the Client access + * radios: client_id => allow / deny / ''). One already waiting for the + * address is changed to this, and keeps its picture unless $avatar has a new + * one. ['invite_id', 'again' => true when one was waiting, 'avatar_error']. + */ +function userInviteSave(array $in, $avatar = null) { + global $mysqli, $session_user_id; + $email = trim((string) $in['email']); + $name_sql = escapeSql(mb_substr(trim((string) $in['name']), 0, 200)); + $email_sql = escapeSql($email); + $role_id = intval($in['role_id']); + $force_mfa = !empty($in['force_mfa']) ? 1 : 0; + $by = intval($session_user_id); + + // The rules for clients that exist + $rules = ['allow' => [], 'deny' => []]; + $wanted = []; + foreach ((array) ($in['access'] ?? []) as $client_id => $type) { + if (($type === 'allow' || $type === 'deny') && intval($client_id)) { + $wanted[intval($client_id)] = $type; + } + } + if ($wanted) { + $sql = mysqli_query($mysqli, "SELECT client_id FROM clients WHERE client_id IN (" . implode(',', array_keys($wanted)) . ") ORDER BY client_id"); + while ($client = mysqli_fetch_row($sql)) { + $rules[$wanted[intval($client[0])]][] = intval($client[0]); + } + } + $access_sql = $rules['allow'] || $rules['deny'] ? "'" . mysqli_real_escape_string($mysqli, json_encode($rules)) . "'" : 'NULL'; + + $open = userInviteFor($email); + if ($open) { + $invite_id = intval($open['invite_id']); + mysqli_query($mysqli, "UPDATE user_invites SET invite_name = '$name_sql', invite_email = '$email_sql', invite_role_id = $role_id, invite_force_mfa = $force_mfa, + invite_client_access = $access_sql, invite_created_by = $by WHERE invite_id = $invite_id"); + } else { + mysqli_query($mysqli, "INSERT INTO user_invites SET invite_name = '$name_sql', invite_email = '$email_sql', invite_role_id = $role_id, invite_force_mfa = $force_mfa, + invite_client_access = $access_sql, invite_expires_at = NOW(), invite_created_by = $by"); + $invite_id = intval(mysqli_insert_id($mysqli)); + } + + $avatar_error = ''; + if (!empty($avatar['tmp_name'])) { + userInviteSaveAvatar($invite_id, $avatar, $avatar_error); + } + return ['invite_id' => $invite_id, 'again' => (bool) $open, 'avatar_error' => $avatar_error]; +} + +/** + * Give an invite a new link - so one given out before stops working - and + * email it, or with $mail false only make it, for the admin to pass on. + * ['url', 'mailed'], or null with $error (HTML-safe) when the link can't be + * made (the admin's session can't open the vault) or the email can't go. + */ +function userInviteSend($invite_id, $mail, &$error = '') { + $error = ''; + $token = userInviteNewToken($invite_id); + if ($token === '') { + $error = 'Your session can\'t open the credential vault, so the invite can\'t carry their copy of it. Sign out and in again, then send it.'; + return null; + } + if ($mail && !userInviteMail($invite_id, $token)) { + $error = 'The invite email could not be queued - check Admin > Mail, or get the link and send it to them yourself.'; + return null; + } + return ['url' => userInviteUrl($invite_id, $token), 'mailed' => (bool) $mail]; +} + +/** + * Show an invite's link once, at the top of Admin > Users, for the admin to + * pass on - it is never kept, so it can't be shown again (getting it again + * makes a new one). + */ +function userInviteShowLink($invite_id, $url) { + $_SESSION['user_invite_link'] = ['invite_id' => intval($invite_id), 'url' => (string) $url]; +} + +/** The link to show once, with its invite - and forget it. ['url', 'row'] or null. */ +function userInviteTakeLink() { + $link = $_SESSION['user_invite_link'] ?? null; + unset($_SESSION['user_invite_link']); + $row = is_array($link) ? userInviteRow($link['invite_id'] ?? 0) : null; + if (!$row || userInviteState($row) !== 'pending') { + return null; + } + return ['url' => (string) $link['url'], 'row' => $row]; +} + +/** + * Email an invite its link - $token from userInviteNewToken(). Says who sent + * it, the role, and that they choose their own password. Notes when it went. + * False when it can't go. + */ +function userInviteMail($invite_id, $token) { + global $mysqli; + $row = userInviteRow($invite_id); + if (!$row || !userInviteMailReady() || !filter_var($row['invite_email'], FILTER_VALIDATE_EMAIL)) { + return false; + } + $company_name = trim((string) (billingCompany()['company_name'] ?? '')); + $app = userMailAppName(); + $where = $company_name !== '' ? "$company_name on $app" : $app; + $by = userInviteBy($row); + $role = trim((string) ($row['role_name'] ?? '')); + $name = trim((string) $row['invite_name']); + + $intro = [($by !== '' ? "$by has invited you" : 'You are invited') . " to join $where" . ($role !== '' ? ", as $role" : '') . '.', + 'Accept with the button below - you choose your own password there, and you are ready to sign in. It takes a minute.']; + if (intval($row['invite_force_mfa'])) { + $intro[] = 'Have an authenticator app on your phone ready: you set up multi-factor authentication the first time you sign in.'; + } + $summary = ['Invited' => (string) $row['invite_email']]; + if ($role !== '') { + $summary['Role'] = $role; + } + $sent = (bool) emailQueue([['email' => (string) $row['invite_email'], 'name' => $name, 'greet' => $name !== '' ? $name : null, + 'footnote' => 'You received this because ' . ($by !== '' ? $by : 'an administrator') . " invited this address to $where."]], + ($by !== '' ? "$by invited you" : 'You are invited') . " to $where", [ + 'kind' => 'Your account', 'tone' => 'primary', 'preview' => 'Choose your password and you are in', + 'title' => "Join $where", + 'intro' => $intro, + 'summary' => $summary, + 'button' => ['label' => 'Accept the invite', 'url' => userInviteUrl($row['invite_id'], $token)], + 'after' => ['The invite works for ' . USER_INVITE_LINK_DAYS . ' days. If you were not expecting it, you can ignore this email - nothing is set up unless the link is used.'], + ]); + if ($sent && !emailCapturing()) { + mysqli_query($mysqli, "UPDATE user_invites SET invite_sent_at = NOW(), invite_sent_count = invite_sent_count + 1 WHERE invite_id = " . intval($row['invite_id'])); + } + return $sent; +} + +/** Cancel an invite that is waiting - its link stops working, and what it kept goes. False when it isn't waiting. */ +function userInviteCancel($invite_id) { + global $mysqli; + $invite_id = intval($invite_id); + mysqli_query($mysqli, "UPDATE user_invites SET invite_cancelled_at = NOW(), invite_vault = '' + WHERE invite_id = $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL"); + if (mysqli_affected_rows($mysqli) !== 1) { + return false; + } + userInviteRemoveAvatar($invite_id); + return true; +} + +/* + * ---------- accepting ---------- + */ + +/** + * Accept an invite - $invite from userInviteByLink() (pending), $token the + * link's, $in what they put in: name, password and confirm. Makes them an + * agent with what the invite set - role, MFA, client access, picture - and + * their copy of the vault under their password, spends the link and tells + * whoever sent it. The new agent's user_id, or 0 with $error (plain text). + */ +function userInviteAccept(array $invite, $token, array $in, &$error = '') { + global $mysqli, $session_user_id; + $error = ''; + $invite_id = intval($invite['invite_id']); + $email = trim((string) $invite['invite_email']); + $by = userInviteBy($invite); + $ask = $by !== '' ? "ask $by" : 'ask your administrator'; + + $name = trim(preg_replace('/\s+/', ' ', strip_tags(mb_substr((string) ($in['name'] ?? ''), 0, 200)))); + $password = (string) ($in['password'] ?? ''); + if ($name === '') { + $error = 'Put in your name.'; + return 0; + } + if (($problem = userPasswordProblem($password)) !== '') { + $error = $problem; + return 0; + } + if ($password !== (string) ($in['confirm'] ?? '')) { + $error = 'The two passwords are not the same - type it again in both.'; + return 0; + } + + // Still theirs to take: the address free, the role there, a seat + $taken = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_type FROM users WHERE user_email = '" . escapeSql($email) . "' AND user_archived_at IS NULL LIMIT 1")); + if ($taken) { + $error = intval($taken['user_type']) === 1 + ? 'This address signs in already - sign in with it. Forgotten the password? ' . ucfirst($ask) . ' to set a new one.' + : 'This address signs in to the client portal, so it can\'t be an agent\'s too - ' . $ask . ' about it.'; + return 0; + } + if (($invite['role_name'] ?? null) === null) { + $error = 'The role you were invited with has been removed - ' . $ask . ' to invite you again.'; + return 0; + } + $limit = getAgentUserLimit(); + if ($limit > 0 && getActiveAgentUserCount() + userInvitesHoldingSeats($invite_id) >= $limit) { + $error = 'Every agent seat is taken - ' . $ask . ' to free one, then open this link again.'; + return 0; + } + $master = decryptUserSpecificKey((string) $invite['invite_vault'], (string) $token); + if (!is_string($master) || $master === '') { + $error = 'This invite can\'t be used - ' . $ask . ' to send it again.'; + return 0; + } + + // Spend the link - one press, or one of two at once, gets through + mysqli_query($mysqli, "UPDATE user_invites SET invite_accepted_at = NOW(), invite_vault = '' + WHERE invite_id = $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL AND invite_expires_at > NOW() + AND invite_token_hash = '" . mysqli_real_escape_string($mysqli, (string) $invite['invite_token_hash']) . "'"); + if (mysqli_affected_rows($mysqli) !== 1) { + $error = 'That link has been used already.'; + return 0; + } + + // The agent, their copy of the vault under the password they chose + $name_sql = escapeSql($name); + $hash = mysqli_real_escape_string($mysqli, password_hash($password, PASSWORD_DEFAULT)); + $vault = mysqli_real_escape_string($mysqli, setupFirstUserSpecificKey($password, $master)); + $role_id = intval($invite['invite_role_id']); + mysqli_query($mysqli, "INSERT INTO users SET user_name = '$name_sql', user_email = '" . escapeSql($email) . "', user_password = '$hash', user_specific_encryption_ciphertext = '$vault', + user_auth_method = 'local', user_type = 1, user_status = 1, user_role_id = $role_id"); + $user_id = intval(mysqli_insert_id($mysqli)); + mysqli_query($mysqli, "INSERT INTO user_settings SET user_id = $user_id, user_config_force_mfa = " . (intval($invite['invite_force_mfa']) ? 1 : 0)); + if (!intval($invite['role_is_admin'])) { + $access = userInviteClientAccess($invite); + userSetClientAccess($user_id, array_fill_keys(array_keys($access['allow']), 'allow') + array_fill_keys(array_keys($access['deny']), 'deny')); + } + + // Their picture, from the invite to their own folder + $avatar = (string) ($invite['invite_avatar'] ?? ''); + $from = userInviteAvatarDir($invite_id) . "/$avatar"; + if ($avatar !== '' && basename($avatar) === $avatar && is_file($from)) { + $dir = dirname(__DIR__) . "/uploads/users/$user_id"; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + if (rename($from, "$dir/$avatar")) { + mysqli_query($mysqli, "UPDATE users SET user_avatar = '" . escapeSql($avatar) . "' WHERE user_id = $user_id"); + } + } + userInviteRemoveAvatar($invite_id); + + // The invite is theirs now; any other waiting for the address is done with + mysqli_query($mysqli, "UPDATE user_invites SET invite_user_id = $user_id WHERE invite_id = $invite_id"); + mysqli_query($mysqli, "UPDATE user_invites SET invite_cancelled_at = NOW(), invite_vault = '' + WHERE invite_email = '" . escapeSql($email) . "' AND invite_id <> $invite_id AND invite_accepted_at IS NULL AND invite_cancelled_at IS NULL"); + + // In their name - it was them + $session_user_id = $user_id; + $role = (string) $invite['role_name']; + logAudit("User", "Create", escapeSql("$name ($email) accepted " . ($by !== '' ? "$by's" : 'an') . " invite and joined as $role"), 0, $user_id); + if (intval($invite['invite_created_by']) && intval($invite['invite_by_status'] ?? 0) === 1 && empty($invite['invite_by_archived_at'])) { + mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = 'User', notification = '" . escapeSql(mb_substr("$name accepted your invite and joined as $role", 0, 1000)) . "', + notification_action = '" . escapeSql('/admin/users.php?q=' . rawurlencode($email)) . "', notification_entity_id = $user_id, notification_user_id = " . intval($invite['invite_created_by'])); + } + return $user_id; +} + +/** How an agent joined, when it was by an invite - ['by', 'at'] or null. */ +function userInviteJoined($user_id) { + global $mysqli; + if (!userInvitesReady() || !intval($user_id)) { + return null; + } + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT invite_accepted_at, inviter.user_name AS invite_by_name FROM user_invites + LEFT JOIN users inviter ON inviter.user_id = invite_created_by AND invite_created_by > 0 + WHERE invite_user_id = " . intval($user_id) . " AND invite_accepted_at IS NOT NULL ORDER BY invite_accepted_at DESC LIMIT 1")); + return $row ? ['by' => trim((string) $row['invite_by_name']), 'at' => $row['invite_accepted_at']] : null; +} + +/** Invites waiting - in date or run out - newest first, with their role. For Get started. */ +function userInvitesWaiting() { + global $mysqli; + $rows = []; + if (!userInvitesReady()) { + return $rows; + } + $sql = mysqli_query($mysqli, "SELECT user_invites.*, role_name, role_is_admin FROM user_invites LEFT JOIN user_roles ON role_id = invite_role_id + WHERE invite_accepted_at IS NULL AND invite_cancelled_at IS NULL ORDER BY invite_created_at DESC, invite_id DESC"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row; + } + return $rows; +} + +/* + * ---------- nightly ---------- + */ + +/** + * Tidy the invites: one that has run out loses what it kept of the vault + * (sending it again seals it afresh), and cancelled ones, and ones that ran + * out, go after 90 days with their picture. How many went. + */ +function userInvitesNightly() { + global $mysqli; + if (!userInvitesReady()) { + return 0; + } + mysqli_query($mysqli, "UPDATE user_invites SET invite_vault = '' WHERE invite_vault <> '' AND invite_accepted_at IS NULL AND invite_expires_at < NOW()"); + $gone = 0; + $sql = mysqli_query($mysqli, "SELECT invite_id FROM user_invites WHERE invite_accepted_at IS NULL AND ( + (invite_cancelled_at IS NOT NULL AND invite_cancelled_at < NOW() - INTERVAL 90 DAY) + OR (invite_cancelled_at IS NULL AND invite_expires_at < NOW() - INTERVAL 90 DAY))"); + while ($row = mysqli_fetch_row($sql)) { + userInviteRemoveAvatar($row[0]); + mysqli_query($mysqli, "DELETE FROM user_invites WHERE invite_id = " . intval($row[0])); + $gone += max(0, intval(mysqli_affected_rows($mysqli))); + } + return $gone; +} diff --git a/join.php b/join.php new file mode 100644 index 000000000..7062ec338 --- /dev/null +++ b/join.php @@ -0,0 +1,191 @@ + Your team): ?invite=&token=. The person checks their name and + * chooses a password - nobody else ever knows it - and is made an agent with + * the role, MFA and client access the invite set (userInviteAccept(), + * functions/user_invite.php). Then they sign in on the usual page. A link + * that has run out, been used or cancelled says so, and what to do instead. + * + * The client portal's own invites open client/join.php. + */ + +header("Content-Security-Policy: default-src 'self'; img-src 'self' data:"); +header("X-Frame-Options: DENY"); +header("Referrer-Policy: no-referrer"); + +if (!file_exists('config.php')) { + header("Location: /setup"); + exit(); +} + +require_once 'config.php'; +require_once 'functions.php'; +require_once 'includes/load_global_settings.php'; + +if (!isset($config_enable_setup) || $config_enable_setup == 1 || !userInvitesReady()) { + header("Location: /login.php"); + exit(); +} + +// A password is chosen here - not over plain HTTP, as with signing in +if ( + $config_https_only + && (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') + && (!isset($_SERVER['HTTP_X_FORWARDED_PROTO']) || $_SERVER['HTTP_X_FORWARDED_PROTO'] !== 'https') +) { + header("Location: /login.php"); + exit(); +} + +require_once __DIR__ . "/includes/session_init.php"; +require_once "includes/inc_set_timezone.php"; + +// Nobody is signed in here - the history notes where it came from +$session_ip = escapeSql(getIP()); +$session_user_agent = escapeSql($_SERVER['HTTP_USER_AGENT'] ?? ''); +$session_user_id = 0; + +$company = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_logo FROM companies WHERE company_id = 1")); +$company_name = (string) ($company['company_name'] ?? ''); +$app = userMailAppName(); +$where = $company_name !== '' ? "$company_name on $app" : $app; + +$invite_id = intval($_POST['invite'] ?? ($_GET['invite'] ?? 0)); +$token = $_POST['token'] ?? ($_GET['token'] ?? ''); +$token = is_string($token) ? $token : ''; +$link = userInviteByLink($invite_id, $token); +$invite = $link['row']; +$error = ''; +$in_name = $invite ? (string) $invite['invite_name'] : ''; + +// Where they sign in - with the login key when one is required, for someone with a real invite +$sign_in = '/login.php'; +if ($invite && !empty($config_login_key_required)) { + $sign_in .= '?key=' . rawurlencode((string) $config_login_key_secret); +} + +if ($invite && $link['state'] === 'pending' && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['agent_join'])) { + $in_name = (string) ($_POST['name'] ?? ''); + $user_id = userInviteAccept($invite, $token, [ + 'name' => $in_name, + 'password' => (string) ($_POST['new_password'] ?? ''), + 'confirm' => (string) ($_POST['confirm_password'] ?? ''), + ], $error); + if ($user_id) { + $first = strtok(trim(preg_replace('/\s+/', ' ', strip_tags($in_name))), ' '); + $_SESSION['login_message'] = "Welcome, $first - your account is ready. Sign in with " . $invite['invite_email'] . ' and your new password.' + . (intval($invite['invite_force_mfa']) ? ' Have your authenticator app ready - you set up multi-factor authentication as you sign in.' : ''); + $_SESSION['login_message_type'] = 'success'; + header("Location: $sign_in"); + exit(); + } + // Used or cancelled in the meantime - say so rather than show the form again + $link = userInviteByLink($invite_id, $token); + $invite = $link['row']; +} + +$by = $invite ? userInviteBy($invite) : ''; +$ask = $by !== '' ? "Ask $by to send it again." : 'Ask your administrator to send it again.'; +$notes = [ + 'gone' => ["That link doesn't work", 'It may have been copied only in part - or a newer invite replaced it, so use the link in the latest one.'], + 'expired' => ['That invite has run out', 'Invites work for ' . USER_INVITE_LINK_DAYS . " days. $ask"], + 'accepted' => ["You've joined already", 'Sign in with ' . ($invite['invite_email'] ?? 'your email address') . ' and the password you chose.'], + 'cancelled' => ['That invite was cancelled', $by !== '' ? "Ask $by about it." : 'Ask your administrator about it.'], +]; +$state = $link['state']; +$title = $state === 'pending' ? "Join $where" : $notes[$state][0]; +?> + + + + + <?= escapeHtml($company_name !== '' ? $company_name : $app) ?> | <?= escapeHtml($title) ?> + + + + + + + + + + + + + + + + +