Compare commits
12
Commits
4c92d19d2e
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
969d864106 | ||
|
|
69179b75c9
|
||
|
|
4e5382e259
|
||
|
|
28046e0fd1 | ||
|
|
699e479805
|
||
|
|
1b42d20541 | ||
|
|
ab5212282d
|
||
|
|
6e3affb1cb
|
||
|
|
7875cb1bf7 | ||
|
|
04cba9702c
|
||
|
|
d554e35d80 | ||
|
|
b5b9a7ac54
|
+7
-1
@@ -11,17 +11,23 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.3.1]
|
||||
## [1.4.0]
|
||||
|
||||
### Added
|
||||
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
|
||||
|
||||
### Security
|
||||
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
|
||||
|
||||
### Changed
|
||||
- Signup now asks **"Who are you registering?"** as a three-way choice — **just myself**, **on behalf of one or more students**, or **both** — in place of the single parent/guardian tick. The tick could only ever say "I have children to add"; it could not say whether the account holder was a student themselves, so every account was offered its own name in the **Who is this for?** picker whether or not anyone meant to book them a lesson. Choosing *on behalf of* now leaves the account holder out of that picker. Existing accounts are unaffected and stay bookable, since the flag records only the new "not a student" case.
|
||||
- The studio's **account-signup questions are now asked of anyone registering as a student**, including someone registering themselves alongside their children. Choosing **both** previously collected the questions per child only, so the account holder's own instrument, level and the rest were never asked for or stored, even though they could book lessons. Their answers are recorded against their own account, and a blank required answer now names them rather than blaming "each student".
|
||||
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
|
||||
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
|
||||
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
|
||||
|
||||
### Fixed
|
||||
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
|
||||
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
@@ -525,6 +525,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The live password verdict under the signup field. Colour is a reinforcement,
|
||||
* not the message — the text says what is wrong on its own, so this still reads
|
||||
* correctly to anyone who cannot separate the hues.
|
||||
*/
|
||||
.us-password-strength {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.us-password-strength.is-short,
|
||||
.us-password-strength.is-weak {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.us-password-strength.is-medium {
|
||||
color: #7a5c00;
|
||||
}
|
||||
|
||||
.us-password-strength.is-strong {
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
/*
|
||||
* The account panel: who is signed in, and the way out. Sized to sit in a
|
||||
* header or sidebar, so the rules stay minimal and inherit the theme's type —
|
||||
@@ -548,6 +572,49 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
|
||||
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
|
||||
* author !important is the only way to win, and it has to sit before the
|
||||
* display rule it guards against.
|
||||
*/
|
||||
.us-notice[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
|
||||
* or class list rather than replacing it, so it needs to read as a banner
|
||||
* about something that just happened — not as the page's content.
|
||||
*/
|
||||
.us-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #b7dfc0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2faf4;
|
||||
color: #1a5c2a;
|
||||
}
|
||||
|
||||
.us-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.us-notice-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shown only in block-editor previews (see BlockPreview). */
|
||||
.us-editor-note {
|
||||
font-size: 0.85em;
|
||||
|
||||
+52
-10
@@ -335,7 +335,12 @@
|
||||
|
||||
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
||||
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
||||
if (slot) btn.addEventListener('click', () => openRegistration(slot));
|
||||
if (slot) {
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openRegistration(slot);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -571,8 +576,11 @@
|
||||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||||
: null))
|
||||
.then((result) => {
|
||||
loadMyLessons();
|
||||
showConfirmation(window.usPayment.message(result));
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadSlots() clears any standing notice, and it
|
||||
// is what puts the calendar back with the booked slot gone.
|
||||
return loadSlots().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
@@ -668,10 +676,43 @@
|
||||
.catch(() => { myLessons.innerHTML = ''; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed booking without taking the calendar away.
|
||||
*
|
||||
* This used to hide the slot list and leave the confirmation as the whole
|
||||
* page, which is a dead end: the student had nothing to click and no way
|
||||
* back to booking short of reloading. The notice now sits above a freshly
|
||||
* loaded calendar, so "it worked" and "you can book again" are the same
|
||||
* screen.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
slotList.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
if (!confirm) return;
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
// The private-lesson catalog drives both the filter and the registration
|
||||
@@ -696,16 +737,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the calendar is back. */
|
||||
function loadSlots() {
|
||||
clearError();
|
||||
loadMyLessons();
|
||||
|
||||
// An upcoming-lessons-only embed has no calendar to fill.
|
||||
if (!slotList) return;
|
||||
if (!slotList) return Promise.resolve();
|
||||
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
hideConfirmation();
|
||||
|
||||
return Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
.then(([slots]) => {
|
||||
allSlots = slots;
|
||||
render();
|
||||
|
||||
@@ -173,7 +173,10 @@
|
||||
|
||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
btn.addEventListener('click', () => openEnrolment(offering));
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openEnrolment(offering);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||
@@ -254,25 +257,60 @@
|
||||
.then((res) => (res.payment
|
||||
? window.usPayment.collect('enrollment', res.id, list)
|
||||
: null))
|
||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||
.then((result) => {
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadClasses() clears any standing notice, and
|
||||
// it is what puts the list back showing the new enrolment.
|
||||
return loadClasses().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed enrolment without taking the class list away. Hiding
|
||||
* the list left the student on a dead-end screen with no way back to
|
||||
* browsing short of a reload; the notice now sits above a freshly loaded
|
||||
* list instead. Mirrors booking.js.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
list.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the list is back. */
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
list.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
hideConfirmation();
|
||||
// The student's own enrolments are fetched alongside the catalog so a
|
||||
// class they already have an active enrolment in shows its status
|
||||
// instead of offering to enrol them again (the API would reject the
|
||||
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
apiFetch('offerings?kind=group_class'),
|
||||
apiFetch('enrollments'),
|
||||
])
|
||||
|
||||
+146
-16
@@ -9,15 +9,119 @@
|
||||
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
|
||||
* behind a "Next" button that only advances once step one passes native
|
||||
* validation.
|
||||
* 2. **Parent/guardian.** The children section is hidden until the
|
||||
* parent/guardian box is ticked, and "Add another child" clones the child
|
||||
* block. Ticking the box also takes the guardian's *own* question panel out
|
||||
* of play — in guardian mode the questions are asked per child, so the
|
||||
* server ignores those answers and the browser must not demand them.
|
||||
* 2. **Who are you registering?** The student section is hidden until the
|
||||
* choice is "on behalf of students" or "both", and "Add another student"
|
||||
* clones the student block. "On behalf of students" *alone* also takes the
|
||||
* account holder's own question panel out of play — they are not a student
|
||||
* in that case, so the server ignores those answers and the browser must not
|
||||
* demand them. Under "both" they are a student and do answer them.
|
||||
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
|
||||
* own `wp.passwordStrength`) and a weak one is refused. The server applies
|
||||
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PASSWORD = window.usSchedulerPassword || {};
|
||||
|
||||
/**
|
||||
* Gate the form on password strength.
|
||||
*
|
||||
* The verdict is attached to the field with `setCustomValidity()` rather than
|
||||
* by disabling the submit button: the form has up to three submits (the plain
|
||||
* one, the guardian-mode early one, and step two's) plus a "Next" that
|
||||
* already gates on `checkValidity()`, and an invalid field blocks all of them
|
||||
* at once without any of them having to know why.
|
||||
*/
|
||||
function enhancePassword(form) {
|
||||
var field = form.querySelector('#us-reg-pass');
|
||||
var output = form.querySelector('#us-reg-pass-strength');
|
||||
var strings = PASSWORD.strings || {};
|
||||
|
||||
if (!field || !PASSWORD.minScore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// What the password must not simply repeat back. Mirrors the identity
|
||||
// check PasswordPolicy makes server-side.
|
||||
function identity() {
|
||||
var out = [];
|
||||
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var value = (sources[i].value || '').trim();
|
||||
if (value) {
|
||||
out.push(value);
|
||||
if (value.indexOf('@') > 0) {
|
||||
out.push(value.split('@')[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function assess() {
|
||||
var value = field.value || '';
|
||||
|
||||
if (!value) {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length < (PASSWORD.minLength || 8)) {
|
||||
report(strings.short, 'short');
|
||||
return;
|
||||
}
|
||||
|
||||
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
|
||||
// reports -1 until it arrives. Say nothing and allow the submit in that
|
||||
// window — the server still checks, and the next keystroke re-runs this
|
||||
// once the dictionary is in.
|
||||
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
var score = window.wp.passwordStrength.meter(value, identity(), '');
|
||||
|
||||
if (score < 0) {
|
||||
report('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (score >= 3) {
|
||||
report(strings.strong, 'strong');
|
||||
} else if (score >= PASSWORD.minScore) {
|
||||
report(strings.medium, 'medium');
|
||||
} else {
|
||||
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the verdict, and make it the field's validity at the same time. */
|
||||
function report(message, level) {
|
||||
var acceptable = '' === level || 'medium' === level || 'strong' === level;
|
||||
|
||||
if (output) {
|
||||
output.textContent = message || '';
|
||||
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
|
||||
}
|
||||
|
||||
field.setCustomValidity(acceptable ? '' : message || '');
|
||||
}
|
||||
|
||||
field.addEventListener('input', assess);
|
||||
field.addEventListener('blur', assess);
|
||||
|
||||
// The identity check depends on these, so a password typed first and an
|
||||
// email typed second is still caught.
|
||||
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
sources[i].addEventListener('change', assess);
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceSteps(form) {
|
||||
var step1 = form.querySelector('[data-step="1"]');
|
||||
var step2 = form.querySelector('[data-step="2"]');
|
||||
@@ -95,21 +199,44 @@
|
||||
}
|
||||
|
||||
function enhanceGuardian(form, steps) {
|
||||
var toggle = form.querySelector('#us-is-guardian');
|
||||
var choices = form.querySelectorAll('.us-registering-for');
|
||||
var children = form.querySelector('#us-children');
|
||||
|
||||
if (!toggle || !children) {
|
||||
if (!choices.length || !children) {
|
||||
return;
|
||||
}
|
||||
|
||||
var addButton = children.querySelector('.us-add-child');
|
||||
var nextIndex = 1;
|
||||
|
||||
// The guardian's own question panel is only meaningful when they are
|
||||
// registering for themselves. Disabling it (rather than hiding it) is what
|
||||
// stops a `required` question the server will ignore from blocking submit.
|
||||
/** The selected "who are you registering?" value; 'self' if somehow none is. */
|
||||
function mode() {
|
||||
for (var i = 0; i < choices.length; i++) {
|
||||
if (choices[i].checked) return choices[i].value;
|
||||
}
|
||||
return 'self';
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the form in step with the choice.
|
||||
*
|
||||
* Two independent questions, which is why "both" needs its own answer to
|
||||
* each:
|
||||
*
|
||||
* - Are student blocks in play? For "students" and "both".
|
||||
* - Does the account holder answer the studio's questions themselves? For
|
||||
* "self" and "both" — they are a student in those two, and the questions
|
||||
* describe a student. A pure guardian answers them per student instead,
|
||||
* so their own copy goes out of play; disabling it rather than hiding it
|
||||
* is what stops a `required` question the server will ignore from
|
||||
* blocking submit.
|
||||
*/
|
||||
function sync() {
|
||||
children.hidden = !toggle.checked;
|
||||
var current = mode();
|
||||
var wantsStudents = current !== 'self';
|
||||
var asksSelf = current !== 'students';
|
||||
|
||||
children.hidden = !wantsStudents;
|
||||
|
||||
// Each student's name and birth year are required, but only once the
|
||||
// block is in play: a `required` field inside a hidden container makes
|
||||
@@ -118,7 +245,7 @@
|
||||
// enforces the same rule either way.
|
||||
var required = children.querySelectorAll('[data-us-child-required]');
|
||||
for (var r = 0; r < required.length; r++) {
|
||||
required[r].required = toggle.checked;
|
||||
required[r].required = wantsStudents;
|
||||
}
|
||||
|
||||
if (!steps) {
|
||||
@@ -127,19 +254,21 @@
|
||||
|
||||
var fields = steps.step2.querySelectorAll('input, select, textarea');
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
fields[i].disabled = toggle.checked;
|
||||
fields[i].disabled = !asksSelf;
|
||||
}
|
||||
|
||||
// With the questions out of play there is no second step to advance to,
|
||||
// so "Next" would be a dead end — swap it for the submit.
|
||||
steps.next.hidden = toggle.checked;
|
||||
steps.next.hidden = !asksSelf;
|
||||
|
||||
if (steps.earlySubmit) {
|
||||
steps.earlySubmit.hidden = !toggle.checked;
|
||||
steps.earlySubmit.hidden = asksSelf;
|
||||
}
|
||||
}
|
||||
|
||||
toggle.addEventListener('change', sync);
|
||||
for (var c = 0; c < choices.length; c++) {
|
||||
choices[c].addEventListener('change', sync);
|
||||
}
|
||||
sync();
|
||||
|
||||
if (addButton) {
|
||||
@@ -168,6 +297,7 @@
|
||||
: null;
|
||||
|
||||
enhanceGuardian(forms[i], steps);
|
||||
enhancePassword(forms[i]);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -77,6 +77,40 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
|
||||
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
|
||||
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
|
||||
|
||||
## Email and password validation
|
||||
|
||||
Both are checked on the server on every signup path, and the browser is given a
|
||||
matching but *stricter* job so a bad password is caught before submitting.
|
||||
|
||||
**Email** — `type="email"` and `required` in the markup, `is_email()` on the
|
||||
server, then `email_exists()` for "an account already exists for this email". A
|
||||
personal invite fixes the address and the server always uses the invite's own
|
||||
value, so a tampered field is ignored rather than validated.
|
||||
|
||||
**Password** — `Auth\PasswordPolicy` is the authority. It deliberately does
|
||||
*not* try to reproduce a strength score in PHP; it rejects the categorically
|
||||
bad, which is what a server can check without shipping a dictionary:
|
||||
|
||||
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
|
||||
composition rules like "must contain a symbol" are deliberately **not** used,
|
||||
as they push people towards predictable substitutions),
|
||||
- one of the well-known leaked passwords,
|
||||
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
|
||||
- containing the user's own display name, email, or the part before the `@`.
|
||||
|
||||
The nuance happens in the browser. `register.js` scores the password with
|
||||
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
|
||||
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
|
||||
guessable password without demanding a passphrase to book a piano lesson). The
|
||||
thresholds reach JavaScript via `wp_localize_script()` from the same constants
|
||||
the server enforces, so the two cannot drift apart.
|
||||
|
||||
The verdict is applied with `setCustomValidity()` on the password field rather
|
||||
than by disabling a button: the form has up to three submits plus a "Next" that
|
||||
already gates on `checkValidity()`, and an invalid field stops all of them
|
||||
without any needing to know why. zxcvbn's dictionary loads asynchronously, so
|
||||
the gate stays open until it arrives — the server is the check that always runs.
|
||||
|
||||
## Registration Questions (signup step two)
|
||||
When the studio has configured **account-scope** registration questions
|
||||
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
|
||||
|
||||
@@ -30,7 +30,8 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
|
||||
12. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Lesson-Type Filter
|
||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||
|
||||
@@ -140,10 +140,54 @@ apply to.
|
||||
|
||||
## Registration
|
||||
|
||||
A **"I'm registering as a parent or guardian"** checkbox on the existing
|
||||
`[us_student_register]` form (all three signup paths — personal invite, group
|
||||
link, self-approval) reveals a repeatable child block. Ticking it requires at
|
||||
least one child name.
|
||||
A **"Who are you registering?"** choice on the existing `[us_student_register]`
|
||||
form (all three signup paths — personal invite, group link, self-approval), as
|
||||
three radios:
|
||||
|
||||
| Choice | `us_registering_for` | Student blocks | Account holder is a student | Answers the studio's questions |
|
||||
|---|---|---|---|---|
|
||||
| Just myself | `self` | no | yes | for themselves |
|
||||
| On behalf of one or more students | `students` | yes | **no** | per student only |
|
||||
| Both — myself and one or more students | `both` | yes | yes | **per student *and* for themselves** |
|
||||
|
||||
The last column follows from the third, and is the whole of it: the
|
||||
account-scope questions describe a *student* — instrument, level, school — so
|
||||
they are asked of everyone being registered as one. Under `both` that is each
|
||||
student **and** the account holder, whose answers are stored against their own
|
||||
user id, not shared with anyone. Under `students` the account holder is not a
|
||||
student, so anything posted for them is ignored outright.
|
||||
|
||||
Required answers are checked in two passes rather than one, so the error can say
|
||||
whose are missing: `both` would otherwise have to blame "each student" for the
|
||||
account holder's own blank field.
|
||||
|
||||
Radios rather than checkboxes because the three answers are mutually exclusive:
|
||||
"both" only means anything as a third choice alongside the other two. Either
|
||||
student-bearing choice requires at least one student name.
|
||||
|
||||
Anything unrecognised — a form posted without the field, an old cached page, a
|
||||
crafted request — is read as `self`, the choice that collects the least and
|
||||
grants the least. A missing radio must never be taken as "register these
|
||||
children".
|
||||
|
||||
### The account holder as a student
|
||||
|
||||
This replaced a single "I'm registering as a parent or guardian" checkbox, which
|
||||
could only say *whether there were children to add*. It could not say whether the
|
||||
**account holder** was a student, so `bookableStudents()` always offered them
|
||||
their own name and every guardian could book themselves a lesson nobody intended
|
||||
to sell.
|
||||
|
||||
`students` now records `us_guardian_only = 1` and `bookableStudents()` leaves the
|
||||
account holder out. The flag is stored as the **negative** deliberately: every
|
||||
account predating the choice is a bookable student, and absence has to keep
|
||||
meaning exactly that, or the picker would silently stop offering people
|
||||
themselves on upgrade. `GuardianService::setGuardianOnly()` clears the key rather
|
||||
than writing `0`, so "not set" stays the one spelling of "yes, a student".
|
||||
|
||||
One guard: a guardian-only account with **nobody linked to it** is still offered
|
||||
itself, because an empty picker is no way to book at all. They can put the
|
||||
account right from the profile page.
|
||||
|
||||
Per child the form collects:
|
||||
- **Name** (required)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* What counts as an acceptable signup password.
|
||||
*
|
||||
* The check is deliberately split across the two sides, because the two sides
|
||||
* can do different things:
|
||||
*
|
||||
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
|
||||
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test —
|
||||
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple` —
|
||||
* but it is only advice, because anything in a browser can be turned off.
|
||||
* - **This class** runs on the server and is the rule that actually holds. It
|
||||
* cannot score a password the way zxcvbn does without shipping a dictionary,
|
||||
* so it does not pretend to: it rejects the categorically bad — too short,
|
||||
* the user's own name or email, a password from the well-known lists, or one
|
||||
* built from almost no distinct characters.
|
||||
*
|
||||
* Neither half is sufficient alone, which is the point. A password that clears
|
||||
* both is not guaranteed strong; one that fails either is definitely not.
|
||||
*/
|
||||
class PasswordPolicy {
|
||||
|
||||
/**
|
||||
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
|
||||
* against composition rules ("must contain a symbol") on the grounds that they
|
||||
* push people towards predictable substitutions. Length plus the checks below
|
||||
* does more for less annoyance.
|
||||
*/
|
||||
public const MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The zxcvbn score the browser demands before it will let the form submit,
|
||||
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
|
||||
* passwords a stranger would guess while still accepting an ordinary
|
||||
* memorable one — a studio signup form is not a bank.
|
||||
*/
|
||||
public const MIN_SCORE = 2;
|
||||
|
||||
/**
|
||||
* How much of the user's own identity has to appear in the password before it
|
||||
* is refused. Short enough to catch a name inside a longer password, long
|
||||
* enough that a two- or three-letter coincidence does not trip it.
|
||||
*/
|
||||
private const IDENTITY_FRAGMENT_LENGTH = 4;
|
||||
|
||||
/** Fewest distinct characters a password may be built from. */
|
||||
private const MIN_DISTINCT_CHARACTERS = 4;
|
||||
|
||||
/**
|
||||
* Why this password is unacceptable, or null when it passes.
|
||||
*
|
||||
* `$email` and `$displayName` are what the same submission is claiming as an
|
||||
* identity, so they can be checked against the password before either exists
|
||||
* as a user.
|
||||
*/
|
||||
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
|
||||
// Not trimmed: a leading or trailing space is a legitimate character, and
|
||||
// silently changing what someone typed would lock them out later.
|
||||
if ( strlen( $password ) < self::MIN_LENGTH ) {
|
||||
return sprintf(
|
||||
/* translators: %d: minimum number of characters. */
|
||||
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
|
||||
self::MIN_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
$lower = strtolower( $password );
|
||||
|
||||
if ( in_array( $lower, self::commonPasswords(), true ) ) {
|
||||
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
|
||||
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
|
||||
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the password contains the user's display name, their email address,
|
||||
* or the part of it before the `@` — the first things anyone guessing would
|
||||
* try, and the reason "grace2019" is worse than its length suggests.
|
||||
*/
|
||||
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
|
||||
$email = strtolower( trim( $email ) );
|
||||
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
|
||||
|
||||
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
|
||||
|
||||
foreach ( $fragments as $fragment ) {
|
||||
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwords common enough that a guess costs nothing. Only entries at least
|
||||
* {@see MIN_LENGTH} long are worth listing — anything shorter is already
|
||||
* refused — so this is the long tail of the usual leaked-password lists
|
||||
* rather than the whole of it. zxcvbn in the browser covers the rest.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function commonPasswords(): array {
|
||||
return [
|
||||
'password',
|
||||
'password1',
|
||||
'password12',
|
||||
'password123',
|
||||
'passw0rd',
|
||||
'p@ssword',
|
||||
'p@ssw0rd',
|
||||
'12345678',
|
||||
'123456789',
|
||||
'1234567890',
|
||||
'123123123',
|
||||
'qwertyui',
|
||||
'qwertyuiop',
|
||||
'qwerty123',
|
||||
'qwerty12',
|
||||
'1qaz2wsx',
|
||||
'zaq12wsx',
|
||||
'iloveyou',
|
||||
'princess',
|
||||
'sunshine',
|
||||
'football',
|
||||
'baseball',
|
||||
'basketball',
|
||||
'superman',
|
||||
'batman123',
|
||||
'trustno1',
|
||||
'welcome1',
|
||||
'welcome123',
|
||||
'letmein1',
|
||||
'letmein123',
|
||||
'admin123',
|
||||
'administrator',
|
||||
'abc12345',
|
||||
'abcd1234',
|
||||
'monkey123',
|
||||
'dragon123',
|
||||
'michael1',
|
||||
'jennifer',
|
||||
'starwars',
|
||||
'computer',
|
||||
'whatever',
|
||||
'freedom1',
|
||||
'changeme',
|
||||
'secret123',
|
||||
'login123',
|
||||
];
|
||||
}
|
||||
}
|
||||
+100
-22
@@ -19,6 +19,15 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class RegistrationPage {
|
||||
|
||||
/** "Who are you registering?": the account holder, and nobody else. */
|
||||
public const FOR_SELF = 'self';
|
||||
|
||||
/** Only other people — the account holder is not a student. */
|
||||
public const FOR_STUDENTS = 'students';
|
||||
|
||||
/** The account holder *and* other people. */
|
||||
public const FOR_BOTH = 'both';
|
||||
|
||||
/** Success signal: an invited student was created and logged in. */
|
||||
private const RESULT_INVITE = 'invite';
|
||||
|
||||
@@ -124,6 +133,28 @@ class RegistrationPage {
|
||||
// needed whenever the form itself is on screen.
|
||||
if ( $canRegister && '' === $successType ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
|
||||
// The browser gate reads the same numbers the server enforces, so the
|
||||
// two cannot drift into disagreeing about what it accepted.
|
||||
wp_localize_script(
|
||||
'us-scheduler-register',
|
||||
'usSchedulerPassword',
|
||||
[
|
||||
'minLength' => PasswordPolicy::MIN_LENGTH,
|
||||
'minScore' => PasswordPolicy::MIN_SCORE,
|
||||
'strings' => [
|
||||
'short' => sprintf(
|
||||
/* translators: %d: minimum number of characters. */
|
||||
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
|
||||
PasswordPolicy::MIN_LENGTH
|
||||
),
|
||||
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
|
||||
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
|
||||
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
|
||||
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
@@ -248,10 +279,6 @@ class RegistrationPage {
|
||||
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
|
||||
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
|
||||
|
||||
if ( strlen( $password ) < 8 ) {
|
||||
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// The email is fixed by a personal invite; group-link signups and
|
||||
// self-signups supply their own.
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
@@ -263,6 +290,15 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// After the email, so the password can be checked against it. The browser
|
||||
// scores the password with zxcvbn and refuses to submit a weak one, but
|
||||
// that is advice a client can decline to take — this is the check that
|
||||
// holds. See PasswordPolicy for why the two halves differ.
|
||||
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
|
||||
if ( null !== $passwordError ) {
|
||||
return esc_html( $passwordError );
|
||||
}
|
||||
|
||||
$policyForms = $this->signupPolicies();
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
|
||||
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
|
||||
@@ -276,17 +312,26 @@ class RegistrationPage {
|
||||
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
|
||||
// Registering as a parent/guardian turns the account-signup questions from
|
||||
// "about you" into "about each child" — they describe the student
|
||||
// (instrument, level, school), not the person holding the account.
|
||||
$isGuardian = $this->submittedIsGuardian();
|
||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||
$answers = $isGuardian ? [] : $this->submittedAnswers();
|
||||
// The account-signup questions describe a *student* — instrument, level,
|
||||
// school — not whoever holds the account. So they are asked of each
|
||||
// student being added, and of the account holder only when they are a
|
||||
// student themselves. "Both" is both.
|
||||
$registeringFor = $this->submittedRegisteringFor();
|
||||
|
||||
// "Students" and "both" collect student blocks; only "self" does not.
|
||||
$isGuardian = self::FOR_SELF !== $registeringFor;
|
||||
|
||||
// "Self" and "both" make the account holder a student, so they answer the
|
||||
// questions in their own right. Only a pure guardian does not.
|
||||
$asksSelf = self::FOR_STUDENTS !== $registeringFor;
|
||||
|
||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||
$answers = $asksSelf ? $this->submittedAnswers() : [];
|
||||
|
||||
// Everything is validated before a single user is created, so a bad child
|
||||
// block never leaves a half-registered family behind.
|
||||
if ( $isGuardian && [] === $children ) {
|
||||
return esc_html__( 'Please add at least one student, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
|
||||
return esc_html__( 'Please add at least one student, or choose "Just myself" instead.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
// Name and birth year are required per student, and are checked here for
|
||||
@@ -303,16 +348,18 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return $isGuardian
|
||||
? esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' )
|
||||
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
// Checked as two passes rather than one so the message can say *whose*
|
||||
// answers are missing — under "both" a single message could not.
|
||||
foreach ( array_column( $children, 'answers' ) as $set ) {
|
||||
if ( $this->hasUnansweredRequired( $accountQuestions, $set ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions for each student.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $asksSelf && $this->hasUnansweredRequired( $accountQuestions, $answers ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
if ( email_exists( $email ) ) {
|
||||
return esc_html__( 'An account already exists for this email.', 'unsupervised-schedular' );
|
||||
}
|
||||
@@ -333,12 +380,20 @@ class RegistrationPage {
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
||||
|
||||
// Only "students" means the account holder is not a student themselves;
|
||||
// "both" registers them alongside the people they book for.
|
||||
$this->guardians->setGuardianOnly( (int) $userId, self::FOR_STUDENTS === $registeringFor );
|
||||
|
||||
if ( $isGuardian ) {
|
||||
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
||||
if ( '' !== $failure ) {
|
||||
return $failure;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// After the children, so a rollback that deletes this account cannot
|
||||
// leave its answers behind pointing at a user that no longer exists.
|
||||
if ( $asksSelf ) {
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
}
|
||||
|
||||
@@ -481,12 +536,35 @@ class RegistrationPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "I'm registering as a parent or guardian" box was ticked.
|
||||
* Whether any required question in `$questions` is left blank in `$answers`.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers
|
||||
*/
|
||||
private function submittedIsGuardian(): bool {
|
||||
private function hasUnansweredRequired( array $questions, array $answers ): bool {
|
||||
foreach ( $questions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this signup is for: {@see FOR_SELF}, {@see FOR_STUDENTS} or
|
||||
* {@see FOR_BOTH}.
|
||||
*
|
||||
* Anything unrecognised — including a form posted without the field at all —
|
||||
* falls back to "just myself", the choice that collects the least and grants
|
||||
* the least. A missing radio must not be read as "register these children".
|
||||
*/
|
||||
private function submittedRegisteringFor(): string {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
|
||||
$value = sanitize_key( Val::string( wp_unslash( $_POST['us_registering_for'] ?? '' ) ) );
|
||||
|
||||
return in_array( $value, [ self::FOR_STUDENTS, self::FOR_BOTH ], true ) ? $value : self::FOR_SELF;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,17 @@ class GuardianService {
|
||||
*/
|
||||
public const META_DOB = 'us_date_of_birth';
|
||||
|
||||
/**
|
||||
* Set on an account that registered **only** to book for other people, so it
|
||||
* is not offered as a student in its own right.
|
||||
*
|
||||
* Stored as the negative on purpose. Every account that existed before this
|
||||
* choice was offered is a bookable student, and absence of the flag has to
|
||||
* keep meaning exactly that — otherwise the picker would quietly stop
|
||||
* offering people themselves on upgrade.
|
||||
*/
|
||||
public const META_GUARDIAN_ONLY = 'us_guardian_only';
|
||||
|
||||
/**
|
||||
* The earliest birth year the form will accept. Old enough for any student a
|
||||
* studio will ever enrol, and late enough to reject a typo like `19` or `190`
|
||||
@@ -228,7 +239,9 @@ class GuardianService {
|
||||
* their kid is the error worth designing out.
|
||||
*
|
||||
* The guardian is still offered, last, so a parent taking lessons alongside
|
||||
* their children can book for themselves from the same account.
|
||||
* their children can book for themselves from the same account — unless they
|
||||
* said at signup that they are not a student, in which case offering them is
|
||||
* an invitation to book a lesson nobody meant to buy.
|
||||
*
|
||||
* @return list<array{id: int, name: string, is_self: bool}>
|
||||
*/
|
||||
@@ -243,6 +256,13 @@ class GuardianService {
|
||||
];
|
||||
}
|
||||
|
||||
// A guardian-only account with nobody linked to it would otherwise get an
|
||||
// empty list and no way to book at all. Offering them themselves is the
|
||||
// lesser wrong: they can still correct the account from the profile page.
|
||||
if ( self::isGuardianOnly( $userId ) && [] !== $out ) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$self = get_userdata( $userId );
|
||||
|
||||
$out[] = [
|
||||
@@ -254,6 +274,28 @@ class GuardianService {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account books only for other people. False for every account
|
||||
* that predates the choice — see {@see META_GUARDIAN_ONLY}.
|
||||
*/
|
||||
public static function isGuardianOnly( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_GUARDIAN_ONLY, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record whether this account is a student in its own right. Clears the flag
|
||||
* rather than storing a `0`, so "not set" stays the single meaning of "yes,
|
||||
* they are a student".
|
||||
*/
|
||||
public function setGuardianOnly( int $userId, bool $guardianOnly ): void {
|
||||
if ( $guardianOnly ) {
|
||||
update_user_meta( $userId, self::META_GUARDIAN_ONLY, '1' );
|
||||
return;
|
||||
}
|
||||
|
||||
delete_user_meta( $userId, self::META_GUARDIAN_ONLY );
|
||||
}
|
||||
|
||||
/**
|
||||
* A guardian's children, in link order, with the details the family and admin
|
||||
* screens display.
|
||||
|
||||
@@ -91,7 +91,21 @@ class ShortcodeRegistrar {
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
/*
|
||||
* Progressive enhancement for the two-step registration form.
|
||||
*
|
||||
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
|
||||
* the signup form scores a password exactly the way wp-admin does rather
|
||||
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
|
||||
* fetches the (large) dictionary only once the page has loaded — hence
|
||||
* the guard in register.js for the window where it is not there yet.
|
||||
*/
|
||||
wp_register_script(
|
||||
'us-scheduler-register',
|
||||
USC_PLUGIN_URL . 'assets/js/register.js',
|
||||
[ 'password-strength-meter' ],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,17 @@ $studentsJson = wp_json_encode(array_values($students));
|
||||
<div id="us-my-lessons"></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($showBooking) : ?>
|
||||
<?php
|
||||
/*
|
||||
* Above the calendar, because it reports on what the student just did and
|
||||
* the calendar below it is what they do next. Filled and shown by
|
||||
* booking.js; empty and hidden until then.
|
||||
*/
|
||||
?>
|
||||
<div id="us-booking-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||
<div id="us-slot-list">
|
||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-booking-confirmation" style="display:none;">
|
||||
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,10 @@ if (! defined('ABSPATH')) {
|
||||
$studentsJson = wp_json_encode(array_values($students));
|
||||
?>
|
||||
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
||||
<?php /* Above the list, for the same reason as the booking page. */ ?>
|
||||
<div id="us-group-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||
<div id="us-group-list">
|
||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-confirmation" style="display:none;">
|
||||
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Unsupervised\Schedular\Auth\PasswordPolicy;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionField;
|
||||
|
||||
@@ -67,19 +69,43 @@ if (! defined('ABSPATH')) {
|
||||
</p>
|
||||
<p>
|
||||
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
|
||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
|
||||
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
|
||||
<?php
|
||||
/*
|
||||
* Filled in by register.js. `aria-live` announces the verdict as
|
||||
* it changes, and it starts empty so nothing is announced — or
|
||||
* takes up space — before anything has been typed.
|
||||
*/
|
||||
?>
|
||||
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
|
||||
</p>
|
||||
|
||||
<fieldset class="us-guardian">
|
||||
<legend><?php esc_html_e('Who are you registering?', 'unsupervised-schedular'); ?></legend>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" name="us_is_guardian" id="us-is-guardian" value="1">
|
||||
<?php esc_html_e("I'm registering as a parent or guardian, for one or more students", 'unsupervised-schedular'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php
|
||||
/*
|
||||
* Radios, not checkboxes: the three answers are mutually
|
||||
* exclusive, and "both" only means anything as a third
|
||||
* choice alongside the other two. "Just myself" is
|
||||
* pre-selected because it is the commonest signup and the
|
||||
* one that collects the least.
|
||||
*/
|
||||
$registeringForChoices = [
|
||||
RegistrationPage::FOR_SELF => __('Just myself', 'unsupervised-schedular'),
|
||||
RegistrationPage::FOR_STUDENTS => __('On behalf of one or more students', 'unsupervised-schedular'),
|
||||
RegistrationPage::FOR_BOTH => __('Both — myself and one or more students', 'unsupervised-schedular'),
|
||||
];
|
||||
?>
|
||||
<?php foreach ($registeringForChoices as $value => $label) : ?>
|
||||
<p>
|
||||
<label>
|
||||
<input type="radio" name="us_registering_for" value="<?php echo esc_attr($value); ?>" class="us-registering-for"<?php checked($value, RegistrationPage::FOR_SELF); ?>>
|
||||
<?php echo esc_html($label); ?>
|
||||
</label>
|
||||
</p>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php /* Revealed by the checkbox; without JS it is simply always visible. */ ?>
|
||||
<?php /* Revealed by the two student-bearing choices; without JS it is simply always visible. */ ?>
|
||||
<div class="us-children" id="us-children">
|
||||
<p class="us-children-intro"><?php esc_html_e('Add each student you will be booking lessons for. They do not need their own login — you book and pay for them from this account.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Auth\PasswordPolicy;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PasswordPolicyTest extends TestCase
|
||||
{
|
||||
public function testAcceptsAnOrdinaryMemorablePassword(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A leading or trailing space is a character like any other. Trimming it
|
||||
* would accept a password the user could then never type back.
|
||||
*/
|
||||
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
|
||||
|
||||
// Seven characters counting both spaces: one short, and still one short
|
||||
// after the spaces are counted rather than stripped.
|
||||
self::assertNotNull(PasswordPolicy::validate(' short '));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider tooShort
|
||||
*/
|
||||
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function tooShort(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [''],
|
||||
'one short' => ['sevench'],
|
||||
'a few chars' => ['abc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider commonPasswords
|
||||
*/
|
||||
public function testRejectsAWellKnownPassword(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function commonPasswords(): array
|
||||
{
|
||||
return [
|
||||
'password123' => ['password123'],
|
||||
'shouting' => ['PASSWORD123'],
|
||||
'mixed case' => ['PassWord123'],
|
||||
'a keyboard walk' => ['qwertyuiop'],
|
||||
'digits in a row' => ['123456789'],
|
||||
'the classic' => ['iloveyou'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider tooFewDistinctCharacters
|
||||
*/
|
||||
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
|
||||
{
|
||||
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function tooFewDistinctCharacters(): array
|
||||
{
|
||||
return [
|
||||
'one character' => ['aaaaaaaaaa'],
|
||||
'two alternating' => ['abababababab'],
|
||||
'three' => ['abcabcabcabc'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider identityEchoes
|
||||
*/
|
||||
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
|
||||
{
|
||||
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string, string}> */
|
||||
public static function identityEchoes(): array
|
||||
{
|
||||
return [
|
||||
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
|
||||
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
|
||||
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
|
||||
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A two- or three-letter overlap with a name is coincidence, not a weakness,
|
||||
* and refusing it would be baffling to the person typing.
|
||||
*/
|
||||
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
|
||||
}
|
||||
|
||||
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
|
||||
{
|
||||
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,13 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
||||
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
|
||||
// Every submit reads the "who are you registering?" radio through it.
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
|
||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||
// Reached on every submit now that the email is validated before the
|
||||
// password, so the password can be checked against it.
|
||||
Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v));
|
||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||
// The birth-year check reads current_time('Y'), so answer that format
|
||||
// properly rather than leaving it to cast out of the datetime string.
|
||||
@@ -45,6 +50,7 @@ class RegistrationPageTest extends TestCase
|
||||
);
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||
Functions\when('wp_localize_script')->justReturn(true);
|
||||
|
||||
$invites = Mockery::mock(InviteRepository::class);
|
||||
$policies = Mockery::mock(PolicyRepository::class);
|
||||
@@ -70,6 +76,8 @@ class RegistrationPageTest extends TestCase
|
||||
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
||||
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
|
||||
// Recorded on every successful signup; the tests that care assert on it.
|
||||
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->byDefault();
|
||||
|
||||
$this->ctx['page'] = new RegistrationPage(
|
||||
$invites,
|
||||
@@ -105,6 +113,17 @@ class RegistrationPageTest extends TestCase
|
||||
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
||||
}
|
||||
|
||||
/** Everything the invite success branch touches once the account is created. */
|
||||
private function stubInviteSuccess(): void
|
||||
{
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
||||
Functions\when('wp_set_current_user')->justReturn(null);
|
||||
Functions\when('wp_set_auth_cookie')->justReturn(null);
|
||||
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
||||
}
|
||||
|
||||
private function submit(?Invite $invite, bool $open): string
|
||||
{
|
||||
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
|
||||
@@ -114,7 +133,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testInviteBranchCreatesAndLogsInTheStudent(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
@@ -131,7 +150,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
@@ -151,7 +170,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Functions\when('is_email')->justReturn(true);
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
@@ -178,7 +197,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Functions\when('is_email')->justReturn(true);
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
@@ -346,7 +365,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Functions\when('is_email')->justReturn(true);
|
||||
|
||||
@@ -365,7 +384,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
|
||||
{
|
||||
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
||||
|
||||
Functions\when('is_email')->justReturn(true);
|
||||
|
||||
@@ -385,7 +404,7 @@ class RegistrationPageTest extends TestCase
|
||||
public function testRecordsAccountAnswersOnSuccess(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Ada',
|
||||
'us_answers' => [ '5' => 'By a friend' ],
|
||||
];
|
||||
@@ -419,7 +438,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
|
||||
{
|
||||
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
|
||||
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
||||
$_REQUEST = [ 'us_invite' => 'raw-token' ];
|
||||
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
@@ -618,9 +637,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [
|
||||
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => 'Violin']],
|
||||
@@ -656,12 +675,251 @@ class RegistrationPageTest extends TestCase
|
||||
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser gates on zxcvbn, but that is advice a client can decline to
|
||||
* take. Nothing is created for a password the server refuses.
|
||||
*
|
||||
* @dataProvider refusedPasswords
|
||||
*/
|
||||
public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void
|
||||
{
|
||||
$_POST = [
|
||||
'email' => '[email protected]',
|
||||
'password' => $password,
|
||||
'display_name' => 'Grace Hopper',
|
||||
];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
self::assertStringContainsString(
|
||||
$expected,
|
||||
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function refusedPasswords(): array
|
||||
{
|
||||
return [
|
||||
'too short' => ['abc123', 'at least'],
|
||||
'a known password' => ['password123', 'commonly used'],
|
||||
'barely any variety' => ['ababababab', 'repeated characters'],
|
||||
'their own name' => ['grace-hopper-1906', 'name or email'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testSignupRefusesAnAddressThatIsNotAnEmail(): void
|
||||
{
|
||||
$_POST = [
|
||||
'email' => 'not-an-email',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
self::assertStringContainsString(
|
||||
'valid email address',
|
||||
$this->submit(null, true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "On behalf of students" is the one choice that says the account holder is
|
||||
* not a student, so it is the one that sets the flag.
|
||||
*/
|
||||
public function testRegisteringOnlyForStudentsMarksTheAccountGuardianOnly(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||
];
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, true);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider modesThatKeepTheAccountHolderAStudent
|
||||
*/
|
||||
public function testTheAccountHolderStaysAStudentForTheOtherTwoChoices(string $mode, bool $withChildren): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => $mode,
|
||||
];
|
||||
|
||||
if ($withChildren) {
|
||||
$_POST['children'] = [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]];
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
}
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, bool}> */
|
||||
public static function modesThatKeepTheAccountHolderAStudent(): array
|
||||
{
|
||||
return [
|
||||
'just myself' => [RegistrationPage::FOR_SELF, false],
|
||||
'myself and students' => [RegistrationPage::FOR_BOTH, true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Both" collects students exactly as "on behalf of" does — the only
|
||||
* difference is whether the account holder is one of them.
|
||||
*/
|
||||
public function testBothStillRequiresAtLeastOneStudent(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'children' => [],
|
||||
];
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
self::assertStringContainsString('at least one student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* A form posted without the radio — an old cached page, or a crafted
|
||||
* request — must fall to the choice that collects and grants the least,
|
||||
* never be read as "register these children".
|
||||
*/
|
||||
public function testAMissingOrUnknownChoiceFallsBackToJustMyself(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => 'something-else',
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||
];
|
||||
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([]);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
// No student is created from children[] the caller never asked to register.
|
||||
$this->ctx['guardians']->shouldNotReceive('createChild');
|
||||
$this->ctx['guardians']->shouldReceive('setGuardianOnly')->once()->with(42, false);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Under "both" the account holder is a student too, so the studio's
|
||||
* questions are asked of them as well as of each student they add. Before
|
||||
* this they were asked per student only, and the account holder's own
|
||||
* answers were never collected or stored.
|
||||
*/
|
||||
public function testBothRecordsAnswersForTheAccountHolderAndEachStudent(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'us_answers' => ['7' => 'Cello'],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||
];
|
||||
|
||||
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$recorded = [];
|
||||
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||
static function (Answer $answer) use (&$recorded): int {
|
||||
$recorded[] = [$answer->studentId, $answer->answerValue];
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
|
||||
// The student's answer against the student, the account holder's against
|
||||
// themselves — not one answer shared between them.
|
||||
self::assertEqualsCanonicalizing([[101, 'Piano'], [42, 'Cello']], $recorded);
|
||||
}
|
||||
|
||||
public function testBothRejectsAnUnansweredQuestionForTheAccountHolder(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => RegistrationPage::FOR_BOTH,
|
||||
'us_answers' => ['7' => ' '],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||
];
|
||||
|
||||
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\expect('wp_insert_user')->never();
|
||||
|
||||
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
||||
|
||||
// The message names nobody else — the student's answer was fine.
|
||||
self::assertStringContainsString('Please answer all required registration questions.', $result);
|
||||
self::assertStringNotContainsString('for each student', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pure guardian is not a student, so the questions are theirs to answer
|
||||
* per student and never about them. Anything posted for them is ignored.
|
||||
*/
|
||||
public function testRegisteringOnlyForStudentsStoresNoAnswersForTheAccountHolder(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'us_answers' => ['7' => 'Should be ignored'],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']]],
|
||||
];
|
||||
|
||||
$question = new Question(null, 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7);
|
||||
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([$question]);
|
||||
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
||||
$this->stubInviteSuccess();
|
||||
|
||||
$students = [];
|
||||
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
||||
static function (Answer $answer) use (&$students): int {
|
||||
$students[] = $answer->studentId;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
||||
self::assertSame([101], $students);
|
||||
}
|
||||
|
||||
public function testGuardianSignupWithNoChildrenIsRejected(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
|
||||
];
|
||||
|
||||
@@ -683,9 +941,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testGuardianSignupRejectsAHalfFilledChildRatherThanDroppingIt(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [
|
||||
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
|
||||
['name' => '', 'birth_year' => '2017', 'answers' => []],
|
||||
@@ -709,9 +967,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testGuardianSignupRejectsAChildWithoutAUsableBirthYear(string $submitted): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [['name' => 'Ada', 'birth_year' => $submitted, 'answers' => []]],
|
||||
];
|
||||
|
||||
@@ -743,9 +1001,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [
|
||||
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
||||
['name' => 'Alan', 'birth_year' => '2017', 'answers' => [7 => ' ']],
|
||||
@@ -770,9 +1028,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'children' => [
|
||||
['name' => 'Ada', 'birth_year' => '2015', 'answers' => []],
|
||||
['name' => 'Alan', 'birth_year' => '2017', 'answers' => []],
|
||||
@@ -807,9 +1065,9 @@ class RegistrationPageTest extends TestCase
|
||||
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
|
||||
{
|
||||
$_POST = [
|
||||
'password' => 'password123',
|
||||
'password' => 'thistle-marrow-42',
|
||||
'display_name' => 'Grace',
|
||||
'us_is_guardian' => '1',
|
||||
'us_registering_for' => RegistrationPage::FOR_STUDENTS,
|
||||
'accept' => [3],
|
||||
'children' => [['name' => 'Ada', 'birth_year' => '2015', 'answers' => []]],
|
||||
];
|
||||
@@ -846,7 +1104,7 @@ class RegistrationPageTest extends TestCase
|
||||
|
||||
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
|
||||
{
|
||||
$_POST = ['password' => 'password123', 'display_name' => 'Ada'];
|
||||
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
|
||||
|
||||
Functions\when('email_exists')->justReturn(false);
|
||||
Functions\when('wp_insert_user')->justReturn(42);
|
||||
|
||||
@@ -17,6 +17,32 @@ abstract class TestCase extends BaseTestCase
|
||||
Monkey\setUp();
|
||||
Monkey\Functions\stubTranslationFunctions();
|
||||
Monkey\Functions\stubEscapeFunctions();
|
||||
|
||||
// WordPress's form-state helpers. Stubbed here rather than per test
|
||||
// because they are pure output helpers with no behaviour worth faking
|
||||
// differently anywhere — a template either emits the attribute or not.
|
||||
Monkey\Functions\when('checked')->alias(
|
||||
static fn (mixed $checked, mixed $current = true, bool $echo = true): string
|
||||
=> self::formAttribute('checked', $checked, $current, $echo)
|
||||
);
|
||||
Monkey\Functions\when('selected')->alias(
|
||||
static fn (mixed $selected, mixed $current = true, bool $echo = true): string
|
||||
=> self::formAttribute('selected', $selected, $current, $echo)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors WordPress's `__checked_selected_helper()`, echo included. */
|
||||
private static function formAttribute(string $type, mixed $helper, mixed $current, bool $echo): string
|
||||
{
|
||||
// WordPress compares loosely, and templates rely on that: `checked($a, $b)`
|
||||
// is routinely called with an int against a numeric string.
|
||||
$result = $helper == $current ? " {$type}='{$type}'" : ''; // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
|
||||
|
||||
if ($echo) {
|
||||
echo $result; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed literal.
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.3.1
|
||||
* Version: 1.4.0
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.3.1');
|
||||
define('USC_VERSION', '1.4.0');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user