From 88c1471335a30c27015c701ee6426338b80ce49d Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 20 Oct 2026 11:25:10 -0400 Subject: [PATCH] Setup wizard stage 2: check the account, company and region steps in the browser and on the server with errors beside each field and typed values kept, confirm the password and preview the avatar and logo, give the company phone its country code and save the website without its scheme, start the region on the country's currency and language, the browser's locale and timezone with a sample of money and dates, keep spaces at either end of a password, and only accept real pictures and listed languages, currencies and timezones --- CHANGELOG.md | 8 ++ css/itflow_custom.css | 50 +++++++ js/setup.js | 264 +++++++++++++++++++++++++++++++++++ setup/index.php | 231 ++++++++++++++---------------- setup/setup_forms.php | 173 +++++++++++++++++++++++ setup/steps/company.php | 97 ++++++------- setup/steps/localization.php | 67 +++++++-- setup/steps/user.php | 52 +++++-- 8 files changed, 742 insertions(+), 200 deletions(-) create mode 100644 js/setup.js create mode 100644 setup/setup_forms.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2436c7b94..85a4953f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1015,6 +1015,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Checks splits what ITFlow needs from what it works better with. A required check that fails - the PHP version, an extension, a folder it can't write to - says how to fix it and holds setup until it passes, and Check again looks again. Recommended: HTTPS with a valid certificate, a domain name rather than an address, uploads of 500 MB for restoring a backup, zip, and git for one-click updates. - Database: a mistake shows on the form, saying why it couldn't connect, with what you typed kept apart from the password. A database that already has tables in it is turned away rather than written over. ITFlow's tables are made before config.php is written, and if config.php can't be written, setup shows what to put in it, with a copy button. A config.php made by hand to an empty database gets a Create the tables button. - Each step after the database waits for the one before it, and says so. + - Your account: the password is typed twice, each with its eye, and the avatar shows once picked. A missing name, an email that isn't one, a short password, passwords that differ or a file that isn't a picture is said beside the field before anything is sent - and by the server too, with the name and email kept. + - Company: the phone takes its country code from the country picked, as in Admin > Settings > Company, and the logo shows once picked. A mistake comes back with everything typed kept, and the website is saved without its http:// and trailing slash. + - Region starts on the currency of the company's country, a language of that country, your browser's where there is one (French (Canada) for a French browser in Canada, English (Canada) for others) and your browser's timezone - or the country's, when it has only one - and shows money and a date as they will look. ### Bug Fixes @@ -1148,6 +1151,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Setup: Finish could land back on setup rather than the sign-in page, while PHP's opcache still held the config.php from a moment before. - Setup: the show-password eye and copy buttons did nothing - the page's script stopped at the editor, which setup doesn't load. - Setup: on a phone the wizard was squeezed beside its menu, a few letters to a line. +- Setup: a password with a space at the start or end could not sign in - setup saved it without them. +- Setup: the account, company and region steps saved whatever they were sent - no name, an email that isn't one, a language, currency or timezone not on the list - and an avatar or logo was taken on its name alone. +- Setup: the company's phone was saved without its country code. ### Developer Updates @@ -1703,6 +1709,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `setup/setup_database.php`: `setupConnect()` catches `mysqli_sql_exception` and says why; `setupTables()`; `setupImportSchema()` runs `db.sql` a statement at a time and drops what it made if one fails; `setupConfigContents()` writes the values with `var_export()`; `setupClose()` appends `$config_enable_setup = 0` and `setupConfigChanged()` drops opcache's copy of `config.php`. - `config.php` is included inside a try/catch, its error in `$config_error`. Setup has its own session (`itflow_setup`) and `setupFlash($message, $type)`. - New POST `create_tables`. `add_user` needs `$database_ready`, `add_company_settings` `$install_is_live`, `add_localization_settings` `$company_exists` and `add_telemetry` `$localization_done` - each otherwise goes to `?$resume_step`. + - `setup/setup_forms.php`: `setupUserForm()`, `setupCompanyForm()` and `setupRegionForm()` each return the values and the errors by field, drawn with `setupFieldInvalid()` and `setupFieldError()` (`data-field-error`); `setupImageProblem()` checks a picture with `getimagesize()`; Region starts on `setupSuggestCurrency()` (intl's currency for the country), `setupSuggestLocale()` (the country and `Accept-Language`) and `setupSuggestTimezone()`. The avatar is saved with `userSaveAvatar()`, and the company's `company_phone_country_code` is set. + - `js/setup.js`: `form[data-setup-form]` checked before it is sent, `data-setup-same-as`, `data-setup-image` with its `data-setup-preview`, `data-setup-browser-timezone`, the Region sample, and each setup form sent once. The company step loads intl-tel-input and `js/phone_inputs.js`. - `.itflow-setup*` styles in `css/itflow_custom.css`. - `js/app.js`: the four `tinymce.init()` calls go through `itflowTinyMce`, which does nothing where TinyMCE isn't loaded. diff --git a/css/itflow_custom.css b/css/itflow_custom.css index 1c072c8e0..93b932a4d 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -8000,6 +8000,56 @@ a.itflow-portal-request:focus-visible { .itflow-setup-checks .list-group-item { background: transparent; } +/* The picture picked for the avatar or logo, before it is sent */ +.itflow-setup-preview { + flex: 0 0 auto; + width: 3.5rem; + height: 3.5rem; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed var(--bs-border-color); + border-radius: .5rem; + background: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + font-size: 1.25rem; + overflow: hidden; +} +.itflow-setup-preview.is-round { + border-radius: 50%; +} +.itflow-setup-preview.has-image { + border-style: solid; +} +.itflow-setup-preview.has-image > i { + display: none; +} +.itflow-setup-preview > img { + width: 100%; + height: 100%; + object-fit: cover; +} +.itflow-setup-preview:not(.is-round) > img { + object-fit: contain; + padding: .2rem; +} +/* Region - money and a date as they will look */ +.itflow-setup-sample { + display: flex; + flex-wrap: wrap; + gap: .35rem 1rem; + padding: .6rem .9rem; + border-radius: .5rem; + background: var(--bs-tertiary-bg); + font-size: .9rem; +} +.itflow-setup-sample[hidden] { + display: none; +} +.itflow-setup-sample > span:not(:first-child) { + font-weight: 600; + font-variant-numeric: tabular-nums; +} .itflow-setup-footer { text-align: center; font-size: .8rem; diff --git a/js/setup.js b/js/setup.js new file mode 100644 index 000000000..1f79df9d6 --- /dev/null +++ b/js/setup.js @@ -0,0 +1,264 @@ +/* + * The setup wizard (setup/index.php, setup/steps/) - what its forms do in the + * browser before they are sent. The server checks everything again + * (setup/setup_forms.php); this says so sooner, beside the field. + * + * form[data-setup-form] checked on submit - each field that isn't + * right says why under it, and the first gets + * the focus + * [data-setup-same-as=id] has to match that field (password again) + * [data-setup-image=kinds] a picture of those kinds, up to + * data-setup-image-max bytes, shown in + * [data-setup-preview=its id] + * [data-setup-browser-timezone] starts on the browser's timezone + * form[data-setup-region] shows money and a date as they will look + * + * And every setup form shows it is working once sent, and is sent once. + */ +(function () { + 'use strict'; + + function selectValue(select, value) { + if (select.itflowSelect) { + select.itflowSelect.setValue(value); + } else { + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + } + + // Where a field says what is wrong with it - made the first time, after its input group + function feedbackFor(field) { + var anchor = field.closest('.input-group') || field; + var next = anchor.nextElementSibling; + if (next && next.matches('[data-setup-feedback]')) { + return next; + } + var feedback = document.createElement('div'); + feedback.className = 'invalid-feedback'; + feedback.setAttribute('data-setup-feedback', field.id || field.name); + anchor.insertAdjacentElement('afterend', feedback); + return feedback; + } + + // What the server said about a field, under it on the page as it came back + function serverErrorFor(field) { + var col = field.closest('[class*="col"]'); + return col ? col.querySelector('[data-field-error="' + field.name + '"]') : null; + } + + function showField(field, message) { + field.classList.toggle('is-invalid', message !== ''); + var feedback = feedbackFor(field); + feedback.textContent = message; + feedback.classList.toggle('d-block', message !== ''); + var server = serverErrorFor(field); + if (server && message === '') { + server.classList.remove('d-block'); + server.hidden = true; + } + } + + function checkSameAs(field) { + var other = document.getElementById(field.dataset.setupSameAs); + field.setCustomValidity(other && field.value !== '' && field.value !== other.value ? 'The two passwords are not the same.' : ''); + } + + function checkImage(input) { + var file = input.files && input.files[0]; + var message = ''; + if (file) { + var accepted = (input.getAttribute('accept') || '').split(',').map(function (type) { return type.trim(); }); + if (accepted.length && accepted.indexOf(file.type) === -1) { + message = "That isn't a " + input.dataset.setupImage + ' picture.'; + } else if (file.size > Number(input.dataset.setupImageMax || 0)) { + message = 'That picture is too large - up to ' + Math.round(Number(input.dataset.setupImageMax) / 1048576) + ' MB.'; + } + } + input.setCustomValidity(message); + var preview = document.querySelector('[data-setup-preview="' + input.id + '"]'); + if (preview) { + if (preview.dataset.url) { + URL.revokeObjectURL(preview.dataset.url); + delete preview.dataset.url; + } + var img = preview.querySelector('img'); + if (file && !message) { + if (!img) { + img = document.createElement('img'); + img.alt = ''; + preview.appendChild(img); + } + preview.dataset.url = URL.createObjectURL(file); + img.src = preview.dataset.url; + preview.classList.add('has-image'); + } else { + if (img) { + img.remove(); + } + preview.classList.remove('has-image'); + } + } + return message; + } + + function initForm(form) { + form.querySelectorAll('[data-setup-same-as]').forEach(function (field) { + var other = document.getElementById(field.dataset.setupSameAs); + [field, other].forEach(function (el) { + if (el) { + el.addEventListener('input', function () { + checkSameAs(field); + if (field.classList.contains('is-invalid')) { + showField(field, field.validationMessage); + } + }); + } + }); + }); + + form.querySelectorAll('[data-setup-image]').forEach(function (input) { + input.addEventListener('change', function () { + showField(input, checkImage(input)); + }); + }); + + // A field put right stops saying it is wrong + form.querySelectorAll('input, select, textarea').forEach(function (field) { + ['input', 'change'].forEach(function (type) { + field.addEventListener(type, function () { + if (field.classList.contains('is-invalid') && field.checkValidity()) { + showField(field, ''); + } + }); + }); + }); + + form.addEventListener('submit', function (e) { + var first = null; + form.querySelectorAll('[data-setup-same-as]').forEach(checkSameAs); + form.querySelectorAll('input, select, textarea').forEach(function (field) { + if (!field.willValidate || field.type === 'hidden') { + return; + } + var ok = field.checkValidity(); + if (ok && !field.classList.contains('is-invalid')) { + return; + } + showField(field, ok ? '' : field.validationMessage); + if (!ok && !first) { + first = field; + } + }); + if (first) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (first.itflowSelect && first.itflowSelect.control) { + first.itflowSelect.control.focus(); + } else { + first.focus(); + } + } + }); + } + + // Sent once, and showing it is on its way - creating the tables or restoring takes a while + function initBusy(form) { + form.addEventListener('submit', function (e) { + if (e.defaultPrevented) { + return; + } + if (form.dataset.setupSent) { + e.preventDefault(); + return; + } + form.dataset.setupSent = '1'; + var button = e.submitter || form.querySelector('[type="submit"]'); + if (button) { + button.classList.add('disabled'); + button.setAttribute('aria-disabled', 'true'); + button.insertAdjacentHTML('afterbegin', ''); + } + }); + } + + function initRegion(form) { + var locale = form.querySelector('[name="locale"]'); + var currency = form.querySelector('[name="currency_code"]'); + var timezone = form.querySelector('[name="timezone"]'); + var sample = form.querySelector('[data-setup-sample]'); + + if (timezone && timezone.hasAttribute('data-setup-browser-timezone')) { + var browserZone = ''; + try { + browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch (err) { + browserZone = ''; + } + var has = Array.prototype.some.call(timezone.options, function (option) { return option.value === browserZone; }); + if (browserZone && has) { + selectValue(timezone, browserZone); + var col = timezone.closest('[class*="col"]'); + var hint = col.querySelector('[data-setup-suggested="timezone"]'); + if (!hint) { + hint = document.createElement('div'); + hint.className = 'form-text'; + hint.setAttribute('data-setup-suggested', 'timezone'); + col.appendChild(hint); + } + hint.textContent = "Your browser's timezone."; + } + } + + function draw() { + var tag = (locale.value || '').replace(/_/g, '-'); + if (!sample || !tag || !currency.value) { + if (sample) { + sample.hidden = true; + } + return; + } + try { + sample.querySelector('[data-setup-sample-money]').textContent = new Intl.NumberFormat(tag, { style: 'currency', currency: currency.value }).format(1234.56); + sample.querySelector('[data-setup-sample-date]').textContent = new Intl.DateTimeFormat(tag, { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone.value || undefined }).format(new Date()); + sample.hidden = false; + } catch (err) { + sample.hidden = true; + } + } + [locale, currency, timezone].forEach(function (select) { + select.addEventListener('change', draw); + }); + draw(); + } + + function init() { + document.querySelectorAll('form[data-setup-form]').forEach(initForm); + document.querySelectorAll('.itflow-setup form').forEach(initBusy); + document.querySelectorAll('form[data-setup-region]').forEach(initRegion); + } + + // Back to a page the browser kept as it was left - sent and spinning - ready to send again + window.addEventListener('pageshow', function (e) { + if (!e.persisted) { + return; + } + document.querySelectorAll('.itflow-setup form[data-setup-sent]').forEach(function (form) { + delete form.dataset.setupSent; + form.querySelectorAll('[aria-disabled="true"]').forEach(function (button) { + button.classList.remove('disabled'); + button.removeAttribute('aria-disabled'); + var spinner = button.querySelector('.spinner-border'); + if (spinner) { + spinner.remove(); + } + }); + }); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/setup/index.php b/setup/index.php index 4e0ea4892..cd935a8e7 100644 --- a/setup/index.php +++ b/setup/index.php @@ -26,6 +26,7 @@ define('FROM_SETUP', true); require "seed_data.php"; // Shared install-time seed data require "setup_checks.php"; require "setup_database.php"; +require "setup_forms.php"; // Setup's own session - for what a step has to say on the page after it if (session_status() === PHP_SESSION_NONE) { @@ -148,6 +149,14 @@ $database_form = ['database' => '', 'host' => 'localhost', 'username' => '']; $database_error = ''; $config_manual = ''; +// The account, company and region steps' forms - what was sent, and what is wrong with it +$user_form = ['name' => '', 'email' => '']; +$user_errors = []; +$company_form = []; +$company_errors = []; +$region_form = []; +$region_errors = []; + if (isset($_POST['add_database'])) { // Check if database has been set up already. If it has, direct user to edit directly instead. @@ -322,71 +331,46 @@ if (isset($_POST['add_user'])) { exit; } - $name = escapeSql($_POST['name']); - $email = escapeSql($_POST['email']); - $password = password_hash(trim($_POST['password']), PASSWORD_DEFAULT); - - //Generate master encryption key - $site_encryption_master_key = randomString(); - - //Generate user specific key - $user_specific_encryption_ciphertext = setupFirstUserSpecificKey(trim($_POST['password']), $site_encryption_master_key); - - mysqli_query($mysqli,"INSERT INTO users SET user_name = '$name', user_email = '$email', user_password = '$password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext', user_role_id = 3"); - - // Normally 1, but the table's AUTO_INCREMENT can already have moved on, so ask for it. - $user_id = intval(mysqli_insert_id($mysqli)); - - mkdirMissing("../uploads/users/$user_id"); - - $avatar_message = ''; - - //Check to see if a file is attached - if (!empty($_FILES['file']['tmp_name'])) { - - // get details of the uploaded file - $file_error = 0; - $file_tmp_path = $_FILES['file']['tmp_name']; - $file_name = $_FILES['file']['name']; - $file_size = $_FILES['file']['size']; - $file_extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); - - // sanitize file-name - $new_file_name = md5(time() . $file_name) . '.' . $file_extension; - - // check if file has one of the following extensions - $allowed_file_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp'); - - if (in_array($file_extension,$allowed_file_extensions) === false) { - $file_error = 1; - } - - //Check File Size - if ($file_size > 2097152) { - $file_error = 1; - } - - if ($file_error == 0) { - // directory in which the uploaded file will be moved - $upload_file_dir = "../uploads/users/$user_id/"; - $dest_path = $upload_file_dir . $new_file_name; - - move_uploaded_file($file_tmp_path, $dest_path); - - //Set Avatar - mysqli_query($mysqli,"UPDATE users SET user_avatar = '$new_file_name' WHERE user_id = $user_id"); - } else { - $avatar_message = ' - the avatar was left off: it has to be a jpg, png, gif or webp image under 2 MB'; - } + [$user_form, $user_errors] = setupUserForm($_POST); + $avatar_problem = setupImageProblem($_FILES['file'] ?? null, ['jpg', 'jpeg', 'gif', 'png', 'webp'], [IMAGETYPE_JPEG, IMAGETYPE_GIF, IMAGETYPE_PNG, IMAGETYPE_WEBP], 'JPG, PNG, GIF or WebP'); + if ($avatar_problem !== '') { + $user_errors['file'] = $avatar_problem; } - //Create Settings - mysqli_query($mysqli,"INSERT INTO user_settings SET user_id = $user_id"); + if (!$user_errors) { + $name = escapeSql($user_form['name']); + $email = escapeSql($user_form['email']); + // Exactly as typed - the sign-in page checks exactly what is typed + $password = escapeSql(password_hash($user_form['password'], PASSWORD_DEFAULT)); - setupFlash("Account " . escapeHtml($_POST['name']) . " created$avatar_message.", $avatar_message ? 'warning' : 'success'); + //Generate master encryption key + $site_encryption_master_key = randomString(); - header("Location: ?company"); - exit; + //Generate user specific key + $user_specific_encryption_ciphertext = setupFirstUserSpecificKey($user_form['password'], $site_encryption_master_key); + + mysqli_query($mysqli,"INSERT INTO users SET user_name = '$name', user_email = '$email', user_password = '$password', user_specific_encryption_ciphertext = '$user_specific_encryption_ciphertext', user_role_id = 3"); + + // Normally 1, but the table's AUTO_INCREMENT can already have moved on, so ask for it. + $user_id = intval(mysqli_insert_id($mysqli)); + + mkdirMissing("../uploads/users/$user_id"); + + // Checked above - saved as Profile saves one + $avatar_message = ''; + $avatar_error = ''; + if (!empty($_FILES['file']['tmp_name']) && !userSaveAvatar($user_id, $_FILES['file'], $avatar_error) && $avatar_error !== '') { + $avatar_message = " - the avatar was left off: $avatar_error"; + } + + //Create Settings + mysqli_query($mysqli,"INSERT INTO user_settings SET user_id = $user_id"); + + setupFlash("Account " . escapeHtml($user_form['name']) . " created$avatar_message.", $avatar_message ? 'warning' : 'success'); + + header("Location: ?company"); + exit; + } } @@ -403,66 +387,38 @@ if (isset($_POST['add_company_settings'])) { exit; } - $name = escapeSql($_POST['name']); - $country = escapeSql($_POST['country']); - $address = escapeSql($_POST['address']); - $city = escapeSql($_POST['city']); - $state = escapeSql($_POST['state']); - $zip = escapeSql($_POST['zip']); - $phone = preg_replace("/[^0-9]/", '',$_POST['phone']); - $email = escapeSql($_POST['email']); - $website = escapeSql($_POST['website']); - $tax_id = escapeSql($_POST['tax_id']); - - mysqli_query($mysqli,"INSERT INTO companies SET company_name = '$name', company_address = '$address', company_city = '$city', company_state = '$state', company_zip = '$zip', company_country = '$country', company_phone = '$phone', company_email = '$email', company_website = '$website', company_tax_id = '$tax_id'"); - - $logo_message = ''; - - //Check to see if a file is attached - if (!empty($_FILES['file']['tmp_name'])) { - - // get details of the uploaded file - $file_error = 0; - $file_tmp_path = $_FILES['file']['tmp_name']; - $file_name = $_FILES['file']['name']; - $file_size = $_FILES['file']['size']; - $file_extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION)); - - // sanitize file-name - $new_file_name = md5(time() . $file_name) . '.' . $file_extension; - - // check if file has one of the following extensions - $allowed_file_extensions = array('jpg', 'jpeg', 'png'); - - if (in_array($file_extension,$allowed_file_extensions) === false) { - $file_error = 1; - } - - //Check File Size - if ($file_size > 2097152) { - $file_error = 1; - } - - if ($file_error == 0) { - // directory in which the uploaded file will be moved - $upload_file_dir = "../uploads/settings/"; - $dest_path = $upload_file_dir . $new_file_name; - - move_uploaded_file($file_tmp_path, $dest_path); - - mysqli_query($mysqli,"UPDATE companies SET company_logo = '$new_file_name' WHERE company_id = 1"); - } else { - $logo_message = ' - the logo was left off: it has to be a jpg or png under 2 MB'; - } + [$company_form, $company_errors] = setupCompanyForm($_POST, $countries_array); + $logo_problem = setupImageProblem($_FILES['file'] ?? null, ['jpg', 'jpeg', 'png'], [IMAGETYPE_JPEG, IMAGETYPE_PNG], 'JPG or PNG'); + if ($logo_problem !== '') { + $company_errors['file'] = $logo_problem; } - // Seed the defaults shared with the CLI installer - seedDefaultData($mysqli); + if (!$company_errors) { + $sql_values = []; + foreach (['name', 'address', 'city', 'state', 'zip', 'country', 'phone_country_code', 'phone', 'email', 'website', 'tax_id'] as $field) { + $sql_values[] = "company_$field = '" . escapeSql($company_form[$field]) . "'"; + } + mysqli_query($mysqli, "INSERT INTO companies SET " . implode(', ', $sql_values)); - setupFlash("Company " . escapeHtml($_POST['name']) . " saved$logo_message.", $logo_message ? 'warning' : 'success'); + // Checked above - into uploads/settings, as Admin > Settings > Company saves one + $logo_message = ''; + if (!empty($_FILES['file']['tmp_name'])) { + $new_file_name = checkFileUpload($_FILES['file'], ['jpg', 'jpeg', 'png']); + if ($new_file_name && move_uploaded_file($_FILES['file']['tmp_name'], "../uploads/settings/$new_file_name")) { + mysqli_query($mysqli, "UPDATE companies SET company_logo = '" . escapeSql($new_file_name) . "' WHERE company_id = 1"); + } else { + $logo_message = ' - the logo could not be saved, add it in Admin > Settings > Company'; + } + } - header("Location: ?localization"); - exit; + // Seed the defaults shared with the CLI installer + seedDefaultData($mysqli); + + setupFlash("Company " . escapeHtml($company_form['name']) . " saved$logo_message.", $logo_message ? 'warning' : 'success'); + + header("Location: ?localization"); + exit; + } } @@ -479,21 +435,25 @@ if (isset($_POST['add_localization_settings'])) { exit; } - $locale = escapeSql($_POST['locale']); - $currency_code = escapeSql($_POST['currency_code']); - $timezone = escapeSql($_POST['timezone']); + [$region_form, $region_errors] = setupRegionForm($_POST, $locales_array, $currencies_array, $timezones); - mysqli_query($mysqli,"UPDATE companies SET company_locale = '$locale', company_currency = '$currency_code' WHERE company_id = 1"); + if (!$region_errors) { + $locale = escapeSql($region_form['locale']); + $currency_code = escapeSql($region_form['currency_code']); + $timezone = escapeSql($region_form['timezone']); - mysqli_query($mysqli,"UPDATE settings SET config_timezone = '$timezone' WHERE company_id = 1"); + mysqli_query($mysqli,"UPDATE companies SET company_locale = '$locale', company_currency = '$currency_code' WHERE company_id = 1"); - // Create Default Cash Account - mysqli_query($mysqli,"INSERT INTO accounts SET account_name = 'Cash', account_type = 4, account_currency_code = '$currency_code'"); + mysqli_query($mysqli,"UPDATE settings SET config_timezone = '$timezone' WHERE company_id = 1"); - setupFlash("Language, currency and timezone saved - one last step."); + // Create Default Cash Account + mysqli_query($mysqli,"INSERT INTO accounts SET account_name = 'Cash', account_type = 4, account_currency_code = '$currency_code'"); - header("Location: ?telemetry"); - exit; + setupFlash("Language, currency and timezone saved - one last step."); + + header("Location: ?telemetry"); + exit; + } } @@ -585,9 +545,15 @@ foreach (array_merge(array_keys($setup_steps), ['restore']) as $key) { break; } } -// The Database step's errors, and the config.php to make by hand, show on it - wherever the form was sent from +// A step's errors show on it - wherever its form was sent from if ($database_error !== '' || $config_manual !== '') { $setup_step = 'database'; +} elseif ($user_errors) { + $setup_step = 'user'; +} elseif ($company_errors) { + $setup_step = 'company'; +} elseif ($region_errors) { + $setup_step = 'localization'; } $setup_order = array_keys($setup_steps); $setup_resume_index = array_search($resume_step, $setup_order, true); @@ -638,6 +604,9 @@ $setup_title = $setup_step === 'restore' ? 'Restore a backup' : $setup_steps[$se + + + @@ -710,7 +679,13 @@ $setup_title = $setup_step === 'restore' ? 'Restore a backup' : $setup_steps[$se + + + + + + diff --git a/setup/setup_forms.php b/setup/setup_forms.php new file mode 100644 index 000000000..93e6c1811 --- /dev/null +++ b/setup/setup_forms.php @@ -0,0 +1,173 @@ + trim((string) ($post['name'] ?? '')), + 'email' => trim((string) ($post['email'] ?? '')), + 'password' => (string) ($post['password'] ?? ''), + ]; + $errors = []; + if ($values['name'] === '') { + $errors['name'] = 'Your name, as your team will see it.'; + } elseif (mb_strlen($values['name']) > 200) { + $errors['name'] = 'Up to 200 characters.'; + } + if ($values['email'] === '') { + $errors['email'] = 'The email you will sign in with.'; + } elseif (!filter_var($values['email'], FILTER_VALIDATE_EMAIL) || mb_strlen($values['email']) > 200) { + $errors['email'] = 'That is not an email address.'; + } + if (($problem = userPasswordProblem($values['password'])) !== '') { + $errors['password'] = $problem; + } elseif (array_key_exists('password_confirm', $post) && (string) $post['password_confirm'] !== $values['password']) { + $errors['password_confirm'] = 'The two passwords are not the same.'; + } + return [$values, $errors]; +} + +/* + * What is wrong with an uploaded picture - '' when nothing is, or nothing was + * sent. A real image of one of $types (IMAGETYPE_*), checked rather than + * trusted by its name, up to 5 MB. + */ +function setupImageProblem($file, array $extensions, array $types, $kinds) { + if (!is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE || empty($file['name'])) { + return ''; + } + if (in_array($file['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || ($file['size'] ?? 0) > SETUP_IMAGE_MAX_BYTES) { + return "That picture is too large - up to 5 MB."; + } + $image = checkFileUpload($file, $extensions) ? @getimagesize($file['tmp_name']) : false; + if (!$image || !in_array($image[2], $types, true)) { + return "That isn't a $kinds picture."; + } + return ''; +} + +/* The company step - its name and country, and the rest as given. The website loses its http:// and trailing slash. */ +function setupCompanyForm(array $post, array $countries) { + $values = []; + foreach (['name', 'country', 'address', 'city', 'state', 'zip', 'email', 'website', 'tax_id'] as $field) { + $values[$field] = mb_substr(trim((string) ($post[$field] ?? '')), 0, 200); + } + $values['phone_country_code'] = mb_substr(preg_replace('/[^0-9]/', '', (string) ($post['phone_country_code'] ?? '')), 0, 10); + $values['phone'] = mb_substr(preg_replace('/[^0-9]/', '', (string) ($post['phone'] ?? '')), 0, 200); + $values['website'] = rtrim(preg_replace('#^[a-z][a-z0-9+.-]*://#i', '', $values['website']), '/'); + if ($values['phone'] === '') { + $values['phone_country_code'] = ''; + } + + $errors = []; + if ($values['name'] === '') { + $errors['name'] = "Your company's name, as it goes on invoices."; + } + if (!in_array($values['country'], $countries, true)) { + $errors['country'] = 'Pick the country the company is in.'; + } + if ($values['email'] !== '' && !filter_var($values['email'], FILTER_VALIDATE_EMAIL)) { + $errors['email'] = 'That is not an email address.'; + } + if ($values['website'] !== '' && !preg_match('/^[^\s\/]+\.[^\s\/]+(\/\S*)?$/', $values['website'])) { + $errors['website'] = 'A website looks like company.com.'; + } + return [$values, $errors]; +} + +/* The region step - a language, currency and timezone from the lists offered. */ +function setupRegionForm(array $post, array $locales, array $currencies, array $timezones) { + $values = [ + 'locale' => trim((string) ($post['locale'] ?? '')), + 'currency_code' => strtoupper(trim((string) ($post['currency_code'] ?? ''))), + 'timezone' => trim((string) ($post['timezone'] ?? '')), + ]; + $errors = []; + if (!isset($locales[$values['locale']])) { + $errors['locale'] = 'Pick the language dates and numbers are written in.'; + } + if (!isset($currencies[$values['currency_code']])) { + $errors['currency_code'] = 'Pick the currency you bill in.'; + } + if (!in_array($values['timezone'], $timezones, true)) { + $errors['timezone'] = 'Pick your timezone.'; + } + return [$values, $errors]; +} + +/* The currency a country uses (intl knows) - when it is one ITFlow offers, or ''. */ +function setupSuggestCurrency($iso2, array $currencies) { + $iso2 = strtoupper((string) $iso2); + if (!preg_match('/^[A-Z]{2}$/', $iso2) || !class_exists('NumberFormatter')) { + return ''; + } + $code = (new NumberFormatter("und_$iso2", NumberFormatter::CURRENCY))->getTextAttribute(NumberFormatter::CURRENCY_CODE); + return isset($currencies[$code]) ? $code : ''; +} + +/* + * The language for a country - the browser's own language there when ITFlow + * has it (fr_CA for a French browser in Canada), then English there, then any + * language of that country; failing those the browser's language, then en_US. + */ +function setupSuggestLocale($iso2, $accept_language, array $locales) { + $iso2 = strtoupper((string) $iso2); + $browser = ''; + if ($accept_language !== '' && function_exists('locale_accept_from_http')) { + $browser = (string) locale_accept_from_http($accept_language); + } + $language = $browser !== '' ? strtolower(strtok(str_replace('-', '_', $browser), '_')) : ''; + + $candidates = []; + if (preg_match('/^[A-Z]{2}$/', $iso2)) { + if ($language !== '') { + $candidates[] = "{$language}_$iso2"; + } + $candidates[] = "en_$iso2"; + foreach (array_keys($locales) as $locale) { + if (substr($locale, -3) === "_$iso2") { + $candidates[] = $locale; + } + } + } + if ($browser !== '') { + $candidates[] = str_replace('-', '_', $browser); + $candidates[] = $language; + } + $candidates[] = 'en_US'; + foreach ($candidates as $candidate) { + if (isset($locales[$candidate])) { + return $candidate; + } + } + return ''; +} + +/* A country's timezone, when it has only the one - as the UK has, and Canada has not. */ +function setupSuggestTimezone($iso2) { + $iso2 = strtoupper((string) $iso2); + if (!preg_match('/^[A-Z]{2}$/', $iso2)) { + return ''; + } + $zones = @DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY, $iso2) ?: []; + return count($zones) === 1 ? $zones[0] : ''; +} + +/* The is-invalid class and the message under a field, when the step has one for it. */ +function setupFieldInvalid(array $errors, $field) { + return isset($errors[$field]) ? ' is-invalid' : ''; +} +function setupFieldError(array $errors, $field) { + return isset($errors[$field]) ? '
' . escapeHtml($errors[$field]) . '
' : ''; +} diff --git a/setup/steps/company.php b/setup/steps/company.php index bc9336260..e4ac80dcc 100644 --- a/setup/steps/company.php +++ b/setup/steps/company.php @@ -2,12 +2,28 @@ /* * Setup - Company: your company's details, on invoices, quotes and emails - * (add_company_settings in setup/index.php, which seeds the defaults too). - * Saved once - the rest is changed in Admin > Settings. + * (add_company_settings in setup/index.php, checked by setupCompanyForm(), + * which seeds the defaults too). Saved once - the rest is changed in Admin > + * Settings. The phone takes its country code from the Country picked, as on + * that page. A mistake comes back with everything typed kept. */ defined('FROM_SETUP') || die("Direct file access is not allowed"); +$form = $company_form + ['name' => '', 'country' => '', 'address' => '', 'city' => '', 'state' => '', 'zip' => '', 'phone_country_code' => '', 'phone' => '', 'email' => '', 'website' => '', 'tax_id' => '']; +$first_error = array_key_first($company_errors); +$field = function ($name, $label, $icon, $type = 'text', $placeholder = '', $autocomplete = 'off') use ($form, $company_errors, $first_error) { + $id = 'setupCompany' . str_replace('_', '', ucwords($name, '_')); + ob_start(); ?> + +
+ + > +
+ +
@@ -27,89 +43,76 @@ defined('FROM_SETUP') || die("Direct file access is not allowed");
Your account comes first.
Carry on where setup is -
+ + + +
-
+
- + >
+
-
+
- - +
+ Region suggests your currency and language from it.
' ?>
-
- - +
+
+
+ + +
+ Optional - JPG or PNG, up to 5 MB.
' ?> +
+
- -
- - -
+
- -
- - -
+
- -
- - -
+
- -
- - -
+
- + +
- -
- - -
+
- -
- - -
+
- -
- - -
+
diff --git a/setup/steps/localization.php b/setup/steps/localization.php index 337719051..8a7b1b9e1 100644 --- a/setup/steps/localization.php +++ b/setup/steps/localization.php @@ -2,12 +2,39 @@ /* * Setup - Region: the language, currency and timezone ITFlow works in - * (add_localization_settings in setup/index.php, which makes the Cash account - * in that currency too). Saved once - changed later in Admin > Settings. + * (add_localization_settings in setup/index.php, checked by + * setupRegionForm(), which makes the Cash account in that currency too). + * The currency and language start on the company's country's, the timezone + * on the browser's (js/setup.js) - or the country's, when it has only one. + * Saved once - changed later in Admin > Settings. */ defined('FROM_SETUP') || die("Direct file access is not allowed"); +$form = $region_form; +$suggested = ['locale' => false, 'currency_code' => false, 'timezone' => false]; +$company_country = ''; +if ($company_exists && !$localization_done && !$form) { + $company_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_country FROM companies WHERE company_id = 1")); + $company_country = (string) ($company_row['company_country'] ?? ''); + $company_iso2 = $country_iso2_array[$company_country] ?? ''; + $form = [ + 'locale' => setupSuggestLocale($company_iso2, (string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''), $locales_array), + 'currency_code' => setupSuggestCurrency($company_iso2, $currencies_array), + 'timezone' => setupSuggestTimezone($company_iso2), + ]; + foreach ($form as $key => $value) { + $suggested[$key] = $value !== ''; + } +} +$form += ['locale' => '', 'currency_code' => '', 'timezone' => '']; +$hint = function ($key, $text) use ($region_errors, $suggested) { + if (isset($region_errors[$key])) { + return setupFieldError($region_errors, $key); + } + return $suggested[$key] ? '
' . $text . '
' : ''; +}; + ?>
@@ -27,45 +54,61 @@ defined('FROM_SETUP') || die("Direct file access is not allowed");
The company comes first.
Carry on where setup is - + + + +
-
+
- $locale_name) { ?> - +
+
-
+
- $currency_name) { ?> - +
+
-
+
- > - +
+
+ + +
diff --git a/setup/steps/user.php b/setup/steps/user.php index 63cac900f..0902033cb 100644 --- a/setup/steps/user.php +++ b/setup/steps/user.php @@ -2,11 +2,14 @@ /* * Setup - Your account: the first user, an administrator (add_user in - * setup/index.php). Made once - an install that has one carries on. + * setup/index.php, checked by setupUserForm()). Made once - an install that + * has one carries on. A mistake comes back with the name and email kept. */ defined('FROM_SETUP') || die("Direct file access is not allowed"); +$first_error = array_key_first($user_errors); + ?>
@@ -23,36 +26,59 @@ defined('FROM_SETUP') || die("Direct file access is not allowed");
The database comes first.
Set up the database - + + + +
-
+
- + >
+
-
+
- + >
-
What you sign in with.
+ What you sign in with.
' ?>
-
+
-
+
- + >
+ +
+
+ +
+ + + +
+
-
- - +
+
+
+
+ + +
+ Optional - JPG, PNG, GIF or WebP, up to 5 MB.
' ?> +