The confirm-password screen hid its field from every account that was
not Local, and told it to set a password instead. That is right for an
account a provider created, which has no password anybody has seen. It
is wrong for a directory account: its password is the directory's,
PasswordVerification accepts it, and /settings/password refuses to let
it set another. So an LDAP account could not get past the confirmation
at all, and everything behind it, turning on two-factor included, was
out of reach. The new dialog copied the same question.
Both now ask whether the account came from a provider, and the prop is
called has_password, which is what it means. The dialog also clears
the typed password when it closes or once it has been used, instead of
keeping it in component state.
password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.
An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
Reported by binghuo. With per-client folders switched on, a client editing
one of their own files could choose "No folder" and the file left their
home for the root of the library — beside the staff folders, where the
administrator's own things are. The feature exists precisely to stop that
mess, and the editor was the one door still open to it.
Uploading resolves an absent folder to the client's home, and so does
creating a folder without naming a parent. The portal's file editor did
not, so the rule held on two paths out of three.
Now it holds on all three: update() resolves a null folder to the home
where the installation gives them one, and the editor stops offering "No
folder" at all in that case — there is no such place for this client — and
preselects their home for a file that has none.
An installation with the setting off is unchanged: no folder still means
no folder, because there every client's file sits at the root.
Reported by Ricardo Cazati, who ticked "create an account on first
sign-in" and "approve those accounts automatically", named his domain,
and then found every colleague's account sitting in the approval queue
with nothing anywhere saying why.
The rule is right and stays: an address the provider has not confirmed
goes to the queue whatever the box says, because inside one Entra tenant
a colleague can present somebody else's address. What was missing is that
Microsoft confirms nothing until `xms_edov` is added to the app
registration — an administrator configuring their own company directory
reads "verified" as already true of it.
The tenant-ID field mentioned that claim, but only for the half about
never attaching to an existing account. The other half — that this is
also why auto-approval does nothing — now sits under the auto-approve
checkbox itself, where the promise is made, and only when the three
settings that produce the surprise are all on.
Reported by Ricardo Cazati. A client with every file permission could
mark their own file public — and was then shown nothing. The screen said
"anyone with the link will be able to open and download it" while the
only route that makes a link was staff-only, so the link existed for
nobody. Version 1 could do this.
Making one now asks `upload_public`, the same key that lets them mark the
file public, and `update` on the file, which for a client means one they
uploaded and nothing else. Staff are not asked for the key, as they never
have been: `update` is their boundary and asking now would be a new
refusal on every installation that upgrades.
Revoking deliberately does not ask for the publishing key. It takes
access away, and somebody whose permission to publish was withdrawn must
still be able to undo what they published.
The portal's file editor grows the section the staff screen has, minus
what a client has no business setting: the link, a copy button, and
revoke.
Reported by Ricardo Cazati, who had to turn compulsory two-factor off to
get his colleagues working.
An account provisioned by a provider carries a generated password nobody
has ever seen. The password screen asked for the current one before it
would set a new one, so those accounts could never have a password of
their own — and enrolling in two-factor is behind a password
confirmation, so they could not enrol either. With
`TwoFactorEnforcement` set, the enforcement middleware sent them to
enrol, enrolling sent them to confirm a password they do not have, and
every other screen — including the one that would have given them one —
redirected back. No way in and no way out.
- The password screen asks for the current one only where there is one,
and says "Set a password" otherwise. Setting it moves the account to
`local`, the line NewPasswordController already writes when such an
account resets its password: the hash is now what signs it in, and the
settings screens read that off this column.
- An LDAP account is refused outright rather than handed a password that
signs nothing in — its password lives in the directory.
- The enforcement middleware lets the password screen through, the way it
already lets the confirm-password screen through, so the loop has an
exit.
- The confirm-password screen offers to set one instead of asking for a
password that does not exist.
An installation that scans can still let files through: too large for the
scanner, an archive it could not open, or an upload that arrived while the
scanner was down. Both policies default to letting those through, and the
count of them is on the settings screen and the dashboard.
Everyone could see that except the one person it matters to. The uploader
sees the state on their own file and staff see it in the library; whoever
follows a public link saw the page a file that passed gets, having neither
chosen the policy nor any way to see the setting. On the hosted free plan,
where every upload is published behind a link, that is the whole audience.
The share page and the public file page in all four themes now carry one
line: "This file was not checked for viruses." Said plainly and without
alarm — nothing is known to be wrong with the file; what is known is that
nothing looked.
Only where this installation scans, and only for the three reasons that
mean a scanner let something past. A file from before scanning was
switched on says nothing: on an installation that has only just switched
it on that is every file, and saying it about all of them says it about
none of them.
Client file rows in My files now carry expires_at, and all four portal
themes show "Available until <date>" beside the size, date and download
count. Before, the date was only on the file's edit screen, so a file
about to disappear gave no sign of it where the client looks for it.
The portal upload page dispatches ResolvingUploadNotice, handing the
uploader to listeners, and shows any lines they add above the uploader.
The upload page is one page for every theme, so this is the only place a
rule about uploads can be read before one happens; the announcement band
is drawn by one theme's shell and would miss the other three. With
nothing listening it shows nothing. First caller: cloud-modules, telling
free-plan customers how long uploads are kept.
"Detected the test file" left it open what the file was, which reads
like ProjectSend carrying something malicious. The Test button's
description and both of its results now name EICAR and say it is a
harmless file made only for testing, as do DOCKER.md and INSTALL.md.
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.
With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.
The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.
What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".
Some decisions worth naming:
- **A column, not a convention.** `folders.home_for_user_id`, unique.
Matching on the name breaks the moment two clients share one, and
`created_by` plus a null parent catches every root folder a client ever
made themselves. The question is asked on each upload and each portal
listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
already grants somebody their own folder -- no assignment row to keep in
step with it. That is also why this writes the row rather than calling
FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
There are nine of those (ClientAccounts, ClientProvisioning, the profile
screen, two update endpoints, AccountConversion, invitations, LDAP,
social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
when an administrator presses a button that says how many are waiting,
and it reports created/total/already-had afterwards. Somebody should be
able to switch this on, look, and switch it off without having
reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
client cannot rename theirs -- they own it, so ownership alone would have
let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
still called "Acme Ltd" under an account now called something else
misleads the administrator the feature exists for.
Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
A quarantined file was listed in the library with every button a working
file has, and Download answered with an error page. Three changes, all
the same idea: do not offer what cannot be done.
The library no longer lists a file that is quarantined or missing from
storage. Those two live on the screens that exist to act on them —
Quarantine, and Files missing from storage — and both now link each row
to the file itself, which is where somebody deciding needs to look.
That page says why, at the top, in the colour the state deserves: red
for a threat, amber for bytes that are gone. And it stops offering the
download and the preview, because a button that answers 423 is not an
affordance.
A file still being checked stays in the library. It is about to be
usable, and its uploader should be able to see where it went.
The button was disabled on a library that had already been scanned
once, which is most of the time and exactly when somebody would press
it — after updating definitions, say. It now re-checks everything
rather than only what was never looked at, which is what its name says.
A rescan keeps each file available until its new verdict arrives, so a
full pass takes nothing offline. The two states it skips are a file
already waiting for its first verdict and one whose bytes are gone.
It is disabled for two honest reasons now — scanning is off, or a scan
is already running — and says which.
Results are green, amber and red: checked and fine, checked and could
not be read, checked and something was found. The badge gained a
warning variant to say the middle one, matching the amber the warning
alert already uses; before this a missing file wore the same red as a
virus.
A file found missing is also stamped with the time it was checked, so
it appears in the Activity list. It is a verdict like any other, and
without the stamp it was decided somewhere nobody could see.
The Test button asked the scanner on file, which makes it useless at
the moment it is most needed: the first attempt, before anything has
been saved. It now tries what is typed, falling back to the stored
address when the field is empty so the button still answers on a screen
nobody has touched. Nothing is written either way — testing is not
saving.
The address travels as a request field and is applied to the request's
own ScanningConfig, which is scoped so the screen and the scanner it
resolves share one. A preview address beats even a managed one, and is
set in exactly one place.
Quarantine now carries a count in the sidebar, like Comments — in amber
rather than the usual colour, because the others count work waiting and
this one counts something that went wrong. Shown only to whoever holds
the permission to act on it.
Both checked in a browser: an address typed and not saved came back
"No answer from tcp://escrito-a-mano.invalid:3310", the stored one
untouched, and the badge renders amber with the real count.
It sat above the form, which read as a box about the screen rather than
about the field. It belongs under "Scanner address" and above Save,
where it is plainly the answer to "is this address right?".
The box is now shown only where there is something to test — an
installation that connects its own scanner. On a hosted one the endpoint
answers 403, and a button that leads to a refusal is worse than no
button. That is a separate prop from `managed`, which covers two
different reasons the address is not editable: a scanner named in the
environment still has a connection worth testing.
"New scan" sits beside Quarantine as the screen's one action, in the
primary colour, on every tab. The "Files already here" box it replaces
is gone: it held an action under a Save button, a counter that the
Activity tab now shows live, and a field that belongs with the other
settings, which is where it is now.
The button says why when it cannot be pressed — scanning is off, every
file has already been checked, or a scan is already running — rather
than sitting grey with no explanation. It refuses a second scan while
one is working through the queue, which is a thing somebody would
otherwise do by clicking twice.
Starting one lands on the Activity tab. A button whose screen looks
unchanged afterwards reads as a button that did nothing, and this one
has somewhere worth looking.
Checked against a real ClamAV: 42 files queued, 31 came back clean, the
rest were the ones whose bytes are missing. The button then disabled
itself, because there was nothing left to scan.
A row whose bytes are gone was recorded as "the scanner could not be
reached". Wrong on screen, and wrong underneath: that is the one reason
the hourly sweep re-queues, so every orphaned row would have been
rescanned hourly forever.
It is its own state now, `missing`, and withheld rather than offered:
a client who sees a file listed and gets an error on the download is
worse off than one who never saw it. Staff still see it, marked, which
is the point — somebody has to decide what to do about it. The refusal
says what it is ("no longer on the server") instead of sending somebody
looking for a permission that would let them through.
A daily `projectsend:check-missing-files` finds them, whether or not
this installation scans for viruses: it is not a virus question, and an
installation with no scanner has exactly the same problem. It compares
one disk listing against the rows rather than asking "does this exist?"
per file, which on object storage would be a request per file per day.
Files that come back — a remount, a restored backup — are picked up on
the next run and re-checked rather than left for dead.
They are listed beside the orphans, which is the same fault seen from
the other end: bytes with no row, rows with no bytes. The tab carries
the count, each row says where the file should be, and removing one
takes the record with it through the deletion that already exists.
The dashboard says how many there are, and so does
`projectsend:status`, because a fleet-wide jump in this is a storage
fault nothing else in that document would show.
A backfill runs for minutes or hours inside a queue worker, where none
of it is visible. The third tab polls every four seconds and says what
is happening: whether anything is running, how many uploads are held,
how deep the queue is, how many files were checked in the last hour,
and the last twenty verdicts with what each one was. When nothing is
running, that same list is the record of the last run, which is what
somebody opening the tab after the fact came for.
Two things the live screen found that the tests had not:
**A backfill read as "nothing is being scanned."** Re-scanning a file
that already went out unchecked deliberately leaves it available, so it
is never "pending" — and the screen counted only pending files. It
counts the scans queue too, and the two are shown separately, because
"an upload nobody can download yet" and "work the scanner has not
reached" are different facts.
**A file whose bytes are missing was recorded as "the scanner could not
be reached."** Wrong on screen, and worse than wrong in behaviour: that
is the one reason the hourly sweep re-queues, so every orphaned row
would have been rescanned every hour forever. It has its own reason
now, and goes through the same policy as a file the scanner could not
open.
Both tabs also gained the header shortcut to Quarantine, and Quarantine
one back to the settings, each shown only to somebody the destination
will actually let in.
The Test button did nothing visible. The page read `scanner_test_result`
off the shared props, and HandleInertiaRequests shares `success` and
`error` and nothing else — so the answer was set on the session and
never arrived. It is read in the controller and handed over as a prop
now, the way the CAPTCHA screen does it.
The screen is two tabs, Scanner and Options, following the scheduler's
`?tab=` links. Scanner holds the connection and the Test button; Options
holds the policies and the backfill. Both end with their Save, and
nothing sits below it — before this, "Files already here" and its button
were stranded under the Save button of a form they had nothing to do
with.
Verified by clicking the real button in a browser against a real ClamAV:
"Working. ClamAV 1.5.4 detected the test file as Eicar-Test-Signature."
Staff keep seeing every file they always saw — withholding is about
recipients, not about the library — so the library now carries the state
on the row: Checking, Quarantined, Released, or Not scanned with the
reason behind it. Nothing at all for a clean file, which is the common
case.
The API says the same in a `scan` object on every file, with an
`available` flag so a caller need not learn which of six states mean
"you can have it", and `scan_status` is a filter, so an integration can
wait for the file it just uploaded or collect what is in quarantine.
The download endpoint answers 423 for a file that is not available,
which it already did through the shared controller.
Re-exported the OpenAPI document.
Settings → Virus scanning: switch it on, point it at a ClamAV daemon,
choose the two policies, and see how many files are waiting, in
quarantine, or were let through unscanned. Switching it on with no
address is refused rather than saved and left inert.
The Test button is three answers, not one. Unreachable is obvious.
Reachable but detecting nothing is the failure that looks like success
— empty or broken virus definitions — so the test sends the EICAR
string and reports "it found the test file", never "it did not
complain". The string is assembled at runtime so no checkout contains
it: antivirus software on a developer's machine quarantines files that
do.
"Scan existing files" queues the library that predates scanning,
through the hourly command so no request is held open, paced by a
setting so it does not starve today's uploads.
Where the environment names a scanner, the connection and the on/off
switch leave the screen and scanning cannot be turned off — the same
managed shape the CAPTCHA screen has. The two policies stay editable,
because what to do with a file nobody could scan is a decision about
somebody's own files.
An infected file now goes somewhere rather than nowhere. Staff holding
the new release_quarantined_files permission get a Quarantine screen
listing what was refused, who uploaded it, and what the scanner called
it. They can delete it as they always could, or release it — which
needs a written reason, a password confirmation on top of the
permission, and lands in the activity log under their name.
Only the administrator role holds that permission by default. Deciding
a threat report is wrong is a different judgement from deciding a file
is no longer needed, which is why it is not delete_files.
Two notifications, two audiences: staff who can act on it, and the
person who uploaded it — for whom this is how they learn their own
machine has something on it. The people the file was shared with are
deliberately not told about a file they never received.
`projectsend:scan-files` runs hourly: it re-queues files still waiting,
and re-scans the ones that went out unscanned while the scanner was
unreachable, since it may be back. With --existing it also works
through a library uploaded before scanning was switched on, paced by a
setting so it does not starve today's uploads.
A file that was downloadable before it was caught says so on the
screen, with its download count, because that is the case where
somebody may already have a copy.
Every upload now starts as "being checked" and is not served to anyone
until a scanner has looked at it. Infected files are quarantined: kept
on disk, unreachable, waiting for an administrator.
The scanner is ClamAV, reached over a socket, streaming the file
wherever it is stored — no temporary copy for an S3 or GCS disk. What
the scanner answers is a fact; what it means for the file is this
installation's setting, so ClamAvScanner knows nothing about settings
and ScanPolicy knows nothing about sockets. Three of clamd's own alert
options are what make a file it could not open come back as an answer
rather than as "OK"; the client maps those to "too large" and
"encrypted" instead of to a threat.
Both policies default to letting files through, marked "not scanned",
which is the product owner's decision: a scanner that cannot answer must
not stop people working. Every such file is logged, and the screens that
say so come with the rest of this work.
Withholding is two rules. A file that is not available drops out of the
scopes that answer "what may this person see" — recipients and the
public listings, never the uploader's own copy. And every route that
puts bytes on the wire asks FileAvailability first: download, thumbnail,
preview, share link, the four public routes and both ends of a zip
build. A share link minted before the scan finishes says the file is
still being checked rather than 404ing.
Not yet here, and coming next: the quarantine screen and its permission,
the notifications, the settings screen, the hourly retry, the backfill
for existing libraries, and the Docker service.
Search and the category dropdown were the whole filter bar, which is thin
for a library of any size. It now also narrows by:
- who uploaded the file, and separately by the role they hold
- public or private
- never downloaded, or downloaded at least once
- current version, or outdated
Each one forces the same flat, whole-library view search already used, and
they combine.
Two decisions worth naming.
"Public" means what the badge on the row means -- File::isEffectivelyPublic(),
the file's own flag or a public folder anywhere above it. Filtering on the
`public` column alone would have hidden files this very screen labels
Public, which is a filter arguing with the list it filters. The private half
needs its own null branch, because `folder_id NOT IN (...)` is never true for
a NULL folder_id: without it a file at the library root belonged to neither
half and vanished from both. Removing that branch turns the private filter
from one row to zero, which is the test.
The uploader filter carries the same guard /api/v1/files puts on
`uploaded_by`. fileRow() already withholds an uploader's name from a viewer
who may not identify them, so answering this filter plainly would have handed
the same identity straight back as a row count. An id the caller may not
identify now matches nothing, which is indistinguishable from someone who
uploaded nothing, and the dropdown is built through filterClientPairs so it
never offers the name either. Without the guard the scoped-staff test gets
its stranger's file back.
"Outdated" rather than "superseded" throughout, because that is the word the
version badge already uses and the two should not disagree. A file nothing
has replaced counts as current, including one never versioned at all.
The ids are cast out of the validated input: `integer` validates "5" without
converting it, and permitsClientId() takes a strict ?int.
A role now has a start page: the dashboard, files, upload, groups,
clients or the activity log (the last two for staff only). Anyone can
override their role's choice in their profile. The administrator role
takes a start page too, while everything else about it stays locked.
A choice is only used if the account can open that page now. Otherwise
the next one down is tried, ending at the dashboard, so a permission
removed later never lands somebody on a 403. A role cannot be saved
with a start page its own permissions block. A link followed before
signing in still wins, and a waiting getting-started or what's-new page
still goes first.
Applies to password, two-factor and provider sign-ins, and to the site
root for someone already signed in. StartPageTest opens every page for
real, with and without its permission, so the enum cannot drift from
the routes.
Requested by @Zodiac1978 in #1777.
Staff can give a client an expiry date on the create and edit screens,
and through /api/v1/clients. When the date passes, the client is refused
at sign-in and on their next request, and their API access ends too.
Files and history stay, and a later date (or none) brings them back.
Access is checked through one predicate, User::maySignIn(), at every
door: sign-in, the web session, API tokens and the two-factor
challenge. An hourly sweep also switches `active` off, so the list,
its filter and seat counts agree. The sweep is not what enforces it,
so a scheduler that is not running cannot keep an account open.
An account cannot be active with a date that has passed. Reactivating
an expired client needs a new date in the same save.
The day-means-end-of-day-where-you-are rule moved out of FileExpiry
into a shared DateInput, so file and account expiry read dates the
same way.
Requested by @Drardollan in #1310.
The "Invite client" button led to a history, which is not what it says. The
tabs were a way of housing two things that had nowhere else to live, and
now they do: Invitations is a sidebar entry between Custom fields and
Groups, and the button goes to the form.
That is also the shape every other list in this application already has --
Clients, Groups, Categories, Roles all sit in the sidebar with a "New X"
button leading to their own create screen -- so the tabs were the odd one
out rather than the pattern. Two URLs, each meaning one thing:
/clients/invitations is the history, /clients/invitations/create is the
form. Sending now returns to the history, where the invitation just sent
is the first row.
No badge on the sidebar entry, deliberately, unlike the two queues below
it. Account requests and Membership requests count things waiting on
somebody here; an outstanding invitation is waiting on the person who was
invited. A number there would say "you have three things to do" about
three things nobody in this installation can act on.
Translations move with it: "History (:count pending)" was the tab label and
is gone from all sixteen, and "Invite a client to share files with" comes
back -- it was the form's description before the tabs took the heading, and
had never been translated because it left the code in the same commit that
would have reported it missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
Sixteen locales, forty-one strings each: the invite screen and its
history, the redemption and expired-link pages, the invitation email, the
expiry setting, and the four new activity-log entries.
Two English strings were fixed before translating rather than after.
"Waiting" was a second word for what this application has always called
Pending -- account requests and membership requests both use it -- and a
synonym is much harder to take back once it exists in sixteen files. And
the submit button said "Send invitation" while the tab beside it said
"Send an invitation", which is a distinction a translator has to stop and
puzzle over to discover there isn't one. Both now reuse what was already
there, which is also why this pass is forty-one strings rather than
forty-three.
Terminology was read off each catalogue rather than chosen. Every locale
already had its own words for client, group, expired, revoke and status,
and these strings reuse them exactly, so a badge in the new table reads in
the same vocabulary as the filter above it. Formality follows each file
too -- informal in ca, es, it, nl, pl and zh_CN, formal in cs, de, fr, id,
pt_BR, ru and tr -- which for Polish meant following the catalogue rather
than the note in the skill, since its existing strings say "Cześć" and
"Twoje konto".
The numeral trap caught one for real, and only because the screen was
looked at: "Historial (1 pendientes)". Spanish, Catalan and Portuguese
inflect that adjective with the number, so those three now use the
"label: :count" shape the Slavic locales already use, and Swahili follows
for the same reason. French, German, Dutch, Italian, Turkish, Indonesian
and Vietnamese keep the natural word order because their word does not
move.
Verified by rendering the screen in Spanish, German, Japanese and Polish
against real rows in all five states -- nothing overflows, and the German
column headers wrap rather than collide. Enabling every locale to do that
meant changing a live setting: it was ["en","es"] and is ["en","es"] again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
The tab listed only what was still live, which cannot answer the question
somebody actually arrives with: did we ever invite this person, and what
happened? An invitation that was accepted, revoked or replaced by a newer
one simply vanished from the screen, and the activity log was the only
place left to look.
So the tab is a history now -- every invitation ever sent, newest first,
each carrying the state it ended in -- with a status filter for reading one
slice of it. And it opens first, because arriving here the question is
usually about what has already been sent, including to the person you were
about to invite again. ?tab=send still goes straight to the form.
Five states, and two of them needed deciding:
- "Expired" is not a stored status and deliberately is not one: nothing
writes it, a row becomes expired by the clock passing rather than by
anybody acting, and storing it would need a scheduled task to stay true.
Invitation::state() derives it, once, and both the badge and the filter
read that -- two copies of the rule is how they start disagreeing about a
row whose expiry passed a second ago.
- "Replaced" is what superseded says to somebody who is not reading the
source. It is a different fact from expired, and worth telling apart: one
ran out, the other was retired by a newer invitation to the same address.
The count in the tab label stays a count of live invitations rather than of
the rows below. The history is mostly settled, and the number worth
carrying in a label is the one that says whether anybody is still waiting --
which is also why it is counted over the table rather than the filtered
page, so narrowing the list cannot change it.
Revoke appears only on a row that still has something to revoke, and the
expiry column is blank on a settled one: the date is still stored and still
true, and printing it invites somebody to wonder what expires about an
invitation that was accepted.
The screen is called Invitations now, in the heading and the breadcrumb.
With the history first it is no longer a form with a list under it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
They were stacked: the form, then the list under it. Two different jobs on
one scroll, and the list -- the half somebody opens this screen to act on
rather than to fill in -- sat below the fold on any installation with a few
invitations out.
Two tabs now, the same plain border-bottom nav the staff account screen and
the theming settings already use. The count rides in the tab label, because
the reason to open that half is that something is waiting in it.
Three details worth stating:
- The form is hidden rather than unmounted, exactly as the staff account
form is, so switching to the list and back does not throw away a
half-typed invitation.
- Revoking passes preserveState, so the page comes back on the tab the
person was working in. Acting on a row and landing on the other half
reads as having lost the list.
- ?tab=pending opens on the list, so something elsewhere can link at the
half it means rather than at the screen plus a sentence telling the
reader which tab to find.
Verified in a browser, since none of this is visible to the suite: both
tabs render, the form is present on one and absent on the other, the query
parameter opens the right one, and revoking leaves the page on the pending
tab with the count down by one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
An invitation could be sent and never taken back. There was no list of
outstanding ones and no revoke, so the only way to withdraw a link sent to
the wrong address was to let it expire -- and the expired page's own "send
me a new one" button undoes exactly that, silently, for anybody still
holding the link. The one cancel the feature had could be reversed by the
person it was aimed at.
So: a new STATUS_REVOKED, outside the pending() scope that both the
redemption and the resend doors look through. A revoked link is dead to all
three things a live one can do -- opening the form, redeeming it, and
asking for a replacement -- and nothing but sending a fresh invitation
brings it back.
The list sits under the invite form rather than on a screen of its own,
because the person who wants to cancel an invitation is the person who just
sent one. It shows outstanding invitations only: pending, expired ones
included. An expired invitation is not inert until it is revoked, so hiding
it would hide the rows most worth a decision -- which is why they sort to
the top, soonest expiry first.
Revoking is gated by create_clients, the same authority as sending:
whoever may invite somebody may take it back. It is logged, like sending
and redeeming already were. The row is kept rather than deleted, for the
reason a superseded one is kept -- the activity log names who invited this
address and when, and that trail should still lead somewhere.
Verified in a browser, not only in tests: the screen mounts, both rows
render, and the expired one carries its badge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
Two halves of the same gap. Redemption always provisions with
autoApprove: true, so it always meets SeatAllowance::guardClient(), which
refuses on the `email` field -- and the redemption form's email input is
read-only, never submitted, and had nowhere to render an error. The account
was correctly not created and the person was told nothing at all: the form
simply came back. The field now renders errors.email, which is where every
other account form's refusal already lands.
The other half is the button. "New client" has been seat-limited since the
limit existed -- it goes dead with the reason beside it, rather than
offering a form that cannot be submitted. "Invite client" sat next to it,
live, on a full installation. Worse than the original complaint, because
the refusal is met by the invited person rather than by the staff member
who caused it.
So the invite button is seat-limited too, and sending guards as well as
redeeming. An outstanding invitation is still not a client and is still not
counted as one -- the rule a pending account request follows, for the
reason SeatAllowance spells out -- so this reserves nothing. It refuses to
send a link a full installation could not honour, and redemption keeps its
own guard, because the seat can be taken by somebody else in the days
between.
SeatLimitedAction's usage caption is now optional, and the invite button
omits it. Two buttons governed by one limit, each captioned with the same
sentence, reads as two limits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
Staff can now invite a specific address to register instead of typing a
password for somebody and finding a way to get it to them. The invited
person sets their own, the link is locked to the address it was sent to,
and an invitation always activates the account regardless of the
auto-approve setting -- naming an address is already the decision the
approval queue exists to make for one nobody named.
Two fixes ride along: outgoing mail now reads the installation's own site
name in its title, header and signature rather than the one baked into
config('app.name') at install time, and the CSRF cookie name is read per
request rather than captured once at load.
Follow-up work, tracked separately: an invitation cannot be cancelled --
there is no pending-invitations screen and no revoke, so letting one expire
is the only way to take it back, which the self-service resend button then
undoes. Redemption also needs the address-availability check every other
non-form caller of ClientProvisioning makes.
Thanks @mash2k3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
Requested by @ToMMy86 in #1773: an install running on ECS, EC2 or EKS
already has a role attached, and making it also create an IAM user with
a long-lived access key is both extra work and a worse security posture
than the one AWS offers.
The AWS SDK resolves credentials from its default provider chain
whenever none is supplied, and Laravel's FilesystemManager already omits
the `credentials` entry when the key and secret are empty — so the
upload path needed almost nothing. What blocked it was ours:
- `isConfigured()` demanded a key and a secret for S3, so a
credential-less row was never "configured" and every upload silently
stayed on the local disk.
- `access_key` was `required_if:provider,s3` on both the save and the
connection test.
- `probeS3()` built an explicit `credentials` array, so Test connection
would have failed even once uploads worked.
An explicit `use_instance_role` column rather than "the key was left
blank", because blank already means "keep the credential you have" on
this form — neither the secret nor the GCS key file is ever sent back to
the browser. Ticking it deletes the stored key and secret rather than
leaving them in the row for the next database dump.
Unchanged for everyone else: MinIO, Backblaze, Wasabi and any other
S3-compatible service still authenticate with a key and secret, and the
region is still required — the chain resolves credentials, not regions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
All four themes had it as a flex child of the row holding the heading and
the buttons, so it was never full width: it took part of the line and
squeezed "My files" and "Upload a file" into a narrow column beside it. It
now sits above that row, which is where the staff dashboard has always put
it and where the comment claimed it was.
Worth noting why four themes shipped it wrong. The band renders nothing
for almost every viewer — it needs a hosted instance, a free plan, and a
client looking — so the broken layout was invisible to the suite, to the
build, and to anybody working on those pages. Seen only once somebody on
the real free tier looked at it.
Also adds it to the client's dashboard. That screen is about the account
rather than about the files, which is the more natural place for an offer
about the plan, and both read the same shared prop so they cannot disagree
about what is said or to whom.
Checked with real screenshots in all four themes and on the dashboard,
against a temporary listener standing in for cloud-modules, since this
install is community and would otherwise render nothing. The listener and
the theme setting were both put back.
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.
store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.
An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.
Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.
Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7.
Pinning the tenant was half an answer. It defeats the classic
cross-tenant nOAuth, where a stranger's own directory asserts your
address, because a foreign tenant carries a different tid. It does
nothing about the same attack from inside the pinned tenant: Entra's
email claim is user-mutable — a B2B guest's otherMails among its sources
— so a colleague or an invited guest could present an administrator's
address and have their subject bound to that account.
Tenant-pinning answers "which directory said this". It never answered
"does this person own that address". xms_edov is Microsoft's own answer
to the second, and their guidance says to require it wherever email
identifies an account. Absent counts as unverified, which is the only
safe reading given it is absent by default.
Nobody is locked out by this, which is worth saying because it looked
like a breaking change until I read SocialAuthenticator::resolve in
order. An account already linked resolves by subject at step 3, before
trust is consulted at all — those keep working untouched. A first-time
link to an existing account is refused with the message that already
exists for exactly this case, which names the way through: sign in with
your password and connect the provider from your settings. A brand-new
account is still created; it goes to the approval queue rather than
auto-approving.
The settings screen and docs/testing-social-login.md now tell an
operator to add the claim, and there is an upgrade note.
The tests exercise fromSocialite() on raw claims, which nothing did
before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by
hand and so never reaches this mapping. That is how the branch could
trust a tenant match alone with a full suite passing.
Reported by Nooraldden Khalel as GHSA-f32x-fgmp-q353.
The profile screen let a signed-in session change its own email address
with nothing else, and that address is where a password reset is sent. So
a stolen session was enough: point the account at your own inbox, ask for
a reset, set a password, and temporary access is permanent ownership.
Clearing email_verified_at did not stand in the way, because the model
does not implement MustVerifyEmail and the reset broker never asks.
destroy(), thirty lines further down the same controller, has always
required the current password, and its comment says why: "the rule every
other door into this already asks". This door leads to the same place and
was not asking.
Only a *different* address asks. A name, a timezone or a custom field is
not a credential, so the rest of the screen saves with nothing extra —
which is why the rule is excluded rather than flat, and why the comparison
is trimmed and lowercased: re-saving a profile with the address typed in a
different case must not demand a password for nothing.
An account whose credentials live in a directory or at an identity
provider is refused outright and told why, rather than being asked for a
password it does not have. LdapProvisioner stores Str::password(64)
exactly so that local password can never be used, so asking would be a
dead end dressed as a form error — and the address is not theirs to change
here anyway: it is what the directory says it is.
The test walks the whole chain rather than checking the field is
validated, because the chain is what made this high: change the address,
ask for a reset there, and confirm nothing is sent and no such account
exists.
"Did it arrive?" is the question somebody asks about a file they sent, and
on a hosted free account — where a link is the whole of the sharing — the
count is the only evidence either way. Every file a client uploaded now
shows how often it has gone out and when it last did, in every render mode
of every theme.
Only their own. A download entry says somebody fetched the file, so a
count on a file shared with several clients tells each of them about the
others' activity, and nobody is entitled to that but the person who put
the file there. A file shared *with* this client carries null, not zero:
a zero would itself be a claim, and the two have to be distinguishable
because zero is an answer the owner came looking for and is shown as
words.
Counted from the activity log through the same three actions
DownloadAllowance uses, so a file leaving by the public site counts as
much as one leaving by its link. One query for a listing, none at all for
a client with no files of their own. Both filters have a test that fails
when only that filter is removed.
Two things a render check caught that types and a green build did not.
`t()` does no plural selection — the catalogues are flat key/value — so a
"one|many" string reached the screen with its pipe intact; the strings are
whole sentences now, with the singular spelled out. And the gallery card
was already laying its text out beside the action icons in a 200px column,
truncating the filename to "Q…" and the size to "75 …" on main today;
stacking them gives every line its full width.
The band existed only on the staff dashboard, which is a screen a client
never opens. On a shared instance the customer *is* a client account —
they sign in, upload, and share by link, and the administrator is the
operator — so a message for them has to reach the page they actually use.
ViewerAnnouncement reads the shared prop itself rather than taking one,
so each theme adds it in a single line without threading a prop through a
page that has no other reason to know about it. All four portal themes
render it, because a message that only appears in the theme somebody
remembered to wire is a message that quietly does not exist.
Above the heading, not inside the list: it is not one of the things the
client came to do, and burying it under the files would defeat the point
of having it at all.
No theme decides who sees it. Core drops anything not aimed at the
viewer, so a theme renders whatever it is handed and cannot leak a staff
message to a client by being careless.
I shipped both daily calls as the same kind of thing — an operator's
preference — and only one of them is. That was wrong in the direction that
matters, because it handed a decision over rather than keeping it.
An update notice on a hosted tenant is useless: they cannot act on it, the
image is ours, and the screen that would show it is closed by capability.
So that check does not run there at all, which is right and unchanged.
News is the reverse. Announcements about the product are exactly what a
hosted customer should be told, and a Cloud client with view_news sees
that card today. One administrator switching it off for everybody on that
instance is not a decision the platform meant to hand over — so on a
managed instance the news now runs whatever any setting says, including a
row left behind by an instance that used to be self-hosted.
Capability::NewsConfigure, Community-only, and the thing it gates is the
*choice* rather than the news. A self-hosted operator keeps the switch,
because there nobody else decides what their installation reaches out for.
An edition difference through the capability registry rather than an
edition check, as everything here is.
Gated in all three places rather than only the screen: the command ignores
the setting without the capability, the controller neither sends nor reads
the field, and the checkbox is absent. There is a test that a hand-crafted
PATCH cannot do what the missing checkbox could not, and the guard is
proved load-bearing — remove it and the managed-instance test goes red.
The changelog and product highlights said "two switches" and now say what
is actually true, including that neither appears on Cloud and why they are
absent for opposite reasons.
A message worth showing was only on the dashboard, which means somebody
who works in Files and Clients all day never meets it. It now also sits
behind an icon next to the notification bell, and that is on every page.
**One shared prop, not two.** "The same message in both places" is the
requirement, and two props would have drifted the first time anybody
edited one — so the hook moved out of DashboardController into
HandleInertiaRequests, and the dashboard reads the same shared value the
header does. The band and the dropdown also share the component that
renders the words, for the same reason: the reliable way to keep two
renderings identical is not to have two.
Renamed with it. ResolvingDashboardCallout was accurate for about an hour
and became a lie the moment it appeared somewhere else; it is
ResolvingAnnouncement now, and the prop is `announcement`. Free to rename
because nothing has shipped yet — the only other reference was
cloud-modules', by string, updated alongside.
The icon follows UpdateAvailableIcon beside it: absent entirely when there
is nothing to say rather than a dead control, and a plain dot instead of a
count, because there is only ever one of these and a "1" would invite
somebody to look for the second.
Two tests worth naming. One asserts the message reaches a page that is not
the dashboard, which is the whole point of the addition. The other asserts
a client is shown nothing even from a listener that sets it
unconditionally — a client's header carries the bell too, and staff
messages must not reach it however careless the listener.
Two seams, in the shape docs/extension-points-architecture.md settles on:
a Laravel event with a mutable payload, dispatched unconditionally, and
with nothing listening the documented default holds. A community
installation gets an empty list and a null callout, which is exactly what
it had before.
ResolvingNavigationLinks exists because the sidebar is a hardcoded array
in app-sidebar.tsx, so a package could not contribute to it at all — the
nav entry was a separate manual edit every time a package grew a screen,
and being manual it was forgotten more than once. Staff-only, decided in
HandleInertiaRequests rather than trusted to each listener: these render
in the administration area, and a client's portal shows their own files
and nothing about the installation. There is a test that a listener adding
unconditionally still reaches no client.
ResolvingDashboardCallout is one band above the widget grid rather than a
widget in it. The grid is a closed list of keys that dashboard.tsx renders
one by one and each viewer arranges, so a message that mattered would sit
wherever somebody dragged it, or under a fold, or switched off. One at a
time, first listener wins: a dashboard that can accumulate banners
accumulates them, and the second is what teaches people to skip the first.
Core learns nothing about what either seam carries. Titles, URLs and copy
all arrive from the listener, and that is not fastidiousness — the first
caller is the hosted edition's link to its own customer portal and its
pitch to free instances, which is commercial copy belonging to one
offering and has no business sitting in the public repository because the
sidebar happens to live here.
An external link renders as a plain anchor opening in a new tab, never an
Inertia <Link>: Link expects a page component back and another origin will
not give it one, so it fails without saying so. It is also never marked
active — nothing outside this app is the page you are on.
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.
They look like one problem and are two, which is why they are fixed
differently.
**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.
**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.
Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.
The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
Found reviewing the client file editor rather than building it.
File::isEffectivelyPublic() is "my own flag OR my folder's", and
Folder::uploadableBy() admits a client to a public folder on
upload_to_public_folders — a different key from upload_public. So a client
can make a file world-readable without touching the public switch, and
without holding the key that switch is behind.
That is what those two keys have always meant and what uploading into such
a folder has always done, so this does not refuse it. What was new is
where the choice is made. The upload page is entered from a folder the
client has already navigated to, where the list shows a Globe badge on a
public folder. The editor's picker is a flat list of names, and it is the
first place a destination is chosen with none of that context — so the
consequence was invisible exactly where it mattered most.
Public folders now carry the badge in the picker, and choosing one says in
words that anyone will be able to open the file without signing in. Two
tests: that the side door genuinely publishes and is labelled, and that a
private folder is not labelled — a warning on everything is a warning on
nothing.
The rest of the review found no defect. Ownership, the per-field keys, the
staff-scope trap and mass assignment were already covered; a client
deleting a file that staff later revised was checked directly and moves
the chain's recipients onto the successor without widening them, which is
what it is supposed to do. The write path was driven in a real browser —
rename, publish and delete through the actual form and dialog — because a
green suite over a write that 419s in every browser is a mistake this
repository has made before. Bytes gone, audit trail complete, and
file.made_public records the slug.
The authorization landed last commit; this is the way in. A client with
edit_files now gets an Edit action on the files they uploaded, opening a
form with every field their role actually grants, and a Delete beside it.
One page for every theme, not one per theme. portal/edit-file.tsx picks
its shell from the `theme` prop exactly as portal/upload.tsx does, because
a form with eight fields behind five separate permissions, rebuilt four
times, is four places for a field to go quietly missing. What *is*
per-theme is only the entry point: one <FileRowActions /> in each theme's
row actions group, the file twin of the FolderRowActions that was already
there.
Row actions gate on can_update/can_delete, sent per file by
MyFilesController and answered by FilePolicy — never on is_mine, which is
half the question. Holding the file is one half and the role's keys are
the other, and a theme that reads is_mine offers an Edit button that
403s. Written into docs/theming-files-checklist.md so the next theme does
not have to rediscover it.
The folder picker offers only folders the client could have uploaded to,
so it cannot present a destination the save would refuse. Publishing says
in plain words that anyone with the link will be able to open the file
without signing in, and says so differently when the installation has no
public page configured, because there the switch would do nothing visible.
Hiding a control is a courtesy, never the enforcement. Every can_* prop
here is the same question ApplyFileEdits asks when the form posts, and the
tests assert both ends.
Verified in a real browser over CDP rather than only by types and tests,
which say nothing about whether a page mounts: 23 edit actions on the
client's 23 own files and none on the file shared with them, the editor
mounting with its real values, every gated field present, no console
errors. The dev instance's Client role was snapshotted before the run and
restored to exactly what it was.
Refs #1771
Same amber, same underline, same warning triangle as the System widget
row. The two say the same thing about the same installation, so looking
different made the settings one read as an ordinary footnote rather than
the thing to go and read.
Uploads live outside the web root, so PHP authorizes every download and
then hands the file to the web server with a header naming it. Four
routes decided that for themselves and all four hard-coded nginx's
spelling. On Apache or LiteSpeed nothing acts on the header, so the
empty body PHP sent goes to the visitor: files upload fine, thumbnails
are broken images, and downloads arrive as 0 bytes, with every other
page working. Reported as #1765 from an Apache 2.4 install, and before
that as #1266, #1215, #870 and #1271.
It is also a regression from v1, which had a download_method setting --
php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1
therefore worked on any server out of the box and v2 did not, and a v1
Apache user migrating lost every download with nothing to tell them why.
So the four sites now go through one FileDelivery, and it picks:
auto (default) nginx when SERVER_SOFTWARE says nginx, else php
nginx X-Accel-Redirect, a URL path via the internal location
xsendfile X-Sendfile, an absolute path (Apache mod_xsendfile,
LiteSpeed)
php BinaryFileResponse
Defaulting to auto rather than nginx is the point of the change: a
default that assumes nginx leaves an Apache install exactly as broken as
it is today until somebody reads INSTALL.md. Slow beats empty.
Auto never picks xsendfile, even where the module is loaded.
mod_xsendfile also needs XSendFilePath to allow the storage directory,
which cannot be seen from here, and choosing it on the strength of the
module being present would trade a silent failure an administrator can
diagnose from the dashboard for one nobody can.
BinaryFileResponse rather than a readfile loop because it answers Range
requests. nginx does that itself on the fast path, so hand-rolling it
would have broken seeking through a video on exactly the installations
this fallback exists for. Verified end to end: 206 with the right
Content-Range through the live stack.
Two guards. Every method checks the path cannot climb out of the storage
area -- nginx resolves `..` in the URL it is handed as happily as PHP
would -- and the two methods that hand over a filesystem path resolve it
and prove it lands inside the root. Callers pass paths from rows they
just authorized, so this is a backstop; it is here because the cost of
being wrong once is handing over any file the web server can read.
The dashboard's System panel names the method, with a warning icon and a
dialog when PHP is doing the sending: what is happening, what it costs
(one worker held for the whole of each download, so a few large
simultaneous ones can occupy every worker while the processor sits
idle), why it is set that way, and the three ways out. Written to be
accurate rather than reassuring -- nothing is broken, it does not scale
-- and the notice stays even when php was chosen deliberately, because
the trade-off is the same either way. /system/settings/downloads repeats
it, which is where somebody coming from v1 goes looking for the
dropdown.
An environment variable rather than a stored setting: it describes the
server this installation runs on, not a preference, and a value in the
database travels to a different server in a restore and is wrong there.
Read only in config/projectsend.php, so config:cache cannot blank it.
The suite pins itself to nginx. Left at auto it would detect no server
at all, fall back to php, and quietly retire the coverage of the
mechanism most installations actually use.
use-translation.ts states the rule: every user-facing string in a
component must go through t(). Five screens never called it at all --
forgot-password, reset-password, confirm-password, verify-email and
settings/password had zero occurrences of useTranslation -- so a client
who had chosen Spanish reset their password in English, from the browser
tab down to the submit button. settings/profile had the hook but used it
for two strings, leaving its heading, labels and the whole
email-verification notice hardcoded around them.
The password page also carried a second, smaller mistake the miss was
hiding: its <Head> title said "Profile settings", copied from the
profile page, so the tab named the wrong screen in every language.
It says "Password settings" now, the wording its own breadcrumb and
the sibling "Notification settings" title already use.
Every string on the six screens goes through t() now. The two
module-level breadcrumb arrays moved inside their components to reach
the hook -- the shape two-factor, notifications and the other settings
pages already have. Where a key already exists in the catalogs (Email
address, Password, Confirm password, New password, Log out and friends,
shared with the login screen) the existing translations light up
immediately; the keys new to the catalogs fall back to their English
text, exactly what those lines rendered before, until the locales pick
them up.
TranslationUsageTest is the guard, a source scan like
DateFormattingUsageTest and for the same reason: no JavaScript test
runner gates this class of miss. It fails on any page under pages/auth
or pages/settings that never uses the hook -- those screens always carry
copy of their own, so a page there without it is a page somebody forgot
-- and on any literal <Head title="..."> anywhere, which is both a
user-facing string and where the copy-paste title above lived. Both
scans go red on the tree without this change: five pages and six
literal titles.