34 Commits

Author SHA1 Message Date
ignacionelson 2029309126 Release 2.2.1 2026-08-28 01:52:41 -03:00
ignacionelson d83d2d9acb Translate the three strings the last release cycle added
Two seat counters and one folder-delete refusal, in all sixteen locales.
Additive only: nothing already in a catalogue was reordered or reworded,
so the diff is three lines per file.

Polish, Czech and Russian get three plural forms where the English has
two. Those languages inflect a noun by the number in front of it -- one
case for 2-4, another for 5 and up -- and the framework's selector picks
between three segments for them, so writing only the English pair would
have produced "5 pliki" where it has to be "5 plikow". Verified through
trans_choice at 1, 3 and 7.
2026-08-28 01:45:07 -03:00
ignacionelson 06c364d29a Report storage, health and what packages loaded in projectsend:status
Five more facts for whatever watches an installation from outside the
container, and one seam so a package can add its own.

Storage is the one that was about to be wrong. It is summed from the rows
that record it, not measured on the volume: measuring the directory was
correct until external storage went live and silently stopped being, since
an upload that resolves to a bucket leaves nothing on disk to measure. A
figure taken from the filesystem freezes while the account keeps filling,
and on a managed installation that figure is what a customer is shown and
billed against. `by_disk` splits the same sum by where the bytes went,
which is the only way to see what is still sitting locally from before a
cutover. Trashed files are excluded because they hold no bytes -- File's
deleted hook takes them.

Health is what a container cannot show from outside. A queue worker dying
is invisible to anything watching the process: it is still up, and zips
quietly stop building while mail stops going out. Same for a deploy whose
migrations failed -- the application answers every request and is a schema
behind. An unreachable queue reports null rather than zero, because an
unreachable Redis is not an empty queue and reading the second as the
first is how a dead worker looks healthy.

The two-factor enforcement setting is echoed back the way EnforceTwoFactor
reads it, fallback included: reporting a stricter rule than the middleware
actually applies would be worse than reporting none.

And ResolvingInstallationStatus, so a package can report what core cannot
know. The managed storage backend and the version of the package providing
it live in cloud-modules, which this repository must not reference, and a
platform that writes eight environment variables only ever knows what it
asked for. Those came apart once: a bucket provisioned, a token minted,
every variable correct, and an image whose copy of the package predated
the module that reads them. Files went to local disk with the
configuration sitting perfectly right beside them.

Two shapes are cast to objects deliberately. An empty PHP array encodes as
[], so an installation with no packages -- or holding no files -- would
answer a map-shaped field with a list, and a reader unmarshalling it
breaks on the day it happens to be empty rather than the day it is
written. There is a test for each.

Requested by the ProjectSend Cloud control plane, whose storage figure
stops growing the moment a tenant's uploads start reaching the bucket.
2026-08-28 01:32:25 -03:00
Ignacio Nelson 046be36861 Merge pull request #1710 from denkfabrik-li/fix/folder-delete-file-authority
FoldersController::destroy() authorized delete on the folder and nothing else, while FolderService::delete() soft-deletes every file in the subtree and File's deleted hook takes the bytes off disk. So a staff member refused a file one route over could destroy it by deleting the folder around it -- permission and library boundary both unasked.

MyFoldersController::destroy() already draws this line for the client half of the same cascade, and says why: owning the folder is not authority over content someone else put in it. This is the staff half of that sentence.

Verified before merging: the four bug tests fail on main and pass here, and the SQL predicate was read line by line against FilePolicy::delete -- it is a faithful negation, including the null-uploader case and the short-circuit for an unscoped viewer holding both delete permissions. Membership of the check is one COUNT, not a policy call per file. Suite at 2099, PHPStan clean.

Behaviour change, deliberately accepted: a folder delete that used to succeed now refuses, naming how many files are in the way. The likely case is somebody who owns a folder another account uploaded into. The alternative is irreversible loss of files the same person is refused individually.

Not taken: deleting what the actor may and keeping the rest. Half a tree is worse than either answer. Naming the blocking files would be friendlier than counting them and is worth doing later -- the list has to hide any file the viewer cannot see, which is its own small design question.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:20:57 -03:00
Ignacio Nelson 4a35c25894 Merge pull request #1717 from denkfabrik-li/fix/deleted-client-comment-context
file_comments.client_context_id is cascadeOnDelete, but users are soft-deleted, so the cascade never fires: the column goes on pointing at a row that is still there while the relation resolves to null. resolveClientContext() branched on the relation, so "this is Alice's conversation" read as "this has no conversation" -- and a null context on a clients comment is the branch every client on the file reads. A staff reply into a departed client's private thread became a circular, and canAssignClient() was skipped on the way.

That is the invariant docs/feature-comments.md calls the rule everything hangs off: a clients comment carrying client_context_id = C is never returned to any non-staff viewer other than C, because one customer learning another exists is worse than leaking a comment's text.

Verified before merging: both new tests are red on main and green here, and the three that must not move stay green either way. Suite at 2093, PHPStan clean.

The second half is the same root cause through the other column. authorName() read a deleted client's comment as "Anonymous", which is what a visitor's comment looks like -- and a visitor's comment is governed by different rules, so the two must not be able to look the same. Whether the author is a visitor is now decided by author_id alone, the question isFromGuest() already asks.

Accepted consequence: a soft-deleted client's name is visible on their old comments during the erasure grace period, where it previously read as Anonymous. It goes for good when erasure removes the row.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:14:48 -03:00
Ignacio Nelson 58497ef776 Merge pull request #1716 from denkfabrik-li/fix/sole-administrator-self-deletion
ProfileController::destroy() validated the current password and soft-deleted, without asking guardLastAdministrator() -- the rule the other four doors ask, at the one door where the account being removed is certainly signed in. The sole administrator could empty their own installation, and EnsureSetupIsComplete, which asks exists() and so skips trashed rows, then handed the first-run setup form to whoever loaded the page next. That form creates an active System Administrator, unauthenticated.

Verified before merging: on main the sole administrator's self-deletion succeeds and setup reopens; both new tests are red there and green here. Suite at 2088, PHPStan clean.

Two locks, because one of these questions is asked at five doors and the other at one. The guard closes the door. And "has this installation been set up" stops meaning "does it have a working administrator right now" -- a trashed staff row is still evidence that setup happened, counted now in both the middleware and SetupController::setupIsComplete(), which have to agree or the result is a redirect loop or an open form.

Worth recording: erasure force-deletes a self-deleted account after its grace period, so the second lock would expire on its own. It does not matter because the first lock stops the installation reaching that state, but a future change to either should know the other is not permanent.

An installation that has already lost its last administrator now finds setup shut. That is the point: recovery is php artisan projectsend:admin, which is also how every unattended container installs itself.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:12:14 -03:00
Ignacio Nelson d751314196 Merge pull request #1715 from denkfabrik-li/fix/zip-duplicate-entries
The job walked the loose file ids and then every selected folder's subtree, adding whatever each pass found. A selection reaching the same file both ways got it twice: two copies of the same bytes, a total_size inflated by the repeat -- which is what the size cap is checked against -- and a file limited to a single download handed over in three copies while the log recorded one, because delivery logs per contained file and DownloadAllowance counts those records.

Verified before merging: the three new tests fail on main and pass here. Suite at 2082, PHPStan clean.

Two halves, because one fix does not cover both shapes. The added-ids list becomes a map keyed by id and the folder pass skips what is already in, before the per-file re-checks, so a duplicate does not spend an allowance twice either. And a folder sitting inside another selected folder is dropped before either is walked, which also settles which path the surviving entry keeps rather than leaving it to row order.

One measured cost, accepted: the pruning compares every selected folder with every other. The pathological case -- ten thousand sibling folders, the selection cap -- benchmarks at around twenty seconds of CPU, in a background worker, on a selection that would take far longer to compress. A sort-by-path-length version would be cheaper if it ever matters.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:06:43 -03:00
Ignacio Nelson 00d118559d Merge pull request #1714 from denkfabrik-li/fix/group-edit-library-scope
Every group route asked StaffLibraryScope whether this viewer may act on this group except the two that read it. So a client-scoped staff member could open the edit screen of a group they cannot change, read its membership with addresses, and get the whole client roster in available_clients besides. The API twin returned the same membership.

Verified before merging: the three new tests fail on main and pass here. Two things checked beyond the report -- group membership is edited through separate, already-guarded routes, so narrowing the displayed list cannot remove anybody on save; and scramble:export regenerates byte-identical, as claimed. Suite at 2078, PHPStan clean.

The fix has two halves because one guard does not cover both shapes. Reading the group now asks the same reach question the write half asks. And both lists narrow through StaffLibraryScope::clients(), because a group nobody has shared anything with reaches nowhere, stays open to everybody, and can still hold a stranger's client.

Unscoped viewers are unaffected: clients() returns the whole roster for them and allowsGroupChange() is true by construction.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:03:23 -03:00
Ignacio Nelson abaca20261 Merge pull request #1713 from denkfabrik-li/fix/api-self-deactivation-boolean
The  validation rule accepts 0 and "0" as well as false and does not cast, so a strict comparison against the validated array let two of the three spellings past the self-deactivation guard -- and the model's own boolean cast then stored exactly the value the guard had just decided was not a deactivation.

Reproduced on main before merging: {"active": false} is refused, {"active": 0} and {"active": "0"} both return 200 and switch the account off. Green on the branch, suite at 2074, PHPStan clean.

The fix reads the flag once with Request::boolean() and gives that same value to the guard and to the write -- the rule RolesController::guardScopeRemoval already documents for the same reason. Validation is unchanged, so the accepted inputs are the same; one of them just stops meaning two different things on its way through the method.

Follow-up for the release: this is a caller-visible change (200 to 422) and wants a line in api-changelog.md.

Reported and fixed by @denkfabrik-li.
2026-08-28 01:00:55 -03:00
Ignacio Nelson b16d780ebe Merge pull request #1712 from denkfabrik-li/fix/storage-durability-dashboard-assertion
The test named for carrying the durability verdict to the system widget asserted only has('system'), and system is an unconditional key of the render array -- the controller's own comment beside storage_durability says as much. So the assertion could not fail.

Confirmed here by deleting the line that supplies the verdict: the new assertion fails with "Property [system.storage_durability] does not exist", where the old one stayed green.

Test-only, no application code.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:55:45 -03:00
Ignacio Nelson 602c7bed94 Merge pull request #1708 from denkfabrik-li/fix/confirm-password-under-enforcement
EnforceTwoFactor exempts by route name, and only the GET half of the confirm-password screen had one -- Route::named() answers false for a null name, so the submission was never exempt. Enrolling requires password confirmation, so with enforcement on nobody could enrol at all: the form rendered, its POST was redirected to two-factor.show, auth.password_confirmed_at was never written, and every account on the installation was left with logout as its only working route. Including the administrator who turned the setting on.

Reproduced on main before merging: POST /confirm-password redirects to /settings/two-factor and the session flag stays unset. The widened pattern was checked against the route table -- password.confirm* reaches password.confirm and the newly named password.confirm.store and nothing else; password.reset, password.store and the rest are not under that prefix. Exempting the submission grants nothing further, since every other route stays bounced and store() still validates the password.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:24:36 -03:00
Ignacio Nelson 76f79d53a0 Merge pull request #1711 from denkfabrik-li/fix/update-tests-clear-compiled
Ten tests ran the real projectsend:update, which runs clear-compiled, which deletes bootstrap/cache/packages.php and services.php -- one copy for the whole checkout, shared by all eight workers of a parallel run. A worker booting in the window between that delete and its own rebuild reads an empty package manifest, registers no package service providers, and dies rendering the next page with "Target [Inertia\Ssr\Gateway] is not instantiable", in a file that has nothing to do with updates.

Verified here rather than taken on trust: a probe running the real update inside a test on main deletes the manifests, exactly as described. The branch is green at 2066 with PHPStan clean, and touches no application code.

The file already owned a double and explained why the artisan call is a seam; this extends it to the whole file and adds a test asserting the compiled caches survive.

Reported and fixed by @denkfabrik-li.
2026-08-28 00:19:04 -03:00
ignacionelson 3f81dd5eab Merge pull request #1709 from denkfabrik-li/fix/seat-cap-approval-doors
Two doors onto the client seat cap did not ask it. Both update()
methods -- the edit screen and PATCH /api/v1/clients/{id} -- clear
account_requested when a pending client is activated, under a comment
saying that counts as approval, and approval is the moment a seat is
spent. So a managed installation sitting at its cap kept taking clients
on for as long as registrations arrived, and self-registration is open
to strangers, so the supply of pending rows is not the operator's to
control.

Verified rather than taken on trust: the two new door tests were run
against the unguarded controllers and fail there, and every place in
app/ that clears the flag was enumerated to check no third door was
missed. There is none -- the other six already ask, and a conversion
refuses a pending account outright rather than approving it sideways.

The guard sits inside the approval branch, so an installation at its cap
can still rename a client it already holds. That is pinned by a test of
its own.

Conflicted with tonight's seat work in SeatAllowanceTest, which had
added an import beside the one this adds. Resolved by keeping both;
suite green at 2065 and PHPStan clean after resolution.

Reported and fixed by @denkfabrik-li.
2026-08-27 23:30:33 -03:00
Ignacio Nelson 1cefdee610 Merge pull request #1707 from denkfabrik-li/fix/tests-workflow-single-concurrency
The tests workflow has not parsed since c05927c1 added a second top-level `concurrency:` key four lines below the one that was already there. YAML refuses a duplicate key, so GitHub created a run and scheduled no jobs -- verified here with symfony/yaml ("Duplicate key concurrency detected at line 66") and against the run list: every run since is zero-job, including the commit v2.2.0 is tagged at and all five pushed tonight.

The linter workflow carries one block and kept running, which is why the tree read as checked when the suite had not run at all.

Reported and fixed by @denkfabrik-li.
2026-08-27 23:29:34 -03:00
ignacionelson f2e7820f5c Say that the seat counts now have a reader outside this application
The docblock argued for one definition by describing a control plane
showing "2 of 3 seats used" next to an application refusing the fourth,
and the two disagreeing. That was written as a thing to avoid. As of
today it is a screen: the hosted fleet console reads these numbers per
tenant out of projectsend:status --json.

Which makes two rules here load-bearing somewhere nobody editing this
file would think to look -- a deactivated staff account still holds a
seat, a client awaiting approval does not. Changing either changes what
a support person is told before it changes what a customer hits, and
the note is here so that is a decision rather than a surprise.
2026-08-27 23:25:17 -03:00
ignacionelson a92feed3ad Correct the fifth stale Community-only comment, in QuickStart
The quick-start list gates its "Add the rest of your team" step on
Capability::UsersManage, which is right and unchanged: it is the seam an
edition difference would travel through. The comment above it still gave
the old reason -- that a managed installation has no staff accounts of
its own to hand out -- which the capability opening on both editions
made false. The step has appeared on a managed installation's list since
623ad68, and GettingStartedTest already says so.

Found by sweeping every repo for the same claim after four others turned
up: core, both module packages, the migration tool, the customer portal
and the private docs. The remaining ones are in the portal's own
planning documents, which are its to correct.
2026-08-27 23:13:43 -03:00
ignacionelson 73d93495c9 Report the last staff sign-in in projectsend:status
A platform can see that an installation is running. It cannot see
whether anybody is still using it, and the difference is what separates
a customer from an abandoned free instance holding a database.

So the status probe gains one field:

    "activity": { "last_staff_login_at": "2026-08-24T21:13:32+00:00" }

Null means no staff account has ever signed in, and the key is emitted
either way. That is the whole care in this change: "they said never" and
"we got no answer" have to stay distinguishable, because collapsing them
is how a broken probe reads as a dormant fleet.

Only interactive sign-ins count. Laravel's Login event does not fire for
token authentication, so an integration polling every hour cannot make
an empty installation look busy -- which matters when the reading is
used to decide something.

Derived from the activity log rather than denormalised onto users. A
column would cost a migration, a listener change and a backfill to save
one indexed MAX() over a table with a handful of rows on exactly the
installations anybody asks this about. Nothing prunes the log, and
erasure anonymises entries rather than removing them -- actor_type
survives on purpose -- so the answer does not change when the person who
gave it is forgotten.

Requested by the ProjectSend Cloud control plane, which has no other way
to learn the date. Recorded in docs/api-todo.md as deliberately a
command rather than an endpoint, for the reason the command exists at
all: it observes, it does not accept instructions.
2026-08-27 22:58:47 -03:00
ignacionelson 2eb23dbc07 Stop four comments saying user management is Community-only
It stopped being true in 623ad68, when users.manage opened on both
editions. The code moved and these did not, which is the worst kind of
comment: confidently wrong, and about the very rule a reader comes to
them to learn.

PlatformManaged claimed the tenant's own /users screens stay closed,
directly contradicting the UsersManage comment eleven lines above it.
routes/web.php said the same about the group it gates. The API
controller's docblock opened with "**Community only.**", and the
conversion screen's said a managed installation creates staff accounts
elsewhere.

Each now says what is actually true, and says the division the change
turned on: a platform sells the seats, the tenant decides who sits in
them. What limits a managed plan is the seat cap, not a shut door -- so
the API answers 422 at the limit rather than 403, which is a different
sentence to whoever is reading it.
2026-08-27 22:11:21 -03:00
ignacionelson 13b56186f4 Say the seat limit before the form, not after it
On a managed installation with its staff seats full, /users/create opened
as though there were room. You typed a name, an address and a password
you had to invent, pressed Save, and the plan limit came back as a
validation error under the email field -- which reads as a complaint
about the address rather than a fact about the plan.

A full installation is an ordinary state on a plan sold by the seat, so
it is now stated up front. The list carries the seat position, the
button goes dead once the last seat is taken and says why, and the
create screen turns away anyone who reaches it by link or bookmark. The
guard in store() is untouched: that is still the rule, this is only the
door.

The refusal is worded once, in SeatAllowance, and the screen is handed
that sentence rather than writing its own -- two wordings of one limit
is how somebody ends up believing there are two limits. `full` is
derived there too, from the same comparison the guard refuses on, so a
screen cannot disagree with it about the edge (used > limit, after an
operator lowers a limit) and offer a button for a form that cannot be
submitted.

Clients get the same treatment: the cap exists there too, and reached it
the same way. Self-hosted installations have no limit, so they are shown
nothing about one.
2026-08-27 21:02:54 -03:00
denkfabrik-li e272f19045 Keep a private reply private after the client is deleted
file_comments.client_context_id is cascadeOnDelete, but users are
soft-deleted, so the cascade never fires: the column keeps pointing at a
row that is still there while the Eloquent relation resolves to null.
resolveClientContext branched on the relation, and a null context on a
Clients comment is the branch every client on the file reads -- so a
staff reply into one client's private thread became a circular to all of
them, with the canAssignClient check skipped on the way.

VisibleCommentScope says so in its own docblock: "A Clients comment
carrying client_context_id = C is never returned to any non-staff viewer
other than C ... A Clients comment with a null context is a staff message
to everyone on the file, and every client with access reads it."

Measured on main, with one file shared with two clients and the first of
them deleted after commenting:

  column client_context_id      3
  relation clientContext        null
  POST reply into her thread    201, stored with client_context_id null
  read by the other client      yes

Ask the column, and refuse when the account behind it is gone. There is
nobody left to answer, and the one outcome that must not follow from a
filled column is the broadcast, so this throws rather than falling
through to it.

authorName() had the same root cause from the other column: its docblock
claimed author_id cascades so there is no deleted author, and a deleted
client's comment was going out as "Anonymous" -- which is what a guest
comment looks like, and a guest comment is read by different rules. Guest
is now decided by author_id alone, the same question isFromGuest() asks,
and a trashed author is read with withTrashed(). Nothing comes back only
once the grace-period erasure has removed the row for real.

That read costs one query per comment whose author is trashed. Measured
on a ten-comment thread: 11 queries before, 21 after, against 20 for the
same thread with every author alive. Left as a lazy read rather than
eager-loading with withTrashed() at every call site, because the callers
would each have to remember it and the cost only applies to comments
whose author is gone.

Five tests, two measured red against the unfixed code (2 failed / 3
passed) -- one per column. The three that stay green either way are the
branches that must not move: a staff message with no context still
reaches everybody, a reply into a live client's thread still lands in
that thread alone, and a genuine guest comment is still anonymous.

Full suite passes (2053 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:44:25 +02:00
denkfabrik-li 28e18497b5 Refuse the last administrator deleting themselves, and keep setup shut
ProfileController::destroy() validates current_password and soft-deletes.
It never asks StaffAccounts::guardLastAdministrator(), and every other
door does: Staff update(), guardDeletable(), and both directions of the
role conversion. This is the one door where the account being removed is
certainly signed in.

An installation with a single administrator therefore had a button that
emptied it. Measured on main:

  DELETE /settings/profile   302, the account is gone
  live staff rows            0    (the row is trashed, not removed)
  anonymous GET /            302 -> /setup
  anonymous POST /setup      a new active System Administrator

EnsureSetupIsComplete asks ->exists(), which excludes trashed rows, and
routes/web.php registers GET and POST setup with no auth and no guest
middleware -- correctly, since a fresh installation has nobody to
authenticate. SetupController::store() re-checks the same condition, so
both halves agreed with each other and both were wrong once the last
staff row was trashed.

Two locks, because one of them is asked at five doors and the other at
one.

First: destroy() now asks guardLastAdministrator(), the same call with
the same message as everywhere else. An administrator with a colleague
still goes, a non-administrator staff member still goes, and a client
still closes their own account.

Second: "has this installation been set up" is not the same question as
"does it have a working administrator right now", and only the first one
belongs in EnsureSetupIsComplete. A trashed staff row is still evidence
that setup happened, so it now counts -- in the middleware and in
SetupController::setupIsComplete(), which have to agree or the result is
either a redirect loop or an open form.

That second lock holds even if a future door forgets the first one.
Measured with the guard bypassed entirely and the row trashed directly:
GET / answers with the login screen and POST /setup creates nothing.

Worth stating plainly: an installation that has already lost its last
administrator will now find setup shut rather than open. That is the
point -- the recovery path for it is `php artisan projectsend:admin`,
which is also how every unattended container installs itself, not a form
that anybody on the internet can reach.

Six tests, two measured red against the unfixed code (2 failed / 4
passed) -- one per lock. The other four are the boundaries: a colleague
present, a staff member who is not an administrator, a client, and a
genuinely fresh installation that must still reach setup.

Two existing tests needed saying more clearly rather than changing:
ProfileUpdateTest's deletion cases now create a second administrator, so
that what they assert is self-deletion and not this new refusal; and
GettingStartedTest's "fresh installation" cases forceDelete rather than
delete, because a soft-deleted staff row is no longer a fresh
installation -- which is the whole of the second lock.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:35:41 +02:00
denkfabrik-li b44c6bf098 Add a file to a zip once, however many ways the selection reaches it
BuildZipDownloadJob walks the loose file ids and then every selected
folder's subtree, and adds whatever each pass finds. A selection can
reach the same file from more than one of them, and nothing noticed:

  file_ids [f], folder_ids [Reports]
    -> ['report.pdf', 'Reports/report.pdf']

  file_ids [f], folder_ids [Reports, Reports/Q1]
    -> three entries, file_count 3, total_size three times the file

Two copies of the same bytes in one archive, and total_size is what the
size cap is checked against, so a selection could also be refused for a
weight it does not have.

The one that costs more than bandwidth is delivery. It logs one
FileDownloaded per contained file, and DownloadAllowance counts those
records -- so a file limited to a single download left in three copies
while the log recorded one. Measured: three entries, one record.

Two causes, so two halves.

`$added` is now keyed by id instead of being appended to a list, and the
folder pass skips a file already in the archive. A lookup rather than a
scan because the selection cap is 10000 sources. The loose pass runs
first, so a file picked both ways sits under its loose name; either
answer is defensible, but it has to be the same one every run.

And a selected folder inside another selected folder is dropped before
either is walked. Zipping both would reach every file in the inner one
twice, and which path the surviving entry ended up under would be decided
by the order the rows came back in. Keeping the outer folder keeps the
fuller path -- Reports/Q1/report.pdf rather than Q1/report.pdf.

Containment is decided on the materialized path, so it is one comparison
per pair with no queries: a folder's path starts with an ancestor's
subtreePathPrefix(), and both end in '/', so /5/ cannot match /50/.

Not changed: the per-file re-checks inside the folder pass. Visibility
and the download allowance are still re-derived per file, and the skip
happens before them, so a duplicate never spends an allowance twice
either. Nor the selection endpoint -- a caller may send whatever
selection they like, and the job is where it is resolved.

Four tests. Three measured red against the unfixed job (3 failed / 32
passed): the loose-plus-folder case, the nested-folder case, and the
three-way case asserted through delivery rather than through the archive.
The fourth -- two selected folders that merely share a name are both
zipped -- is green either way and guards the pruning against being about
names rather than containment.

Full suite passes (2052 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:27:26 +02:00
denkfabrik-li eade690f73 Hold the group edit screen to the same library boundary as the rest
Every other group route asks StaffLibraryScope whether this viewer may
act on this group. GroupsController::update() and ::destroy() do, and so
do their API twins -- all four with abort_unless(allowsGroupChange, 404).
The two that read do not: edit() and Api\GroupsController::show() had no
boundary at all.

What they hand over is the membership, name and email per member, plus
the whole client roster of the installation as available_clients. So a
client-scoped staff member could open a group whose contents they cannot
see, read off every client on the installation, and only be refused when
they pressed save.

Two halves, because the leak has two shapes:

- The group itself. Reading it now asks the same reach question the write
  half asks, one step earlier, with the same 404 -- a group that reaches
  past the viewer's library is not theirs to open either.
- The lists inside it. Both narrow through StaffLibraryScope::clients(),
  the listing half of the rule this screen's buttons are already guarded
  with: allowsGroupMembership refuses removing a member outside the
  roster, and refuses adding a client outside it. Naming them anyway,
  with their address, is the mistake ClientsController made before
  clients() existed -- that method's own docblock says so.

The reach guard alone would not have been enough. A group nobody has
shared anything with reaches nowhere, so it stays open to everybody --
and it can still hold a stranger's client. That case is why the lists
narrow separately, and there is a test for it.

members_count is left whole on purpose: a size is not an identity, and it
is the same number the group listing already reports.

GroupResource's docblock claimed members are safe to expose because "the
group edit screen already shows [them] to anyone holding edit_groups".
That was a claim about a screen, and it stopped being true the moment the
screen narrowed. Reworded to say what now holds it up, and where.

Not changed: the group listing. It reports names and member counts, not
identities, and every button on it is guarded. Nor Api\GroupsController::
index(), for the same reason. Nor the API document -- scramble:export is
byte-identical, because GET /groups/{group} already documented a 404.

Four tests. Three measured red against the unguarded controllers (3
failed / 21 passed): the group cannot be opened at all, the edit screen
stops naming strangers, and the API twin narrows what it hands back. The
fourth -- an unscoped viewer keeps the whole roster and every member -- is
green either way and guards against the fix over-refusing.

Full suite passes (2052 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:19:36 +02:00
denkfabrik-li 3e15237f90 Refuse self-deactivation over the API however the boolean is written
Api\UsersController::update() compares the validated value strictly:

    if ($user->is($actor) && ($validated['active'] ?? true) === false) {

The `boolean` rule accepts 0 and "0" as well as false, and it does not
cast. `0 === false` is false, so the refusal never fires -- and the
model's own `boolean` cast then stores as false exactly the value the
guard had just decided was not a deactivation.

Measured against main, with a second administrator present so that
guardLastAdministrator is not what answers:

    {"active": false}  -> 422, still active
    {"active": 0}      -> 200, active is now false
    {"active": "0"}    -> 200, active is now false

The method's own docblock says it is "Refused with a 422 if the change
would leave the installation with no active administrator, or if you
would be deactivating yourself", and the web screen does refuse. This is
the API half of that sentence.

RolesController::guardScopeRemoval documents the rule this breaks, in the
same words: callers resolve the flag with Request::boolean() and hand the
same value to the guard and to the write, deliberately, because reading
the validated array and comparing it strictly "would let a request
through here that the model's `boolean` cast then stores as false anyway
-- the guard and the write disagreeing about one value is exactly the
shape this guard exists to prevent".

So read it once, with Request::boolean(), and give that one value to both.

Not changed: the validation rule. It stays `boolean`, so the accepted
inputs are the same as before -- what changes is that one of them stops
meaning two different things on its way through. Nor anything about
deactivating somebody else: all three forms still work, and there are
tests saying so.

Six cases from two datasets. Two measured red against the unfixed
controller (2 failed / 4 passed): 0 and "0" on yourself. `false` was
already refused, and the three "somebody else" cases are green either way
-- they guard against the fix over-refusing, not against the bug.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.
2026-08-28 01:06:02 +02:00
denkfabrik-li 9cc469b111 Make the storage durability dashboard test assert the verdict
The test named for carrying the verdict to the system widget only
asserted that the 'system' key exists. It is an unconditional key of the
Inertia::render array and is allowed to be null, and Inertia's has() is a
key check, so the assertion held whether or not the verdict was in there.
Deleting 'storage_durability' from DashboardController::systemInfo() left
the file green.

Substitute the class the way the rest of the file already does and assert
the payload, as InstallationKindTest does for install_kind next door.
2026-08-28 00:36:32 +02:00
denkfabrik-li 4469648d82 Stop the update tests emptying bootstrap/cache for every other worker
`UpdateWelcomeTest > staff who may not read system information are not
interrupted` fails on a parallel run roughly one time in six, with

    BindingResolutionException: Target [Inertia\Ssr\Gateway] is not
    instantiable

in a file that has nothing to do with updates. Run alone it is green
every time. The cause is not in that file.

`clear-compiled` deletes bootstrap/cache/packages.php and
bootstrap/cache/services.php. There is one of each for the whole
checkout, and `pest --parallel` gives eight worker processes the same
one. Instrumented over three full runs, the real command ran 12 times per
run -- 11 from UpdateCommandTest, 1 from StaleCodeNoticeTest -- and the
other workers observed the package manifest missing at boot 46 times.

What that costs is in PackageManifest::getManifest():

    if (! is_file($this->manifestPath)) {
        $this->build();
    }

    return $this->manifest = is_file($this->manifestPath) ?
        $this->files->getRequire($this->manifestPath) : [];

A worker that loses the second is_file() to another worker's unlink gets
`[]`: no discovered packages, so no package service providers, so
Inertia's is never registered and `Inertia\Ssr\Gateway` is never bound.
The next page it renders dies in the compiled root view, where
`@inertia` resolves that interface. Any test in any file, whichever one
happened to be booting.

Both halves measured. Building the manifest with inertia-laravel in
`dont-discover` reproduces the reported failure exactly -- same test,
same exception, same frame (`app('Inertia\Ssr\Gateway')` from the
compiled app.blade.php). And 12 real `clear-compiled` calls per run is
the count above.

UpdateCommandTest already owns a double for this, and says why in its own
docblock: the artisan call is a seam. Nine of its tests and one in
StaleCodeNoticeTest simply do not use it. None of them asserts that a
command ran -- they assert EnsureSystemRoles, the settings writes, the
activity log and the welcome marker, and the double touches none of
those. So the seam now covers the file, through a beforeEach rather than
per test, because the next test added here should not have to know any of
this.

The double moves to tests/Support and its helper to tests/Helpers.php,
for the reason that file documents: Pest hands whole files to workers, so
a class declared in one test file does not exist for another.

Not changed: UpdateInstallation. `clear-compiled` belongs in a real
update. Also not changed: giving each worker its own bootstrap/cache
through APP_PACKAGES_CACHE and friends. That would make the destruction
cheap rather than remove it, and nothing in the suite needs those
commands to run at all.

One new test, on the files rather than on the recorded call list -- a
future double that forgot to intercept one command would still satisfy a
call-list assertion. Counter-checked: with the beforeEach removed it goes
red on both manifests being gone (1 failed / 22 passed).

Eight consecutive parallel runs green after the change; the manifests'
mtimes are untouched by a full run, where before they were rewritten
every time. Full suite passes (2049 passed / 2 skipped). PHPStan level 8
clean -- it analyses `app` only, so it does not cover this change.

Pre-existing and left alone: pint reports `ordered_imports` on
UpdateCommandTest.php. Its import block is misordered on main too.
2026-08-28 00:32:57 +02:00
denkfabrik-li 26205082c2 Stop a folder deleting the files inside it that its owner may not delete
FoldersController::destroy() authorizes `delete` on the folder and nothing
else. FolderService::delete() then soft-deletes every file in the subtree,
and File::booted()'s `deleted` hook takes the bytes off disk. There is no
restore.

FilePolicy::delete asks two questions the folder route never reaches:
`delete_others_files` for somebody else's upload, and
StaffLibraryScope::allowsFile on top of it. Measured with a role holding
create_own_folders, delete_files, upload and edit_files -- the shape the
Client Manager system role already has, minus delete_others_files:

  DELETE /files/{someone-elses}   403, the file is still there
  DELETE /folders/{their-folder}  302, the file and its bytes are gone

MyFoldersController::destroy already refuses the client half of this exact
cascade, and says why: "Owning the folder is not authority over content
someone else put in it... Refuse rather than silently destroy them." This
is the staff half of the same sentence.

Counted rather than asked per file. A folder can hold thousands, Gate
resolves a fresh policy for every check, and a per-row policy check on a
listing is the cost 0a8b609e went to some trouble to remove. Both halves
of FilePolicy::delete are expressible in SQL: the permission half is
constant for the viewer, and the library half is the query
StaffLibraryScope already memoises per request. Somebody holding both
delete permissions with no library scope short-circuits before the query
runs at all, so the common case pays nothing.

Not changed, deliberately:

- The service. FolderService::delete stays dumb. Its other caller applies
  the client rule ("files you did not upload"), which is a different
  predicate, and putting both in one place is the drift this codebase
  keeps refactoring away from.
- The client half. MyFoldersController is already correct.
- Nothing partial. A blocked folder is left whole rather than emptied of
  what the actor may delete -- half a tree is worse than either answer.

Worth saying plainly: this is a behaviour change. A folder delete that
used to succeed now refuses, and somebody will notice. The alternative is
irreversible loss of files the same person is refused one route over.

Six tests. Four measured red against the unguarded controller (4 failed /
2 passed), one per half of the predicate: the permission half, its
message, a nested file, and the library half -- that last one with both
delete permissions held, so only StaffLibraryScope can refuse. The two
that stay green either way are the other side of the question -- that a
folder holding only your own files still goes, and that an administrator
holding both permissions is unaffected. They guard against the fix
over-refusing, not against the bug.

Full suite passes (2054 passed / 2 skipped), PHPStan level 8 clean.

The new string is English only, per CONTRIBUTING.md -- translations are
their own pass.
2026-08-28 00:32:33 +02:00
denkfabrik-li ab6e9eecf3 Ask the seat cap where a pending client is approved through edit()
SeatAllowance says a cap is only a cap if every door asks, and has a test
per door for that reason. Two doors do not ask.

The moment a seat is spent is the moment `account_requested` is cleared.
Five places do that. approve(), both store()s and ClientProvisioning ask
guardClient(); AccountConversion asks it through guardToClient(). The two
update()s -- web and API -- clear the flag with no guard at all, under a
comment that names exactly what they are doing:

    // Activating a pending account through the edit screen counts as
    // approval and clears the request flag.

Measured with clients: 0, one pending registration:

  POST /account-requests/{id}/approve       refused, flag still set
  PATCH /clients/{id}          active=true  approved, clientUsed() 0 -> 1
  PATCH /api/v1/clients/{id}   active=true  approved, clientUsed() 0 -> 1

A managed installation at its cap therefore keeps taking clients on, from
the edit screen or a PATCH, for as long as registrations keep arriving --
and self-registration is open to strangers, so the supply is not the
operator's to control.

Inside the branch, not above it. Above it, an installation sitting at its
cap could not rename a client it already holds, which would trade one
wrong refusal for another. There is a test pinning that.

The field is `active` rather than the default `email`: on this screen the
administrator is toggling `active`, and an error under the email field
would point at the wrong thing. approve() has no form of its own, so it
keeps the default.

Three tests, per door as the file's other eight are. The two door tests
were measured red against the unguarded controllers (2 failed / 18
passed). The third -- that editing an existing client still works at the
cap -- is green either way: it guards against the fix being written a
line too high, not against the bug.

Full suite passes (2051 passed / 2 skipped), PHPStan level 8 clean.

One thing worth knowing that this branch does not touch: on a parallel
run, `UpdateWelcomeTest > staff who may not read...` fails roughly one run
in six on untouched main, with `BindingResolutionException: Target
[Inertia\Ssr\Gateway] is not instantiable`. Measured over 24 baseline runs
before this change existed. It is not this fix, and it is not in scope
here, but it will start being visible as soon as the workflow parses
again.
2026-08-28 00:19:05 +02:00
denkfabrik-li 1dc274e896 Let an enforced user reach the far side of the confirm-password screen
EnforceTwoFactor exempts by route name, and only the GET half of
confirm-password has one. routes/auth.php:95 names the form
`password.confirm`; :98 registers its submission with no name at all, and
Route::named() answers false for a null name.

So the loop the exemption exists to prevent is still there, one step
further along. With Setting::TwoFactorEnforcement set to staff, clients
or all, an un-enrolled account walks:

  GET   /dashboard                  -> two-factor.show
  GET   /system/settings/security   -> two-factor.show
  PATCH /system/settings/security   -> two-factor.show
  POST  /settings/two-factor        -> /confirm-password   (RequirePassword)
  GET   /confirm-password           -> 200, the form renders
  POST  /confirm-password           -> two-factor.show     <- not exempt

`auth.password_confirmed_at` is never written, so enrolling can never
start, and every route that is not on the exemption list stays shut --
including Settings -> Security, the one screen that could turn
enforcement back off. Logout is the only door left; recovery is CLI or
database access. It takes one administrator turning the setting on to
reach it, and it reaches every account on the installation at once,
including their own.

The fix is the name. `password.confirm*` then covers both halves of one
screen, matching `two-factor.*` in the same expression; the namespace
belongs entirely to a flow enrolment already depends on being reachable,
and the route table has nothing else under it -- `password.confirm` (GET)
and `password.confirm.store` (POST) are the two it reaches.

Exempting the submission grants nothing further. store() validates the
password, writes a session flag and redirects; the redirect it issues
enters this middleware like any other request, so Settings -> Security is
still answered with two-factor.show after confirming. What changes is
that enrolment can now be started.

Two tests, both measured red against the unfixed middleware: the password
confirmation sticks, and enrolment can be started afterwards (the secret
is written and the screen reports `pending`).

Also named the redirect the existing test settles for. `->assertRedirect()`
with no target passes on this middleware bouncing the request back to
two-factor.show, which is the shape that file exists to refuse. It is a
clarification rather than a guard -- that assertion is green either way,
since the redirect it sees comes from RequirePassword.

Full suite passes (2050 passed / 2 skipped), PHPStan level 8 clean.
2026-08-27 23:53:53 +02:00
denkfabrik-li 7045da7450 Leave the test workflow one concurrency block, so it parses again
c05927c1 added a `concurrency:` block on the premise that the suite never
got one. It already had one, four lines above -- the hunk header of that
diff reads `@@ -50,6 +50,23 @@ concurrency:`, which is the existing block
it was appended below.

A YAML mapping cannot carry the same key twice, so the file has not
loaded since. GitHub still creates a run and then schedules nothing:

  553f5fd2  (last green)  run 33036453748  jobs=1  ci -> success
  d58e4830  (main)        run 33114046849  jobs=0  failure

Every run since has that shape, and the run list names it in passing:
those runs appear as `.github/workflows/tests.yml` where the green ones
appear as `tests`, because the `name:` key sits inside the file that did
not parse. `linter` is unaffected -- it carries one block -- which is why
351da21e shows a green linter beside a failed tests run, and the tree
reads as half-checked rather than unchecked.

Reproduced with a parser rather than inferred from the job count:

  before -> THREW: Duplicate key "concurrency" detected at line 66.
  after  -> parsed ok, top-level keys: name,on,concurrency,jobs

Kept the second block, verbatim, because it is the one c05927c1 meant to
end up with and its comment carries the reasoning -- including the
tradeoff that an intermediate commit on `main` can end up with no run of
its own. The two group keys are interchangeable: `github.workflow` is
constant within a workflow, so `tests-${{ github.workflow }}-${{ github.ref }}`
and `tests-${{ github.ref }}` produce the same grouping. Worth knowing
that lint.yml still uses the first shape, if you would rather the two
files read alike.

No test. The failure is loud on the next push, and a test that parses a
workflow file would be a second place to keep the same rule.
2026-08-27 23:53:37 +02:00
ignacionelson d58e48301f Move the seat number to the end of the sentence
It read "limited to 1 staff accounts" -- the number sat directly in front
of a countable noun, which is the message a free-tier customer meets the
first time they try to add anybody.

Adding plural forms would fix English and not much else. Polish, Czech and
Russian inflect the noun by the number in front of it, on a three-way split
that a two-form string cannot express, so ':count kont' cannot be right for
every value however many variants it carries. Ending the sentence on the
number means no language has to agree with it -- the same shape the other
counted strings here already use.

Both strings rewritten in all sixteen locales rather than left to the next
translation pass, since the old key would otherwise go missing and block a
build. Checked at 1 and at 25 in English, Spanish, German, Polish and
Russian.
2026-08-27 17:35:10 -03:00
ignacionelson c49811f3c0 List the issues a release closed
The summary reads well and says nothing a reader can chase. The numbers and
titles are the way back to the original report, so they go at the end where
they are available without being in the way -- summary at the top for
whoever is deciding whether to upgrade, paper trail at the bottom for
whoever is looking for their own bug.

Generated from the closed-since date rather than hand-picked, and titled
'closed since' rather than 'fixed in' so no per-issue judgement is needed
about how each one was resolved.
2026-08-27 16:50:11 -03:00
ignacionelson 351da21e8d Stop the text half of an email printing its link twice in brackets
Laravel's notification view writes the subcopy URL as [$url]($url).
The HTML half parses that into an anchor; the text half parses nothing,
so it arrives as literal brackets around a duplicated address. With the
button line above it the URL appeared three times in one message.

It reads as broken, and it reads broken in a specific direction: a long
opaque token, the recipient's address in the query string, and a
duplicated link in brackets is the shape of a phishing template. On a
password reset, which is often the first mail an installation ever sends
somebody, from a domain with no reputation yet.

Fixed the way every other component in that message already handles the
same split -- one name, two files, Laravel picks per half. Which meant
publishing the framework's view for a one-line change, so there is a note
in it saying to re-copy on upgrade.

Seen in a real reset mail, not in a test.
2026-08-27 15:29:25 -03:00
ignacionelson c172d0d645 Cut 2.2.0 down to the list and the notes
The detail underneath was 400 lines of two-and-three-sentence entries.
Written to be complete, and complete is not the same as read: the list at
the top already says what changed, and the long version mostly restated it
at length for somebody who had stopped reading.

The credits do not go with it. Most of the boundary work in this release
came from outside, and dropping the names to save space would be taking
somebody's contribution off the record to tidy a file. One line at the end
instead of twenty inline.

TRUSTED_PROXIES said 'see the fix below' and there is no longer a below.
2026-08-27 14:54:24 -03:00
65 changed files with 2163 additions and 579 deletions
-6
View File
@@ -44,12 +44,6 @@ on:
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
# A second push supersedes the first: there is no value in finishing a run
# for a commit nobody will look at again.
concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# A second push supersedes the first — the later run covers a superset of
# what the earlier one was checking, so finishing both buys nothing and
# costs a runner.
+65 -391
View File
@@ -13,6 +13,51 @@ Anything under **Upgrade notes** is something you have to do, not something we d
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
## 2.2.1 — 28 August 2026
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
application already enforced — including two that could lock you out of your own installation.
**Merged**
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
**Also fixed**
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
accounts".
### Upgrade notes
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
no migrations, no settings and no new environment values.
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
clearing out files other people uploaded, that now refuses and says how many files are in the way.
It is the same rule the file list has always applied one screen over — the folder was the way
around it, and what it removed was not recoverable.
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
every one of the above.
### Issues closed since 2.2.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
## 2.2.0 — 27 August 2026
A big release. Most of it closes holes in who can see what. The rest is a handful of new things.
@@ -77,401 +122,30 @@ installed ProjectSend by hand.
a banner naming the problem and the fix.
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
was not before — see the fix below. Set it in `.env`, and do not run `config:cache`, which stops
`.env` being read at all.
was not before. Set it in `.env`, and do not run `config:cache`, which stops `.env` being read at
all.
### Added
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who found, diagnosed and fixed most
of the boundary work above, and to [@mstewart14](https://github.com/mstewart14),
[@elibrachas](https://github.com/elibrachas), [@mueller7382](https://github.com/mueller7382) and
[@pabloalvarez44](https://github.com/pabloalvarez44) for reports and fixes.
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
again, and **Test connection** checks it can actually reach the bucket before you switch anything
over — using a probe that works with a least-privilege key, rather than one that needs permission
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
short-lived signed link, exactly as they already were for S3.
### Issues closed since 2.1.0
Nothing changes for an existing installation. Configurations saved before this release are S3, are
still S3, and are not asked to say so. Files already stored stay where they are — the setting
applies to new uploads, and there is still no migration between backends.
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- **A maximum size for zip downloads.** A new Settings → Downloads screen sets the largest selection
anyone can ask for as a single zip — 2 GB out of the box, any figure you like, or 0 for no limit.
Building an archive costs disk space and occupies the background worker for as long as it takes to
write, so one person asking for a whole library at once used to hold up every notification email
behind it. Ask for more than the limit and you are told how large your selection is and what the
ceiling is, rather than simply refused; each person can have one archive being prepared at a time,
for the same reason.
- **ProjectSend tells you if nothing is building your zip downloads.** The change below gives zip
building its own queue, which a manual install's background worker has to be told about. Miss that
and the failure is silent: email keeps going out, zip downloads simply never finish, and nothing
in any log says why. Staff who can see system information now get a banner naming the problem and
the one-line fix, so nobody has to work it out from a spinner that never stops.
- **Zip downloads no longer hold up your email.** Preparing a large archive can take a while, and it
used to run on the same queue as everything else — so one big zip could delay every notification
email behind it. Zip building now has a queue of its own, and the Docker images run a second
background worker for it.
**Manual installs:** your background worker has to be told about the new queue, or zips will never
finish and nothing will say why. `update.sh` spots this and offers to fix the worker service for
you, keeping a copy of the old one — so for most people there is nothing to do but say yes. If you
update by hand, or your worker already names its own queues (the updater will say so rather than
edit a deliberate arrangement), add `zips` to its `--queue` list and reload systemd. Docker
installations need no change. See INSTALL.md for the two-worker setup if you would rather keep the
two kinds of work apart.
- **A deleted account's email address can be used again.** Deleting an account keeps its record for
a grace period before erasing it for good, and the address stays reserved until that happens — but
only accounts that deleted *themselves* were ever scheduled for erasure. An account an
administrator deleted sat in that state permanently, and its address could never be reused, with
nothing on screen to explain why. Every deletion now schedules the erasure the same way, whoever
performed it, and the staff screens explain a reserved address rather than saying only that it is
taken: which date it frees up, or which command frees it sooner. Public registration deliberately
keeps the plain "already taken" message, since telling a stranger the address once had an account
here is the disclosure that message exists to avoid.
Accounts deleted before this change keep their old state on purpose — stamping them during an
update would quietly start a countdown to erasure that nobody chose. The console command named in
the new message handles those.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1678](https://github.com/projectsend/projectsend/pull/1678), closing
[#1648](https://github.com/projectsend/projectsend/issues/1648))
- **A staff role limited to its own clients now stays limited.** Several ways around that limit are
closed together, because any one of them made the rest decorative. A role holding the "manage
users" permission could edit its own role and simply switch the limit off; it could hand itself
clients it was never assigned; it could promote any client on the installation to a staff account,
which is the most far-reaching thing that can be done to a client record. Uploading into, or
moving a file into, a folder belonging to somebody else's clients is refused too, as is browsing
the folder pickers past your own tree. None of this was reachable with any role that ships with
ProjectSend — each needed a custom role built on the roles screen — but the combinations are ones
the screen offers, so anyone who built one should update.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1681](https://github.com/projectsend/projectsend/pull/1681),
[#1694](https://github.com/projectsend/projectsend/pull/1694),
[#1697](https://github.com/projectsend/projectsend/pull/1697),
[#1700](https://github.com/projectsend/projectsend/pull/1700) and
[#1702](https://github.com/projectsend/projectsend/pull/1702))
- **A public file's private notes stay private.** The comment thread on a publicly listed file is
meant to show what any visitor sees. It was instead answering signed-in visitors as themselves, so
simply having an account — any account — showed staff-only notes on that file, or the messages
addressed to that file's clients. Being signed in now shows you what a visitor sees, plus your own
comments, unless you were entitled to see the file anyway.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1695](https://github.com/projectsend/projectsend/pull/1695))
- **A client is no longer shown the names of folders they cannot open.** Browsing into a folder in
the client portal listed every subfolder inside it, including ones shared with somebody else.
Opening one was always refused, so what escaped was the name — which can be enough, when folders
are named after the people they belong to.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1690](https://github.com/projectsend/projectsend/pull/1690))
- **The maximum file size now applies to large uploads.** Big files are sent in pieces, and the size
limit was only checked against the size the sender *claimed* before sending anything. Declaring a
tiny upload and then sending gigabytes passed every check. The assembled file is now measured
against the limit before it is accepted.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1682](https://github.com/projectsend/projectsend/pull/1682))
- **A download limit now holds when a zip is collected.** Preparing an archive never spent anybody's
download allowance, and only collecting one did — so an archive prepared while a file was still
available stayed collectable after its limit was spent, and several could be held that way at
once. The limit is now checked at the moment the archive is handed over, which is also the moment
it is spent. Archives also record exactly which files went into them, so the download history
counts what was actually delivered rather than re-guessing it afterwards.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1692](https://github.com/projectsend/projectsend/pull/1692))
- **Public downloads work on installations using external storage.** The public listing's download
link always answered as though the file were on the server's own disk, so on an installation
keeping files in object storage it pointed at a path that had never been written. Its neighbours
on the same page — thumbnails and previews — already handled both. Now it does too.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1693](https://github.com/projectsend/projectsend/pull/1693))
- **A large upload cannot be finished twice at once.** A retry or a double submit arriving while the
first was still assembling could interleave with it, storing bytes that no longer matched the
file's own checksum, or recording the same upload twice. Finishing an upload now takes a lock for
that upload, and a second attempt is turned away rather than joining in.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1686](https://github.com/projectsend/projectsend/pull/1686))
- **Deleting an account either finishes or does nothing.** Removing an account and dealing with the
files it owns were two separate steps with nothing holding them together, so a failure in the
second left the account gone and its files still pointing at it — most easily when the person
chosen to inherit them was deleted in between. Both now happen together or not at all. Relatedly,
a file's stored bytes are now removed once the deletion is committed rather than as it happens, so
a cancelled bulk deletion no longer restores records whose files are already gone.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1688](https://github.com/projectsend/projectsend/pull/1688) and
[#1691](https://github.com/projectsend/projectsend/pull/1691))
- **Creating something with a create-only role no longer ends in an error page.** Roles can grant
permission to create clients, staff accounts, groups or categories without permission to edit
them. Creating one worked, but the page it sent you to afterwards was the edit page, which such a
role may not open — so the record was created and you were shown a permission error, with no way
to tell whether it had worked. You now land back on the create form with the confirmation message.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1684](https://github.com/projectsend/projectsend/pull/1684))
### Fixed
- **Accounts migrated from v1 can sign in again.** On some installations brought over from
ProjectSend Legacy, every migrated person got an error page instead of a login screen — while
anybody whose account was created in v2 signed in perfectly. The cause was the label on the stored
password. Older versions of PHP wrote `$2a$` or `$2b$` where newer ones write `$2y$`; all three are
the same algorithm, but ProjectSend only recognised the last one and gave up before it had even
looked at the password. Upgrading relabels the affected accounts in place. Nothing about anybody's
password changes, so there is no reset mail to send and nothing for you to do — the password they
already had simply starts working again. The migration tool no longer creates the problem in the
first place, from version 1.0.3 onwards.
([#1706](https://github.com/projectsend/projectsend/issues/1706), reported by
[@pabloalvarez44](https://github.com/pabloalvarez44))
- **Sessions no longer break behind a reverse proxy.** Signing in, or submitting the first-run setup
form, could answer with a page-filling error instead — most visibly for anyone running behind
Traefik, Nginx Proxy Manager or Caddy. `TRUSTED_PROXIES` was being read too early in the boot
sequence to be seen at all, so the setting had never had any effect on a web request. Without it
ProjectSend believed every visitor was arriving from the proxy over plain HTTP, built its links and
cookies accordingly, and rejected the form that came back as though it had come from somewhere
else. Docker installations that set the value as an environment variable were unaffected the whole
time; manual installs, where the guide tells you to put it in `.env`, were not — which is why this
looked so inconsistent. **Upgrade note:** if you run behind a proxy, set `TRUSTED_PROXIES` and do
not run `config:cache`, which stops `.env` being read at all. Both are covered in INSTALL.md.
([#1672](https://github.com/projectsend/projectsend/issues/1672), reported by
[@mstewart14](https://github.com/mstewart14); fixed by
[@elibrachas](https://github.com/elibrachas) in
[#1674](https://github.com/projectsend/projectsend/pull/1674))
- **Saving something after your session has expired now takes you to the login page.** Instead of
being told to sign in again, you got an unexplained error — the dashboard's widget settings and
several settings screens were the usual places to meet it. The cause was a detail of how browsers
follow redirects: they repeat the original request at the new address, so "save this" became "save
this to the login page", which the login page has no idea what to do with. It now answers in a way
that sends the browser to read the page rather than repeat the save. The same thing could happen to
an account that was deactivated while someone was working in it, or one being asked to set up
two-factor authentication, and both are fixed with it.
([#1673](https://github.com/projectsend/projectsend/issues/1673), reported by
[@mstewart14](https://github.com/mstewart14); found, diagnosed and fixed by
[@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1680](https://github.com/projectsend/projectsend/pull/1680))
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
object storage and the storage backend refuses a write — an expired key, a bucket that has been
renamed or removed, a permission that changed underneath you — the upload used to report success
and record the file anyway. The entry appeared in the file list, and the download it promised was
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
file is recorded. Installations keeping files on local disk were never affected.
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
switched on. A share link to a file held in a bucket produced a broken download, and a public
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
correctly everywhere else, which made it look like the share link or the listing was at fault
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
changes for installations keeping files on local disk, which is most of them.
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
nothing happened twice — but it read as though something had, which is the last thing a
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
affected.
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
background, and the server answered by redirecting to the provider — a redirect a browser will not
follow out of a background request to another site. The page sat there with no consent screen and
no error to explain it, so the only reading available was that the button was dead. The server now
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
the login page was never affected, and neither is it now.
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
PHP checks permissions and then hands the web server the path to stream. Where the two run as
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
the file, because uploads are written readable only by the account that wrote them. The rest of
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
uses are readable by every account on the machine, which is the wrong trade on a server where the
web server and PHP are the same user, as they are in the Docker image and on most servers people
set up themselves. The install guide has the full procedure, including the one thing no
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
no matter what ProjectSend asks for.
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
the update instructions for the way you installed it, and it had two answers where it needed
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
including the Compose stack that builds from a checkout of the repository. There is no image
behind those containers to pull, so both commands ran, reported success and changed nothing — and
the dashboard went on offering the same release. Those installations are now recognised and given
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
a release moves its dependencies or its frontend.
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
[@mueller7382](https://github.com/mueller7382))
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
is treated as an error rather than as a "no" — so the one page that asks the question, the
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
it always was: a server that keeps PHP inside a single directory is not our container image, and
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
on your side, and no setting you would have been able to change if there were.
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
big header` and the proxy answered 502. Which pages went over depended on how many assets they
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
browser loses anything, because the tags it actually reads were always in the document. The
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
holding a tighter default.
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
worker and the scheduler, and all of them reported to Docker except the one you need when a
request fails: nginx opened the log files named in its own configuration and wrote to them inside
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
access and error logs now go to the container's output, and the Docker guide has a section on
running behind a reverse proxy that says which side a given message points at.
- **A zip download is never offered over an archive that was not written.** Archives are built in the
background, and the writing all happens at the very end — so a source file deleted while the build
waited its turn, or a disk that filled up, produced no archive at all while the download was still
marked ready. Clicking it then failed with an unexplained error. The same went for a selection
whose files had all become unavailable: an archive with nothing in it is not written to disk
either. Both now fail the build and say why. Large archives were affected differently: a build
taking longer than a minute was killed by the queue worker and the download simply spun forever,
waiting for something that had already stopped. Builds now get the time they need, a build the
queue gives up on reports itself as failed, and the partial files an interrupted build leaves
behind are cleaned up rather than sitting on disk unnoticed.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1687](https://github.com/projectsend/projectsend/pull/1687))
- **Comment moderation now stops at the same boundary everything else does.** A staff role can be
limited to its own assigned clients, and everything in the library respects that — listings,
downloads, file details, and the moderation queue itself. Deleting or approving a single comment
did not. Someone with a client-limited role who also held the comment moderation permission could
remove any comment on the installation by its id, including conversations belonging to clients
they were not assigned to, on files they could not open. No role that ships with ProjectSend
combines those two things, so this needed a custom role to reach; if you have built one, it is
worth updating for. The boundary now lives in the rule itself rather than being restated by each
screen, which is how the gap opened in the first place.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1698](https://github.com/projectsend/projectsend/pull/1698))
- **The dashboard's recent activity now respects a limited role's boundary.** A staff role can be
limited to its own assigned clients, and the activity page has always honoured that — showing only
entries about files, folders and clients in that person's scope. The dashboard's Recent activity
widget did not: it listed the eight most recent entries from the whole installation, file names
and all, to someone who would be refused the files themselves. The Client Manager role ships with
the permission this widget needs, so any installation using it was affected. Both screens now
answer the same way. Nothing changes for an administrator or any unrestricted role.
- **Cached previews are no longer mistaken for stray files.** The tool that finds files sitting on
disk with no database record knew to ignore cached thumbnails, but had never been told about the
larger previews added alongside them. So every cached preview was listed as an unclaimed file:
offered for import on the orphan-files screen, and deleted by the daily cleanup once past its
grace period. Importing one also created a file entry pointing at a path the preview cache owns,
which then vanished the next time that cache was cleared. The list of what counts as a generated
copy is now derived from the copies themselves, so a new kind cannot be left off it again.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1683](https://github.com/projectsend/projectsend/pull/1683))
- **Group membership now respects a limited role's boundary.** A staff role can be limited to its
own assigned clients. Adding somebody to a group, or taking them out, checked only that the person
held the "edit groups" permission — not that the group was any of their business. Because joining a
group hands the new member everything shared with it, someone with a limited role could put one of
their own clients into any group on the installation and, through that client, reach files they
had been refused a moment earlier. Approving or denying a membership request was the same write
through a second door, and the requests screen listed every pending request by name and email,
including clients outside the viewer's roster. All of it is now held to the same boundary the rest
of the library uses, and the sidebar count agrees with the screen behind it. No role that ships
with ProjectSend combines the two permissions this needed, so reaching it took a custom role.
Nothing changes for an administrator or any unrestricted role.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1701](https://github.com/projectsend/projectsend/pull/1701))
- **Declining a group membership request now happens once.** Approving a request that had already
been decided was refused; declining one was not, and declining is not a repeatable act. Each
repeat re-dated the decision — which is what the client's waiting period before asking again
counts from — so the same stale request, sent again, could keep somebody out of a group
indefinitely without anyone deciding anything. It also wrote a second entry in the activity log
and sent the client a second "your request was declined" email for one decision. The queue only
ever lists requests still waiting, so nothing on screen offered this. Both actions now behave the
same way.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1705](https://github.com/projectsend/projectsend/pull/1705))
- **The dashboard's expired-files list says whose files it is showing.** For a staff role limited to
its own clients it lists that person's own uploads, since an expired file is already out of reach
of the clients it was shared with. It now says so — "Your expired files", and a line explaining
what is not in the list — rather than presenting a short list as though it were the whole picture.
A warning about what is due to be deleted is worth nothing if it is quietly narrower than it looks.
- **A limited staff role no longer reaches every client record, or every file name on the
dashboard.** Two more places where holding a permission was treated as holding a boundary. The
clients screen listed every client on the installation by name and email, and a role limited to
its own assigned clients could open, rename, or delete any of them — the same through the API.
Separately, the dashboard's largest-files, expired-files and top-clients widgets named files and
clients from across the whole installation, which mattered more because the Client Manager role
that ships with ProjectSend holds the permission those widgets need. Both now use the same rule
the rest of the library already did. Installation-wide totals stay installation-wide: a count
carries no names. Nothing changes for an administrator or any unrestricted role.
- **Notification settings accept only the switches they offer.** Saving your notification
preferences would store a row for any name a request happened to carry, including ones nothing in
ProjectSend can send. Such a row was never read again and could not be seen or removed from the
screen, so the table quietly collected entries nobody could reach. The form now checks what comes
back against the same list it offered, so the two cannot drift apart. Nothing reachable from the
screen changes — it only ever sends back switches it was given.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1689](https://github.com/projectsend/projectsend/pull/1689))
- **A two-factor recovery code is now spent exactly once.** Using a code removed it from your list
by rewriting the whole list, so two sign-in attempts arriving at the same moment could each save
their own copy and put back the code the other had just spent. Nobody could get in who was not
already holding a valid code, but a code you had crossed off a printed sheet — or watched somebody
type — could quietly start working again, which is the one thing recovery codes promise not to do.
The code is now removed from the record as it stands at that moment, under a lock, so a second
attempt cannot undo the first.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1704](https://github.com/projectsend/projectsend/pull/1704))
- **A file can no longer be filed into a folder that has been deleted.** Deleting a folder deletes
everything inside it, so a file that lands in one afterwards sits somewhere that was already
emptied — reachable by link and in search, but missing from the folder listing its uploader would
look in. Uploading or moving a file into a deleted folder now says so instead, and picks up the
case where a folder is deleted while a large upload is still transferring: the finished file lands
at the top level rather than being thrown away, since the transfer had already happened. The
message says the folder no longer exists rather than that the value was invalid.
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
[#1703](https://github.com/projectsend/projectsend/pull/1703))
- **A limited staff role can no longer rename or delete a group it has no part in.** Group
membership was already held to that boundary; the group itself was not, which was the sharper half
— sharing a file with a group is how its members reach that file, so deleting the group takes the
access away from every one of them, including clients outside the person's own list. A role
limited to its own clients can still manage any group that shares nothing beyond what it can
already see, so a group it created, or one holding its own clients, stays fully editable. Nothing
changes for an administrator or any unrestricted role.
- [#1627](https://github.com/projectsend/projectsend/issues/1627) — Errors while installing via Docker
- [#1648](https://github.com/projectsend/projectsend/issues/1648) — A deleted account's email address can never be used again
- [#1661](https://github.com/projectsend/projectsend/issues/1661) — Docker update instructions do not update ProjectSend when using official Compose setup
- [#1662](https://github.com/projectsend/projectsend/issues/1662) — Preview files not available on v2.1.0
- [#1663](https://github.com/projectsend/projectsend/issues/1663) — Dashboard 500s on shared hosting: container detection trips open_basedir
- [#1664](https://github.com/projectsend/projectsend/issues/1664) — INSTALL.md: the nginx-in-front-of-Apache path needs the buffer advice too
- [#1668](https://github.com/projectsend/projectsend/issues/1668) — INSTALL.md: X-Accel downloads fail when nginx and PHP-FPM run as different users
- [#1672](https://github.com/projectsend/projectsend/issues/1672) — Projectsend 2 behind Traefik issues 419 when logging in or hitting an error?
- [#1673](https://github.com/projectsend/projectsend/issues/1673) — Projectsend 2: Setting Widget Columns throws error
- [#1675](https://github.com/projectsend/projectsend/issues/1675) — Success toast shows twice after create/delete redirects
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
## 2.1.0 — 18 August 2026
@@ -9,6 +9,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -24,6 +25,7 @@ class ProfileController extends Controller
public function __construct(
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
private readonly StaffAccounts $accounts,
) {}
/**
@@ -104,6 +106,19 @@ class ProfileController extends Controller
$user = $request->user();
assert($user !== null);
// The rule every other door into this already asks: Staff update(),
// guardDeletable(), and both role-conversion directions. This one
// did not, and self-deletion is the one door where the account
// being removed is certainly signed in — so the last active
// administrator could take themselves out, leaving no live staff
// row at all. EnsureSetupIsComplete then reopens first-run setup to
// anybody who asks, which is the other half of this and is closed
// below.
$this->accounts->guardLastAdministrator(
$user,
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
);
Auth::logout();
// Self-deletion: soft delete now, permanent GDPR erasure after
@@ -191,7 +191,11 @@ class ClientsController extends Controller
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
}
// Approval, and so the moment the seat is spent — same rule the
// web edit screen and approve() answer to. Inside the branch, so a
// capped installation can still edit a client it already holds.
if (($validated['active'] ?? false) && $client->account_requested) {
$this->seats->guardClient('active');
$client->account_requested = false;
}
@@ -99,11 +99,23 @@ class ClientsController extends Controller
'pagination' => Pagination::meta($clients),
'filters' => $filters,
'reassign_candidates' => $this->accountDeletion->candidates(),
// Null on a self-hosted install: no limit, nothing to say.
'seats' => $this->seats->clientState(),
]);
}
public function create(): Response
public function create(): RedirectResponse|Response
{
// The same courtesy UsersController::create() does: a full
// installation is an ordinary state on a managed plan, so say so
// before somebody fills in a form that cannot be submitted. The
// guard in store() is still the rule; this is only the door.
$seats = $this->seats->clientState();
if ($seats !== null && $seats['full']) {
return redirect()->route('clients.index')->with('error', $seats['message']);
}
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
@@ -234,8 +246,13 @@ class ClientsController extends Controller
]);
// Activating a pending account through the edit screen counts as
// approval and clears the request flag.
// approval and clears the request flag — which is the moment a
// seat is spent, so the cap is asked here for the same reason
// AccountRequestsController::approve() asks it one screen over.
// Inside the branch, not above it: an installation at its cap must
// still be able to rename a client it already has.
if ($client->account_requested && $validated['active']) {
$this->seats->guardClient('active');
$client->account_requested = false;
}
+18 -1
View File
@@ -220,10 +220,27 @@ class FileComments
return null;
}
// Asked of the column, not of the relation. client_context_id is
// cascadeOnDelete, but a user is soft-deleted, so the cascade
// never fires: the column goes on pointing at a row that is still
// there while the relation resolves to null. Branching on the
// relation therefore read "this is Alice's conversation" as "this
// has no conversation" — and a null context on a Clients comment
// is the branch every client on the file reads (see
// VisibleCommentScope's opening rule). A private reply became a
// circular, and canAssignClient below was skipped on the way.
if ($replyTo->client_context_id === null) {
return null;
}
$client = $replyTo->clientContext;
if ($client === null) {
return null;
// The column points at somebody, and that somebody is gone.
// There is nobody to answer, and the one outcome that must
// not follow from a filled column is the broadcast above, so
// this refuses rather than falling through to it.
throw new AuthorizationException('You cannot reply in this conversation.');
}
if (! $this->library->canAssignClient($author, $client)) {
+18 -4
View File
@@ -113,18 +113,32 @@ class FileComment extends Model
* The name to show. Snapshotted for guests at write time; read live
* for accounts so a rename is reflected everywhere at once.
*
* author_id cascades on delete, so a row that has one always has the
* account behind it — there is no deleted-author case to snapshot
* against, unlike the activity log's actor_name.
* A deleted account is still read. author_id cascades on delete, but
* a user is soft-deleted and the cascade never fires, so the row
* behind a deleted commenter is still there — and reading it through
* the plain relation returned null, which sent a named client's
* comment out as "Anonymous". That is what a guest comment looks
* like, and a guest comment is governed by different rules; the two
* must not be able to look the same. Whether the author is a guest is
* decided by author_id alone, which is also what isFromGuest() asks.
*/
public function authorName(): string
{
if ($this->author_id === null) {
return $this->guest_name ?? (string) __('Anonymous');
}
$author = $this->author;
if ($author !== null) {
return $author->name;
}
return $this->guest_name ?? (string) __('Anonymous');
// Trashed: the row is still there, the relation simply will not
// hand it over. Nothing comes back only once the grace-period
// erasure has removed the row for real.
$name = $this->author()->withTrashed()->value('name');
return is_string($name) ? $name : (string) __('Anonymous');
}
}
@@ -405,10 +405,32 @@ class FoldersController extends Controller
return back();
}
public function destroy(Folder $folder): RedirectResponse
public function destroy(Request $request, Folder $folder): RedirectResponse
{
Gate::authorize('delete', $folder);
$viewer = $request->user();
assert($viewer !== null);
// Deleting a folder cascades to every file in its subtree, and a
// File's `deleted` hook removes the bytes from disk — there is no
// restore. Authorizing the folder is not authorizing its contents:
// FilePolicy::delete asks for `delete_others_files` on somebody
// else's upload, and for the library boundary on top of that, and
// neither question is asked anywhere on this path.
//
// MyFoldersController::destroy already refuses for the client half
// of the same cascade, in the same words. This is the staff half.
$blocked = $this->undeletableFileCount($viewer, $folder);
if ($blocked > 0) {
return back()->with('error', trans_choice(
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
$blocked,
['count' => (string) $blocked],
));
}
$name = $folder->name;
$parentId = $folder->parent_id;
@@ -419,6 +441,50 @@ class FoldersController extends Controller
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
}
/**
* How many files in this folder's subtree the viewer may not delete.
*
* Asked as one count rather than FilePolicy::delete per file: a folder
* can hold thousands, Gate resolves a fresh policy for every check, and
* a per-row policy check on a listing is the cost 0a8b609e went to
* some trouble to remove. The two halves of FilePolicy::delete are
* expressible in SQL — the permission half is constant for this
* viewer, and the library half is the query StaffLibraryScope already
* memoises per request.
*
* Somebody holding both delete permissions and no library scope can
* delete anything in the subtree by construction, so they never pay for
* the query at all.
*/
private function undeletableFileCount(User $viewer, Folder $folder): int
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
}
return File::query()
->whereIn('folder_id', $folder->subtreeFolderIds())
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
if (! $mayDeleteOwn) {
$outer->orWhere('uploaded_by', $viewer->id);
}
if (! $mayDeleteOthers) {
$outer->orWhere(fn (Builder $others): Builder => $others
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
}
if ($scoped) {
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
}
})
->count();
}
private function resolveParent(?User $user, ?int $parentId): ?Folder
{
if ($user === null || $parentId === null) {
+52 -10
View File
@@ -135,7 +135,14 @@ class BuildZipDownloadJob implements ShouldQueue
// count, is what lets the download action log exactly what it
// hands over instead of resolving the selection a second time
// against a scope that may have moved since.
$addedIds = [];
//
// Keyed by id rather than appended to a list, because it is
// also what keeps a file out of the archive twice. The loose
// selection cannot repeat itself — one whereIn on the primary
// key — but a selected folder can hold a file that was also
// named loosely, and the cap is 10000 sources, so the check
// has to be a lookup rather than a scan.
$added = [];
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
// Re-checked here for the same reason visibility is: the
@@ -150,11 +157,11 @@ class BuildZipDownloadJob implements ShouldQueue
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
$totalSize += $file->size;
$addedIds[] = $file->id;
$added[$file->id] = true;
}
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $addedIds);
foreach ($this->outermostFolders($zipDownload->folder_ids) as $folder) {
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
}
// Re-checked here, not only in ZipDownloadsController: the
@@ -197,7 +204,7 @@ class BuildZipDownloadJob implements ShouldQueue
@unlink($tempFile);
}
if ($written !== true || $addedIds === []) {
if ($written !== true || $added === []) {
if ($written !== true) {
// What the requester sees stays generic: a libzip
// string means nothing to them and can name a server
@@ -229,8 +236,8 @@ class BuildZipDownloadJob implements ShouldQueue
'status' => ZipDownload::STATUS_READY,
'path' => $relativePath,
'total_size' => $totalSize,
'file_count' => count($addedIds),
'contained_file_ids' => $addedIds,
'file_count' => count($added),
'contained_file_ids' => array_keys($added),
'skipped_files' => $skipped === [] ? null : $skipped,
]);
} catch (Throwable $e) {
@@ -329,9 +336,9 @@ class BuildZipDownloadJob implements ShouldQueue
* @param array<int, string> $tempFiles
* @param Builder<File> $visible every file the requester may read
* @param list<array{id: int, name: string}> $skipped
* @param list<int> $addedIds every file really written into the archive
* @param array<int, true> $added every file really written into the archive, keyed by id
*/
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$addedIds): int
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
{
$allowance = app(DownloadAllowance::class);
@@ -342,6 +349,15 @@ class BuildZipDownloadJob implements ShouldQueue
$totalSize = 0;
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
// Already in the archive under another part of the selection —
// named loosely, or inside a folder selected before this one.
// Skipped rather than added again: a second entry is a second
// copy of the same bytes, and delivery charges one download
// however many copies went out.
if (isset($added[$file->id])) {
continue;
}
// Holding the folder does not entitle the requester to a file
// inside it whose own allowance is spent — same reason the
// per-file visibility filter is re-derived rather than
@@ -357,12 +373,38 @@ class BuildZipDownloadJob implements ShouldQueue
$entryPath = $this->dedupeName($usedNames, $entryPath);
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
$totalSize += $file->size;
$addedIds[] = $file->id;
$added[$file->id] = true;
}
return $totalSize;
}
/**
* The selected folders with the redundant ones dropped: one that sits
* inside another selected folder is already covered by it.
*
* Zipping both would reach the same file twice, and which of the two
* paths the surviving entry ended up under would be decided by
* whatever order the database returned the rows in. Keeping the outer
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
* Q1/report.pdf — and gives the same archive on every run.
*
* @param list<int> $folderIds
* @return Collection<int, Folder>
*/
private function outermostFolders(array $folderIds): Collection
{
/** @var Collection<int, Folder> $folders */
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
return $folders
->reject(fn (Folder $folder): bool => $folders->contains(
fn (Folder $other): bool => $other->id !== $folder->id
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
))
->values();
}
/**
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
*/
@@ -13,6 +13,7 @@ use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -56,9 +57,20 @@ class GroupsController extends Controller
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
}
public function show(Group $group): GroupResource
public function show(Request $request, Group $group): GroupResource
{
return new GroupResource($group->loadCount('members')->load('members'));
$viewer = $request->user();
assert($viewer !== null);
// The web edit screen's boundary, on its API twin: this is the read
// half of the group that update() and destroy() below already refuse
// to touch, and it hands back the membership with addresses.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
return new GroupResource($group->loadCount('members')->load([
'members' => fn (BelongsToMany $members) => $members
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
]));
}
public function store(Request $request): JsonResponse
@@ -10,7 +10,6 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Support\Pagination;
use App\Support\PublicUrl;
use App\Support\Rules;
@@ -102,8 +101,18 @@ class GroupsController extends Controller
return $target->with('success', __('Group created.'));
}
public function edit(Group $group): Response
public function edit(Request $request, Group $group): Response
{
$viewer = $request->user();
assert($viewer !== null);
// The same reach question update() and destroy() ask, asked one
// step earlier. Without it this was the one group route holding no
// library boundary at all: a scoped staff member could open a group
// whose contents they cannot see, read its membership off the
// screen, and only be refused on save.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
return Inertia::render('groups/edit', [
'group' => [
'id' => $group->id,
@@ -112,14 +121,24 @@ class GroupsController extends Controller
'description' => $group->description,
'public' => $group->public,
],
'members' => $group->members()->orderBy('name')->get()
// Both lists narrow through StaffLibraryScope::clients(), which
// is the listing half of the rule this screen's buttons are
// already guarded with: a member outside the roster cannot be
// removed here (allowsGroupMembership refuses it), and a client
// outside it cannot be added. Naming them anyway, with their
// address, was the same mistake the client list made before
// that method existed. An unscoped viewer sees everything,
// unchanged.
'members' => $group->members()
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
->orderBy('name')
->get()
->map(fn (User $member): array => [
'id' => $member->id,
'name' => $member->name,
'email' => $member->email,
])->all(),
'available_clients' => User::query()
->where('type', UserType::Client)
'available_clients' => $this->scope->clients($viewer)
->whereNotIn('id', $group->members()->pluck('users.id'))
->orderBy('name')
->get()
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
* @mixin Group
*
* Members carry a name and an email, which is what the group edit screen
* already shows to anyone holding `edit_groups`. They are attached only
* when explicitly loaded, so a listing of groups does not become a bulk
* export of every client's address.
* shows the same viewer. That is a claim about the screen, so it holds
* only for as long as the screen does: both narrow the list to the
* clients the viewer may act on, and the controller loading this relation
* is where that narrowing is applied. They are attached only when
* explicitly loaded, so a listing of groups does not become a bulk export
* of every client's address.
*/
class GroupResource extends JsonResource
{
@@ -26,10 +26,12 @@ use Inertia\Response;
/**
* Moving an account between staff and clients.
*
* Community edition only, by the same route group as every other
* staff-account screen — managed installations create staff accounts
* outside the application, so a converter there would be a second,
* unmanaged way to create one.
* Both editions since 2.2.0, by the same route group as every other
* staff-account screen: whoever may create a staff account may promote
* one, and a managed installation limits that by seats rather than by
* closing the screen — AccountConversion asks SeatAllowance on both
* directions, because a promotion spends a staff seat and a demotion
* spends a client one.
*
* The rules live in AccountConversion, which calls StaffAccounts for the
* authority questions. This controller is the request shape and the
@@ -26,12 +26,18 @@ use Illuminate\Validation\ValidationException;
/**
* Staff accounts over the API — the API twin of the /users screens.
*
* **Community only.** Every route is behind `capability:users.manage`, so
* a cloud install answers 403 `capability_unavailable`: managed
* installations create staff accounts outside the application, and an API
* that could mint them there would be a second, unmanaged door into the
* same thing.
* The routes are still registered in every edition so the committed
* Both editions since 2.2.0. Every route is behind
* `capability:users.manage`, which cloud installations now hold as well:
* a platform sells staff seats and the tenant fills them, so an API that
* creates one is the same door the screen is, not a second unmanaged one
* (see Capability::UsersManage). How many it may create is
* SeatAllowance's question, asked here through StaffAccounts, and an
* installation at its limit answers 422 rather than 403.
*
* The capability stays in front of the routes rather than being dropped:
* it is the seam an edition difference would have to travel through, and
* an installation without it answers 403 `capability_unavailable`. The
* routes are registered in every edition either way, so the committed
* OpenAPI document is identical everywhere — the middleware refuses, the
* route table does not lie.
*
@@ -176,15 +182,27 @@ class UsersController extends Controller
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
// Read through Request::boolean() rather than off the validated
// array, for the reason RolesController::guardScopeRemoval spells
// out: the `boolean` rule accepts 0 and "0" as well as false but
// does not cast, so a strict comparison lets through a value the
// model's own `boolean` cast then stores as false anyway. The same
// value goes to the guard and to the write.
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
// The same refusal the web screen makes, and for the same reason:
// locking yourself out is never what was meant.
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
if ($user->is($actor) && $deactivating) {
throw ValidationException::withMessages([
'active' => __('You cannot deactivate your own account.'),
]);
}
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
if (array_key_exists('active', $validated)) {
$attributes['active'] = $request->boolean('active');
}
if (array_key_exists('role_id', $validated)) {
$attributes['role_id'] = (int) $validated['role_id'];
@@ -97,8 +97,16 @@ class SetupController extends Controller
return Inertia::render('setup-success');
}
/**
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
* this asks whether the installation was ever set up, and store()
* below is the door a stranger walks through if the answer is wrong.
* The middleware and this must agree — one of them saying "not set
* up" while the other says "set up" is either a redirect loop or an
* open form.
*/
private function setupIsComplete(): bool
{
return User::query()->where('type', UserType::Staff)->exists();
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
}
}
@@ -14,6 +14,7 @@ use App\Modules\Identity\Models\Role;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
@@ -26,9 +27,17 @@ use Inertia\Inertia;
use Inertia\Response;
/**
* Staff ("system users") management — community edition only; managed
* installations create them outside the application. Clients are a different
* population managed by the Clients module: they never appear here.
* Staff ("system users") management. Clients are a different population
* managed by the Clients module: they never appear here.
*
* Available on both editions since 2.2.0. A managed installation is sold a
* number of seats and fills them itself — see Capability::UsersManage for
* why capacity is the platform's and who fills it is the tenant's.
*
* That makes a full installation an ordinary state rather than an error,
* so `index()` reports the seat position and `create()` refuses to open a
* form nothing can be submitted through. SeatAllowance::guardStaff() still
* runs in `store()`: this is the courtesy, that is the rule.
*/
class UsersController extends Controller
{
@@ -37,6 +46,7 @@ class UsersController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly ApiTokens $apiTokens,
private readonly StaffAccounts $accounts,
private readonly SeatAllowance $seats,
) {}
public function index(Request $request): Response
@@ -99,11 +109,24 @@ class UsersController extends Controller
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
'reassign_candidates' => $this->accountDeletion->candidates(),
// Null on a self-hosted install: no limit, nothing to say.
'seats' => $this->seats->staffState(),
]);
}
public function create(): Response
public function create(): RedirectResponse|Response
{
// Turned away here rather than on submit. Somebody reaching this
// by link or bookmark used to fill in a name, an email and a
// password they had to invent, and learn the installation was full
// from a validation error under the email field — which reads as a
// fault with the address rather than a fact about the plan.
$seats = $this->seats->staffState();
if ($seats !== null && $seats['full']) {
return redirect()->route('users.index')->with('error', $seats['message']);
}
return Inertia::render('users/create', [
'roles' => $this->roleOptions(),
'clients' => $this->clientOptions(),
@@ -40,12 +40,17 @@ class EnforceTwoFactor
return $next($request);
}
// password.confirm is on this list because the two-factor mutation
// password.confirm* is on this list because the two-factor mutation
// routes now require it: without the exemption, enrolling would
// redirect to the confirm-password screen, which this middleware
// would redirect straight back to two-factor.show — a loop that
// locks the user out of the only exit.
if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) {
//
// The pattern covers both halves of that screen. Naming only the
// GET left the form rendering and its submission redirected away,
// so the password was never confirmed and the loop stayed shut
// one step further along than before.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
return $next($request);
}
@@ -16,6 +16,16 @@ use Symfony\Component\HttpFoundation\Response;
* sent to the first-run setup screen. The database is the only source of
* truth — no install flags. Client accounts do not count: setup is about
* having an administrator.
*
* Trashed staff count. "Has this installation been set up" is not the
* same question as "does it have a working administrator right now", and
* only the first one belongs here: a soft-deleted staff row is still
* evidence that setup happened, and an installation that has lost its
* last administrator needs a recovery path, not a stranger filling in
* the first-run form. Deleting a staff account is guarded against
* reaching zero (StaffAccounts::guardLastAdministrator), so this is the
* second lock rather than the first — but the first one is asked at five
* separate doors, and this one is asked once.
*/
class EnsureSetupIsComplete
{
@@ -25,7 +35,7 @@ class EnsureSetupIsComplete
return $next($request);
}
if (User::query()->where('type', UserType::Staff)->exists()) {
if (User::query()->withTrashed()->where('type', UserType::Staff)->exists()) {
return $next($request);
}
@@ -73,10 +73,14 @@ enum Capability: string
// package is installed, not a flag an installation can set. Present
// in this enum even so, because a package cannot extend a closed one
// — core has to publish the key before anything can gate on it.
// Cloud-only — staff seats on a managed instance belong to the
// platform that sold them rather than to the instance, so the tenant's
// own /users screens stay closed (see UsersManage above) and a control
// plane creates, deactivates and password-resets them from outside.
// Cloud-only — marks an installation that a platform provisioned and
// looks after, for the screens that have to know the difference.
//
// It does not close the tenant's own /users screens. It used to say
// so, and that stopped being true when UsersManage opened on both
// editions: a platform sells the seats, the tenant decides who sits
// in them. Capacity is the platform's, and it arrives as
// PROJECTSEND_PLATFORM_MAX_STAFF_USERS rather than as a shut door.
//
// The seat *number* deliberately does not live here. There are no
// billing or plan tiers in this application to key off — the same
@@ -4,9 +4,23 @@ declare(strict_types=1);
namespace App\Modules\Platform\Installation\Console;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Command;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Throwable;
/**
* What this installation is, as a fact rather than a screen.
@@ -33,6 +47,53 @@ use Illuminate\Console\Command;
* an inactive account, or a soft-deleted one — and the divergence looks
* like a billing fault rather than a counting one. So there is one
* definition and this reads it.
*
* ### Is anybody there
*
* `activity.last_staff_login_at` answers the one question a platform
* cannot answer from outside: whether a human still uses this
* installation. It is a timestamp and nothing else — no name, no address,
* no session. Only interactive sign-ins reach it, because that is all
* Laravel's Login event fires for: an integration polling the API every
* hour must not make a dormant installation look busy.
*
* Derived from the activity log rather than denormalised onto `users`. A
* column would need a migration, a listener change and a backfill to save
* one indexed MAX() over a table that is small on exactly the
* installations anybody asks this about. The log is never pruned, and
* erasure anonymises entries rather than deleting them (`actor_type`
* survives on purpose — see AccountEraser), so the answer does not change
* when the person who gave it is forgotten.
*
* ### Storage is the application's number, not the disk's
*
* `storage.bytes` is what this installation holds, summed from the rows
* that record it. Measuring the directory instead was correct until
* external storage went live, and silently stopped being: an upload that
* resolves to a bucket leaves nothing on the volume to measure, so a
* figure taken from the filesystem freezes while the account keeps
* filling. `by_disk` is the same sum split by where the bytes went, which
* is the only way to see what is still sitting on local disk from before
* a cutover.
*
* Trashed files are excluded because they hold no bytes: File's `deleted`
* hook removes them, so a soft-deleted row is a record of something that
* is gone rather than something still costing anything.
*
* ### Health is what a container cannot show from outside
*
* A tenant's queue worker dying is invisible to anything watching the
* container: it is still up, and zips quietly stop building while mail
* stops going out. Same for migrations that failed after a deploy — the
* application answers every request and is a schema behind. Neither is a
* secret; both are already visible to anyone who can open the database,
* which is anyone who can run this command.
*
* ### What core cannot answer
*
* `modules` is filled by whatever packages are installed, through
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
* what it asked for; only the installation knows what loaded.
*/
class StatusCommand extends Command
{
@@ -40,7 +101,7 @@ class StatusCommand extends Command
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats): int
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
{
$status = [
'version' => (string) config('projectsend.version'),
@@ -60,6 +121,31 @@ class StatusCommand extends Command
'limit' => $seats->clientLimit(),
],
],
'activity' => [
// Null means "no staff account has ever signed in here",
// and is emitted rather than left out for the same reason
// an unlimited seat count is: a watcher has to be able to
// tell that apart from "we got no answer". Collapsing the
// two is how a broken probe reads as a dormant fleet.
'last_staff_login_at' => $this->lastStaffLoginAt(),
],
'storage' => $this->storage(),
'health' => $this->health(),
'settings' => [
// Echoed back rather than assumed: an operator writes the
// environment variable, and this is the installation
// saying what it actually applied. Read the way
// EnforceTwoFactor reads it, down to what an unreadable
// value falls back to -- reporting a stricter answer than
// the middleware enforces would be worse than reporting
// none at all.
'two_factor_enforcement' => $this->enforcement($settings),
],
// Cast so an installation with no packages emits {} rather
// than [] -- an empty PHP array encodes as a list, and a
// reader unmarshalling a map breaks on the day it happens to
// be empty rather than on the day it is written.
'modules' => (object) $this->modules(),
];
if ($this->option('json')) {
@@ -72,10 +158,145 @@ class StatusCommand extends Command
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
.$status['health']['failed_jobs'].' failed jobs, '
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
return self::SUCCESS;
}
private function enforcement(Settings $settings): string
{
$value = $settings->get(Setting::TwoFactorEnforcement);
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
?? TwoFactorEnforcement::None;
return $enforcement->value;
}
/**
* What this installation holds, from the rows that record it.
*
* @return array{bytes: int, files: int, by_disk: object}
*/
private function storage(): array
{
$perDisk = File::query()
->groupBy('disk')
->selectRaw('disk, sum(size) as bytes, count(*) as files')
->get();
return [
'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')),
'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')),
// Keyed by disk name rather than a list, because the reader
// wants one of them by name — "how much is still local" — and
// not to walk a list looking for it.
// Same reason as `modules`: an installation holding no files
// at all must still answer with a map.
'by_disk' => (object) $perDisk
->mapWithKeys(fn (File $row): array => [
(string) $row->getAttribute('disk') => [
'bytes' => (int) $row->getAttribute('bytes'),
'files' => (int) $row->getAttribute('files'),
],
])->all(),
];
}
/**
* @return array{pending_migrations: int, failed_jobs: int, queues: array<string, int|null>}
*/
private function health(): array
{
return [
'pending_migrations' => $this->pendingMigrations(),
'failed_jobs' => $this->failedJobs(),
// The two this application actually runs workers for. A depth
// is not a fault on its own -- a busy installation has one --
// but a depth that only ever grows is a worker that died, and
// nothing outside the container can see the difference.
'queues' => [
'default' => $this->queueDepth('default'),
'zips' => $this->queueDepth('zips'),
],
];
}
private function pendingMigrations(): int
{
/** @var Migrator $migrator */
$migrator = app('migrator');
// Every path, not just database/migrations: a package registers
// its own, and a package migration left unrun is exactly the kind
// of half-deploy this is here to report.
$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));
return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
}
private function failedJobs(): int
{
$table = config('queue.failed.table');
if (! is_string($table) || $table === '') {
return 0;
}
return DB::table($table)->count();
}
/**
* Null rather than a crash when the queue cannot be reached, and null
* rather than zero: an unreachable Redis is not an empty queue, and a
* reader watching for a worker that died would read the second as
* everything being fine.
*
* This command is a probe, and a probe that dies on one unreachable
* dependency tells the reader nothing about the facts it could still
* have answered.
*/
private function queueDepth(string $queue): ?int
{
try {
return Queue::size($queue);
} catch (Throwable) {
return null;
}
}
/**
* @return array<string, string|int|bool|null>
*/
private function modules(): array
{
$event = new ResolvingInstallationStatus;
Event::dispatch($event);
return $event->facts;
}
/**
* The most recent interactive staff sign-in, or null if there has
* never been one.
*/
private function lastStaffLoginAt(): ?string
{
$latest = ActivityLog::query()
->where('action', Action::Login->value)
->where('actor_type', UserType::Staff->value)
->max('created_at');
// `action` and `actor_type` carry an index each, so this narrows
// on one of them rather than reading the log.
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
}
/**
* @param array{used: int, limit: int|null} $seat
*/
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Installation\Events;
/**
* "What else is worth knowing about this installation?" — asked once,
* by `projectsend:status`, of whatever packages happen to be installed.
*
* Core cannot answer for them. A managed installation's storage backend
* and the version of the package providing it live in
* projectsend/cloud-modules, which this repository is public and must
* not reference; a control plane still has to be able to observe them,
* and observing is exactly what that command is for.
*
* The distinction this exists to preserve: a platform writing eight
* environment variables knows what it *asked for*. Only the installation
* knows what actually loaded. Those came apart once — a bucket was
* provisioned and a token minted while the container ignored both,
* because its image predated the module that reads them, and the
* configuration sitting beside the files looked perfectly correct.
*
* Listened to by *string* class name from a package, same as every
* other hook here — see docs/extension-points-architecture.md.
*/
final class ResolvingInstallationStatus
{
/**
* What listeners have reported, keyed by name.
*
* Scalars and null only: this is serialised to JSON for a reader
* that is not this application, and a shape it has to walk is a
* shape it has to be taught. Null is a real answer — "asked, and
* the thing is not here" — and it must survive to the document
* rather than being dropped, for the reason the whole file's null
* handling exists: absent and "nothing to report" are different
* facts, and a reader that cannot tell them apart guesses.
*
* @var array<string, string|int|bool|null>
*/
public array $facts = [];
public function report(string $key, string|int|bool|null $value): void
{
$this->facts[$key] = $value;
}
}
@@ -126,8 +126,11 @@ class QuickStart
];
}
// Community only, and the example the brief named: a managed
// installation has no staff accounts of its own to hand out.
// Both editions since 2.2.0. A managed installation was once the
// example of a site with no staff accounts of its own to hand out;
// it is sold seats and fills them itself now, so this step belongs
// on its list too. The capability stays in the condition as the
// seam an edition difference would travel through.
if ($this->permissions->allows($user, Permission::CreateUsers)
&& $this->capabilities->has(Capability::UsersManage)) {
$items[] = [
+80 -9
View File
@@ -25,6 +25,12 @@ use Illuminate\Validation\ValidationException;
* like a billing fault rather than a counting one. So `staffUsed()` and
* `clientUsed()` are public and are what `guard*()` reads.
*
* That second consumer stopped being hypothetical on 2026-08-27: the
* hosted fleet console shows these numbers per tenant, read from
* `projectsend:status --json`. So the rules below are load-bearing on a
* screen support staff read, and changing one changes what they are told
* before it changes what a customer hits.
*
* ### What counts
*
* A soft-deleted account does not. Its address stays reserved until
@@ -84,6 +90,27 @@ class SeatAllowance
->count();
}
/**
* The staff seat position, for a screen rather than a guard.
*
* Null on a self-hosted install: there is no limit, so there is
* nothing for a screen to say about one.
*
* @return array{limit: int, used: int, full: bool, message: string|null}|null
*/
public function staffState(): ?array
{
return $this->state($this->staffLimit(), $this->staffUsed(), fn (): string => $this->staffFullMessage());
}
/**
* @return array{limit: int, used: int, full: bool, message: string|null}|null
*/
public function clientState(): ?array
{
return $this->state($this->clientLimit(), $this->clientUsed(), fn (): string => $this->clientFullMessage());
}
/**
* @throws ValidationException when one more staff account would exceed
* what this installation may hold.
@@ -96,11 +123,7 @@ class SeatAllowance
return;
}
throw ValidationException::withMessages([
$field => __('This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.', [
'count' => (string) $limit,
]),
]);
throw ValidationException::withMessages([$field => $this->staffFullMessage()]);
}
/**
@@ -115,13 +138,61 @@ class SeatAllowance
return;
}
throw ValidationException::withMessages([
$field => __('This installation is limited to :count clients. Remove one, or ask for a larger plan.', [
'count' => (string) $limit,
]),
throw ValidationException::withMessages([$field => $this->clientFullMessage()]);
}
/**
* Why a screen is closed, in the words the guard would have used.
*
* A door that turns somebody away and a guard that refuses them are
* the same rule met at two moments, so they say the same sentence. Two
* wordings of one limit is how a person ends up believing there are
* two limits.
*/
public function staffFullMessage(): string
{
return __('Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.', [
'count' => (string) $this->staffLimit(),
]);
}
public function clientFullMessage(): string
{
return __('Clients on this installation are limited to :count. Remove one, or ask for a larger plan.', [
'count' => (string) $this->clientLimit(),
]);
}
/**
* `full` is derived here rather than in each caller, and from the same
* comparison `guard*()` refuses on. A screen that works out for itself
* whether there is room can disagree with the guard about the edge --
* `used > limit` after an operator lowers a limit is the obvious one --
* and then the button is offered for a form that cannot be submitted,
* which is the whole fault this is here to prevent.
*
* The message travels with the state so a screen never has to write
* its own version of the refusal.
*
* @param callable(): string $message
* @return array{limit: int, used: int, full: bool, message: string|null}|null
*/
private function state(?int $limit, int $used, callable $message): ?array
{
if ($limit === null) {
return null;
}
$full = $used >= $limit;
return [
'limit' => $limit,
'used' => $used,
'full' => $full,
'message' => $full ? $message() : null,
];
}
/**
* Absent, empty and non-numeric all mean unlimited. An operator who
* mistypes the variable gets the self-hosted behaviour rather than an
+1 -1
View File
@@ -118,7 +118,7 @@ return [
|
*/
'version' => '2.2.0',
'version' => '2.2.1',
/*
|--------------------------------------------------------------------------
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Ningú no està creant les descàrregues en zip",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Hi ha una descàrrega en zip esperant des del :date i cap procés en segon pla no l’ha recollida. Les descàrregues en zip ara fan servir una cua pròpia, així que un procés iniciat abans d’aquesta versió mira la cua equivocada — tota la resta, correu inclòs, continua funcionant.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "L’ordre del teu procés en segon pla necessita --queue=default,zips. A INSTALL.md hi ha el fitxer de servei complet.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Aquesta instal·lació està limitada a :count comptes de sistema. Elimina'n un o demana un pla més gran.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Aquesta instal·lació està limitada a :count clients. Elimina'n un o demana un pla més gran."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Els comptes de sistema d'aquesta instal·lació estan limitats a :count. Elimina'n un o demana un pla més gran.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Els clients d'aquesta instal·lació estan limitats a :count. Elimina'n un o demana un pla més gran.",
":used of :limit staff seats used": "Usats :used de :limit comptes de sistema",
":used of :limit client accounts used": "Usats :used de :limit comptes de client",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Aquesta carpeta no es pot eliminar: conté :count fitxer que no pots eliminar.|Aquesta carpeta no es pot eliminar: conté :count fitxers que no pots eliminar."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Nikdo nevytváří stažení ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Stažení ZIP čeká od :date a žádný proces na pozadí si ho nevzal. Stahování ZIP má teď vlastní frontu, takže proces spuštěný před touto verzí sleduje špatnou — všechno ostatní, včetně e-mailu, funguje dál.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Příkaz tvého procesu na pozadí potřebuje --queue=default,zips. Celý soubor služby najdeš v INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Tato instalace je omezena na :count systémových účtů. Odeber jeden nebo si vyžádej větší tarif.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Tato instalace je omezena na :count klientů. Odeber jednoho nebo si vyžádej větší tarif."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Počet systémových účtů v této instalaci je omezen na :count. Odeber jeden nebo si vyžádej větší tarif.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Počet klientů v této instalaci je omezen na :count. Odeber jednoho nebo si vyžádej větší tarif.",
":used of :limit staff seats used": "Využito :used z :limit systémových účtů",
":used of :limit client accounts used": "Využito :used z :limit klientských účtů",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tuto složku nelze smazat: obsahuje :count soubor, který nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count soubory, které nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count souborů, které nemůžeš smazat."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Niemand erstellt die ZIP-Downloads",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Ein ZIP-Download wartet seit dem :date und kein Hintergrundprozess hat ihn übernommen. ZIP-Downloads laufen jetzt über eine eigene Warteschlange, daher überwacht ein vor dieser Version gestarteter Prozess die falsche — alles andere, auch E-Mail, funktioniert weiter.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Der Befehl Ihres Hintergrundprozesses braucht --queue=default,zips. In INSTALL.md steht die vollständige Service-Datei.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Diese Installation ist auf :count Systemkonten begrenzt. Entfernen Sie eines oder fragen Sie nach einem größeren Tarif.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Diese Installation ist auf :count Kunden begrenzt. Entfernen Sie einen oder fragen Sie nach einem größeren Tarif."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Die Systemkonten dieser Installation sind auf :count begrenzt. Entfernen Sie eines oder fragen Sie nach einem größeren Tarif.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Die Kunden dieser Installation sind auf :count begrenzt. Entfernen Sie einen oder fragen Sie nach einem größeren Tarif.",
":used of :limit staff seats used": "Belegt: :used von :limit Systemkonten",
":used of :limit client accounts used": "Belegt: :used von :limit Kundenkonten",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Dieser Ordner kann nicht gelöscht werden: Er enthält :count Datei, die Sie nicht löschen dürfen.|Dieser Ordner kann nicht gelöscht werden: Er enthält :count Dateien, die Sie nicht löschen dürfen."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Nadie está creando las descargas en zip",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Hay una descarga en zip esperando desde el :date y ningún proceso en segundo plano la ha recogido. Las descargas en zip ahora usan su propia cola, así que un proceso iniciado antes de esta versión está mirando la cola equivocada — todo lo demás, incluido el correo, sigue funcionando.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "El comando de tu proceso en segundo plano necesita --queue=default,zips. En INSTALL.md está el archivo de servicio completo.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Esta instalación está limitada a :count cuentas de sistema. Elimina una o pide un plan más grande.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Esta instalación está limitada a :count clientes. Elimina uno o pide un plan más grande."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Las cuentas de sistema de esta instalación están limitadas a :count. Elimina una o pide un plan más grande.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Los clientes de esta instalación están limitados a :count. Elimina uno o pide un plan más grande.",
":used of :limit staff seats used": "Usadas :used de :limit cuentas de sistema",
":used of :limit client accounts used": "Usadas :used de :limit cuentas de cliente",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta carpeta no se puede eliminar: contiene :count archivo que no puedes eliminar.|Esta carpeta no se puede eliminar: contiene :count archivos que no puedes eliminar."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Personne ne construit les téléchargements ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Un téléchargement ZIP attend depuis le :date et aucun processus d’arrière-plan ne l’a pris en charge. Les téléchargements ZIP passent désormais par leur propre file d’attente : un processus démarré avant cette version surveille donc la mauvaise — tout le reste, e-mail compris, continue de fonctionner.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "La commande de votre processus d’arrière-plan doit contenir --queue=default,zips. INSTALL.md donne le fichier de service complet.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Cette installation est limitée à :count comptes système. Supprimez-en un, ou demandez une formule plus large.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Cette installation est limitée à :count clients. Supprimez-en un, ou demandez une formule plus large."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Les comptes système de cette installation sont limités à :count. Supprimez-en un, ou demandez une formule plus large.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Les clients de cette installation sont limités à :count. Supprimez-en un, ou demandez une formule plus large.",
":used of :limit staff seats used": "Utilisés : :used sur :limit comptes système",
":used of :limit client accounts used": "Utilisés : :used sur :limit comptes client",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Ce dossier ne peut pas être supprimé : il contient :count fichier que vous n'avez pas le droit de supprimer.|Ce dossier ne peut pas être supprimé : il contient :count fichiers que vous n'avez pas le droit de supprimer."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Tidak ada yang membuat unduhan ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Sebuah unduhan ZIP sudah menunggu sejak :date dan tidak ada proses latar yang mengambilnya. Pembuatan ZIP kini berjalan di antrean sendiri, jadi proses yang dijalankan sebelum versi ini mengawasi antrean yang salah — selebihnya, termasuk email, tetap berjalan.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Perintah proses latarmu perlu --queue=default,zips. Berkas layanan lengkapnya ada di INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Instalasi ini dibatasi :count akun sistem. Hapus satu, atau minta paket yang lebih besar.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Instalasi ini dibatasi :count klien. Hapus satu, atau minta paket yang lebih besar."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Akun sistem pada instalasi ini dibatasi hingga :count. Hapus satu, atau minta paket yang lebih besar.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Klien pada instalasi ini dibatasi hingga :count. Hapus satu, atau minta paket yang lebih besar.",
":used of :limit staff seats used": ":used dari :limit akun sistem terpakai",
":used of :limit client accounts used": ":used dari :limit akun klien terpakai",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus.|Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Nessuno sta creando i download ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Un download ZIP è in attesa dal :date e nessun processo in background lo ha preso in carico. I download ZIP ora usano una coda propria, quindi un processo avviato prima di questa versione sta guardando la coda sbagliata — tutto il resto, email compresa, continua a funzionare.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Il comando del tuo processo in background ha bisogno di --queue=default,zips. In INSTALL.md c’è il file di servizio completo.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Questa installazione è limitata a :count account di sistema. Rimuovine uno o chiedi un piano più grande.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Questa installazione è limitata a :count clienti. Rimuovine uno o chiedi un piano più grande."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Gli account di sistema di questa installazione sono limitati a :count. Rimuovine uno o chiedi un piano più grande.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "I clienti di questa installazione sono limitati a :count. Rimuovine uno o chiedi un piano più grande.",
":used of :limit staff seats used": "Usati :used di :limit account di sistema",
":used of :limit client accounts used": "Usati :used di :limit account cliente",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare.|Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "ZIP ダウンロードを作成しているものがありません",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": ":date から ZIP ダウンロードが待機したままで、どのバックグラウンド処理も取りかかっていません。ZIP の作成は専用のキューで実行されるようになったため、このバージョンより前に起動した処理は別のキューを見ています — メールを含め、ほかはすべて動いています。",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "バックグラウンド処理のコマンドに --queue=default,zips が必要です。完全なサービスファイルは INSTALL.md にあります。",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "このインストールはシステムアカウント :count 件までです。1 件削除するか、上位のプランをご依頼ください。",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "このインストールは取引先 :count 件までです。1 件削除するか、上位のプランをご依頼ください。"
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "このインストールのシステムアカウントの上限は :count です。1 件削除するか、上位のプランをご依頼ください。",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "このインストールの取引先の上限は :count です。1 件削除するか、上位のプランをご依頼ください。",
":used of :limit staff seats used": "システムアカウント :limit 件中 :used 件を使用中",
":used of :limit client accounts used": "クライアントアカウント :limit 件中 :used 件を使用中",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。|このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。"
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Niemand bouwt de ZIP-downloads",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Een ZIP-download wacht al sinds :date en geen enkel achtergrondproces heeft hem opgepakt. ZIP-downloads gebruiken nu een eigen wachtrij, dus een proces dat vóór deze versie is gestart kijkt naar de verkeerde — al het andere, e-mail inbegrepen, werkt gewoon door.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Het commando van je achtergrondproces heeft --queue=default,zips nodig. In INSTALL.md staat het volledige servicebestand.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Deze installatie is beperkt tot :count systeemaccounts. Verwijder er een of vraag om een groter abonnement.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Deze installatie is beperkt tot :count klanten. Verwijder er een of vraag om een groter abonnement."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "De systeemaccounts van deze installatie zijn beperkt tot :count. Verwijder er een of vraag om een groter abonnement.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "De klanten van deze installatie zijn beperkt tot :count. Verwijder er een of vraag om een groter abonnement.",
":used of :limit staff seats used": ":used van :limit systeemaccounts in gebruik",
":used of :limit client accounts used": ":used van :limit klantaccounts in gebruik",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Deze map kan niet worden verwijderd: er staat :count bestand in dat je niet mag verwijderen.|Deze map kan niet worden verwijderd: er staan :count bestanden in die je niet mag verwijderen."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Nikt nie tworzy pobrań ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Pobranie ZIP czeka od :date i żaden proces w tle go nie podjął. Pobrania ZIP mają teraz własną kolejkę, więc proces uruchomiony przed tą wersją obserwuje niewłaściwą — wszystko inne, łącznie z pocztą, działa normalnie.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Polecenie twojego procesu w tle potrzebuje --queue=default,zips. Pełny plik usługi znajdziesz w INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Ta instalacja jest ograniczona do :count kont systemowych. Usuń jedno lub poproś o większy plan.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Ta instalacja jest ograniczona do :count klientów. Usuń jednego lub poproś o większy plan."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Liczba kont systemowych w tej instalacji jest ograniczona do :count. Usuń jedno lub poproś o większy plan.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Liczba klientów w tej instalacji jest ograniczona do :count. Usuń jednego lub poproś o większy plan.",
":used of :limit staff seats used": "Wykorzystano :used z :limit kont systemowych",
":used of :limit client accounts used": "Wykorzystano :used z :limit kont klientów",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tego folderu nie można usunąć: zawiera :count plik, którego nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count pliki, których nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count plików, których nie możesz usunąć."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Ninguém está montando os downloads em ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Um download em ZIP está esperando desde :date e nenhum processo em segundo plano pegou ele. Downloads em ZIP agora usam uma fila própria, então um processo iniciado antes desta versão está olhando para a fila errada — todo o resto, inclusive e-mail, continua funcionando.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "O comando do seu processo em segundo plano precisa de --queue=default,zips. O INSTALL.md tem o arquivo de serviço completo.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Esta instalação está limitada a :count contas de sistema. Remova uma ou peça um plano maior.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Esta instalação está limitada a :count clientes. Remova um ou peça um plano maior."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "As contas de sistema desta instalação estão limitadas a :count. Remova uma ou peça um plano maior.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Os clientes desta instalação estão limitados a :count. Remova um ou peça um plano maior.",
":used of :limit staff seats used": "Usadas :used de :limit contas de sistema",
":used of :limit client accounts used": "Usadas :used de :limit contas de cliente",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta pasta não pode ser excluída: ela contém :count arquivo que você não pode excluir.|Esta pasta não pode ser excluída: ela contém :count arquivos que você não pode excluir."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Никто не собирает ZIP-архивы",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "ZIP-архив ждёт с :date, и ни один фоновый обработчик его не взял. Сборка ZIP теперь идёт в отдельной очереди, поэтому обработчик, запущенный до этой версии, следит не за той — всё остальное, включая почту, работает как прежде.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "В команде вашего фонового обработчика нужен --queue=default,zips. Полный файл службы есть в INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Эта установка ограничена :count системными учётными записями. Удалите одну или запросите более крупный тариф.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Эта установка ограничена :count клиентами. Удалите одного или запросите более крупный тариф."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Количество системных учётных записей в этой установке ограничено до :count. Удалите одну или запросите более крупный тариф.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Количество клиентов в этой установке ограничено до :count. Удалите одного или запросите более крупный тариф.",
":used of :limit staff seats used": "Использовано :used из :limit системных учётных записей",
":used of :limit client accounts used": "Использовано :used из :limit клиентских учётных записей",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Эту папку нельзя удалить: в ней :count файл, который вам нельзя удалять.|Эту папку нельзя удалить: в ней :count файла, которые вам нельзя удалять.|Эту папку нельзя удалить: в ней :count файлов, которые вам нельзя удалять."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Hakuna kinachotengeneza upakuaji wa ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Upakuaji wa ZIP umesubiri tangu :date na hakuna mchakato wa nyuma uliouchukua. Utengenezaji wa ZIP sasa unaendeshwa kwenye foleni yake yenyewe, hivyo mchakato ulioanzishwa kabla ya toleo hili unaangalia foleni isiyo sahihi — kila kitu kingine, pamoja na barua pepe, kinaendelea kufanya kazi.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Amri ya mchakato wako wa nyuma inahitaji --queue=default,zips. Faili kamili ya huduma ipo katika INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Usakinishaji huu umewekewa kikomo cha akaunti za mfumo :count. Ondoa moja, au omba mpango mkubwa zaidi.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Usakinishaji huu umewekewa kikomo cha wateja :count. Ondoa mmoja, au omba mpango mkubwa zaidi."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Akaunti za mfumo katika usakinishaji huu zimewekewa kikomo cha :count. Ondoa moja, au omba mpango mkubwa zaidi.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Wateja katika usakinishaji huu wamewekewa kikomo cha :count. Ondoa mmoja, au omba mpango mkubwa zaidi.",
":used of :limit staff seats used": "Zimetumika :used kati ya :limit akaunti za mfumo",
":used of :limit client accounts used": "Zimetumika :used kati ya :limit akaunti za wateja",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folda hii haiwezi kufutwa: ina faili :count ambalo huruhusiwi kulifuta.|Folda hii haiwezi kufutwa: ina faili :count ambazo huruhusiwi kuzifuta."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "ZIP indirmelerini kimse oluşturmuyor",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Bir ZIP indirmesi :date tarihinden beri bekliyor ve hiçbir arka plan işleyicisi onu almadı. ZIP indirmeleri artık kendi kuyruğunda çalışıyor, bu yüzden bu sürümden önce başlatılan bir işleyici yanlış kuyruğu izliyor — e-posta dahil diğer her şey çalışmaya devam ediyor.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Arka plan işleyicinin komutunda --queue=default,zips olmalı. Tam servis dosyası INSTALL.md içinde.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Bu kurulum :count sistem hesabıyla sınırlı. Birini kaldır ya da daha büyük bir plan iste.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Bu kurulum :count müşteriyle sınırlı. Birini kaldır ya da daha büyük bir plan iste."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Bu kurulumdaki sistem hesabı sayısı en fazla :count olabilir. Birini kaldır ya da daha büyük bir plan iste.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Bu kurulumdaki müşteri sayısı en fazla :count olabilir. Birini kaldır ya da daha büyük bir plan iste.",
":used of :limit staff seats used": ":limit sistem hesabından :used tanesi kullanımda",
":used of :limit client accounts used": ":limit müşteri hesabından :used tanesi kullanımda",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Bu klasör silinemez: silme yetkin olmayan :count dosya içeriyor.|Bu klasör silinemez: silme yetkin olmayan :count dosya içeriyor."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "Không có tiến trình nào đang tạo bản tải ZIP",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Một bản tải ZIP đã chờ từ :date mà không tiến trình nền nào nhận. Việc tạo ZIP giờ chạy trên hàng đợi riêng, nên tiến trình khởi động trước phiên bản này đang theo dõi nhầm hàng đợi — mọi thứ khác, kể cả email, vẫn chạy bình thường.",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Lệnh chạy tiến trình nền của bạn cần --queue=default,zips. Tệp dịch vụ đầy đủ có trong INSTALL.md.",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Bản cài đặt này giới hạn :count tài khoản hệ thống. Hãy xoá bớt một tài khoản, hoặc yêu cầu gói lớn hơn.",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Bản cài đặt này giới hạn :count khách hàng. Hãy xoá bớt một khách hàng, hoặc yêu cầu gói lớn hơn."
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Số tài khoản hệ thống của bản cài đặt này giới hạn ở :count. Hãy xoá bớt một tài khoản, hoặc yêu cầu gói lớn hơn.",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Số khách hàng của bản cài đặt này giới hạn ở :count. Hãy xoá bớt một khách hàng, hoặc yêu cầu gói lớn hơn.",
":used of :limit staff seats used": "Đã dùng :used trong :limit tài khoản hệ thống",
":used of :limit client accounts used": "Đã dùng :used trong :limit tài khoản khách hàng",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Không thể xoá thư mục này: nó chứa :count tệp mà bạn không được phép xoá.|Không thể xoá thư mục này: nó chứa :count tệp mà bạn không được phép xoá."
}
+5 -2
View File
@@ -1901,6 +1901,9 @@
"Nothing is building zip downloads": "没有任何进程在打包 ZIP 下载",
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "有一个 ZIP 下载从 :date 起一直在等待,没有任何后台进程接手。ZIP 打包现在使用单独的队列,因此在此版本之前启动的进程盯着的是另一条队列 —— 其他一切,包括邮件,都照常工作。",
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "你的后台进程命令需要加上 --queue=default,zips。完整的服务文件见 INSTALL.md。",
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "本安装最多允许 :count 个系统账户。请删除一个,或申请更大的套餐。",
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "本安装最多允许 :count 个客户。请删除一个,或申请更大的套餐。"
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "本安装的系统账户上限为 :count。请删除一个,或申请更大的套餐。",
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "本安装的客户上限为 :count。请删除一个,或申请更大的套餐。",
":used of :limit staff seats used": "系统账户已用 :used / :limit",
":used of :limit client accounts used": "客户账户已用 :used / :limit",
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "无法删除此文件夹:其中包含 :count 个你无权删除的文件。|无法删除此文件夹:其中包含 :count 个你无权删除的文件。"
}
+61
View File
@@ -0,0 +1,61 @@
import { Link } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
/**
* What SeatAllowance::staffState() / clientState() send. Null on a
* self-hosted install, where there is no limit and nothing to say.
*/
export interface SeatState {
limit: number;
used: number;
full: boolean;
/** Set only when full, and worded by the server so the screen and the
* refusal it prevents describe the limit the same way. */
message: string | null;
}
/**
* The "New user" / "New client" button, told how many seats are left.
*
* A managed installation is sold a number of accounts, so filling it is an
* ordinary state rather than an error. Offering a live button for a form
* that cannot be submitted is what made it feel like a fault: you invent a
* password, submit, and the plan limit arrives as a validation error under
* the email field. So the button goes dead at the limit and the reason is
* on screen next to it, before anything is typed.
*/
export function SeatLimitedAction({
seats,
href,
label,
usage,
}: {
seats: SeatState | null;
href: string;
label: string;
/** The count line, worded by the caller: staff seats and client seats
* are different things to a reader. */
usage: (seats: SeatState) => string;
}) {
const action = seats?.full ? (
<Button disabled title={seats.message ?? undefined}>
{label}
</Button>
) : (
<Button asChild>
<Link href={href}>{label}</Link>
</Button>
);
if (!seats) {
return action;
}
return (
<div className="flex flex-col items-end gap-1.5">
{action}
<p className="text-muted-foreground max-w-xs text-right text-xs">{seats.full ? seats.message : usage(seats)}</p>
</div>
);
}
+10 -5
View File
@@ -6,6 +6,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import Heading from '@/components/heading';
import { FilterField, ListToolbar } from '@/components/list-toolbar';
import { Pagination, PaginationMeta } from '@/components/pagination';
import { SeatLimitedAction, type SeatState } from '@/components/seat-limit';
import { TableShell } from '@/components/table-shell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -35,9 +36,10 @@ interface ClientsIndexProps {
pagination: PaginationMeta;
filters: Filters;
reassign_candidates: ReassignCandidate[];
seats: SeatState | null;
}
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates }: ClientsIndexProps) {
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates, seats }: ClientsIndexProps) {
const { t } = useTranslation();
const { auth } = usePage<SharedData>().props;
@@ -66,16 +68,19 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca
<div className="px-4 py-6">
<div className="flex items-start justify-between">
<Heading title={t('Clients')} description={t('The people you share files with')} />
<div className="flex gap-2">
<div className="flex items-start gap-2">
{can('manage_custom_fields') && (
<Button variant="outline" asChild>
<Link href={route('client-custom-fields.index')}>{t('Manage custom fields')}</Link>
</Button>
)}
{can('create_clients') && (
<Button asChild>
<Link href={route('clients.create')}>{t('New client')}</Link>
</Button>
<SeatLimitedAction
seats={seats}
href={route('clients.create')}
label={t('New client')}
usage={({ used, limit }) => t(':used of :limit client accounts used', { used, limit })}
/>
)}
</div>
</div>
+9 -4
View File
@@ -7,6 +7,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import Heading from '@/components/heading';
import { FilterField, ListToolbar } from '@/components/list-toolbar';
import { Pagination, PaginationMeta } from '@/components/pagination';
import { SeatLimitedAction, type SeatState } from '@/components/seat-limit';
import { TableShell } from '@/components/table-shell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -43,9 +44,10 @@ interface UsersIndexProps {
filters: Filters;
roles: { id: number; name: string }[];
reassign_candidates: ReassignCandidate[];
seats: SeatState | null;
}
export default function UsersIndex({ users, pagination, filters, roles, reassign_candidates }: UsersIndexProps) {
export default function UsersIndex({ users, pagination, filters, roles, reassign_candidates, seats }: UsersIndexProps) {
const { t } = useTranslation();
const { auth } = usePage<SharedData>().props;
@@ -66,9 +68,12 @@ export default function UsersIndex({ users, pagination, filters, roles, reassign
<div className="flex items-start justify-between">
<Heading title={t('System users')} description={t('The people who administer this installation and upload files')} />
{can('create_users') && (
<Button asChild>
<Link href={route('users.create')}>{t('New user')}</Link>
</Button>
<SeatLimitedAction
seats={seats}
href={route('users.create')}
label={t('New user')}
usage={({ used, limit }) => t(':used of :limit staff seats used', { used, limit })}
/>
)}
</div>
+8
View File
@@ -0,0 +1,8 @@
{{-- The action URL, spelled out for somebody who cannot click the button.
Paired with text/action-url.blade.php. Laravel resolves `mail::`
components against html/ or text/ depending on which half of the
message it is building, which is the whole reason this is a component
rather than a line in the view: the two halves need different things
from the same URL, and only one of them understands markdown. --}}
<span class="break-all">[{{ $url }}]({{ $url }})</span>
+9
View File
@@ -0,0 +1,9 @@
{{-- The plain-text half of html/action-url.blade.php.
Just the URL. Laravel's own view writes `[$url]($url)` here, which is
correct for the HTML half and wrong for this one: nothing parses
markdown in a text/plain body, so it arrives as literal brackets with
the address duplicated inside them — the shape a phishing template
has. Seen in a real password reset, which is often the first mail an
installation ever sends somebody. --}}
{{ $url }}
+72
View File
@@ -0,0 +1,72 @@
{{-- Laravel's notification email view, published so the subcopy can spell
the action URL out differently in each half of the message.
Upstream writes `[$url]($url)` there. That is right for the HTML half
and wrong for the text one, where nothing parses markdown: it arrives
as literal brackets around a duplicated address, which is what a
badly-built phishing mail looks like — on a password reset, often the
first mail an installation ever sends anybody. The x-mail::action-url
component resolves to a different file per half, which is how every
other component in this message already handles the same problem.
This is a copy of a framework view, so it does not follow Laravel
forward on its own. If an upgrade changes the notification layout,
re-copy it and re-apply the one-line change below. --}}
<x-mail::message>
{{-- Greeting --}}
@if (! empty($greeting))
# {{ $greeting }}
@else
@if ($level === 'error')
# @lang('Whoops!')
@else
# @lang('Hello!')
@endif
@endif
{{-- Intro Lines --}}
@foreach ($introLines as $line)
{{ $line }}
@endforeach
{{-- Action Button --}}
@isset($actionText)
<?php
$color = match ($level) {
'success', 'error' => $level,
default => 'primary',
};
?>
<x-mail::button :url="$actionUrl" :color="$color">
{{ $actionText }}
</x-mail::button>
@endisset
{{-- Outro Lines --}}
@foreach ($outroLines as $line)
{{ $line }}
@endforeach
{{-- Salutation --}}
@if (! empty($salutation))
{{ $salutation }}
@else
@lang('Regards,')<br>
{{ config('app.name') }}
@endif
{{-- Subcopy --}}
@isset($actionText)
<x-slot:subcopy>
@lang(
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n".
'into your web browser:',
[
'actionText' => $actionText,
]
) <x-mail::action-url :url="$actionUrl" />
</x-slot:subcopy>
@endisset
</x-mail::message>
+6 -1
View File
@@ -95,7 +95,12 @@ Route::middleware('auth')->group(function () {
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
// Named so EnforceTwoFactor can exempt it. Its exemption list matches
// on route names, and an unnamed route matches nothing -- which left
// the form reachable and its submission not, closing the enrolment
// path enforcement depends on.
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
->name('password.confirm.store');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
+5 -3
View File
@@ -295,9 +295,11 @@ Route::middleware(['auth'])->group(function () {
Route::delete('account-requests/{client}', [AccountRequestsController::class, 'deny'])->name('account-requests.deny');
});
// Staff user & role management is community-only (managed installations
// handle it outside the application) — both layers gate it:
// capability + permission.
// Staff user & role management: both editions since 2.2.0, and still
// gated by both layers — capability + permission. The capability is
// the seam an edition difference would travel through, and cloud
// holds it (see Capability::UsersManage); the seat cap, not a closed
// screen, is what a managed plan limits.
Route::middleware(['staff', 'capability:users.manage', 'can:manage_users'])->group(function () {
Route::get('users', [UsersController::class, 'index'])->name('users.index');
Route::get('users/create', [UsersController::class, 'create'])->middleware('can:create_users')->name('users.create');
+29
View File
@@ -351,6 +351,35 @@ test('a caller cannot deactivate or delete their own account', function () {
->assertJsonPath('errors.user.0', 'You cannot delete your own account.');
});
test('self-deactivation is refused however the boolean is written', function (mixed $active) {
// A second administrator, so the last-administrator guard is not what
// refuses this: with only one, that guard answers first and the refusal
// under test here is never reached.
User::factory()->create();
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['active' => $active])
->assertStatus(422)
->assertJsonPath('errors.active.0', 'You cannot deactivate your own account.');
expect($this->admin->refresh()->active)->toBeTrue();
})->with([
'false' => [false],
'zero' => [0],
'the string zero' => ['0'],
]);
test('deactivating somebody else still works in every one of those forms', function (mixed $active) {
$user = User::factory()->role(SystemRole::Uploader)->create();
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => $active])->assertOk();
expect($user->refresh()->active)->toBeFalse();
})->with([
'false' => [false],
'zero' => [0],
'the string zero' => ['0'],
]);
/*
|--------------------------------------------------------------------------
| Deletion and its content
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentVisibility;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Storage;
/**
* `file_comments.client_context_id` and `author_id` are both
* cascadeOnDelete, and neither cascade ever fires: users are
* soft-deleted, so the row survives and the column goes on pointing at
* it. Everything that asked the *relation* instead of the column read
* that as "there is no client here" — and for client_context_id, "no
* client" is the branch every client on the file reads.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->file = File::factory()->create(['name' => 'Quote']);
$this->alice = User::factory()->client()->create(['name' => 'Alice Ltd']);
$this->bob = User::factory()->client()->create(['name' => 'Bob GmbH']);
shareFileWith($this->file, $this->alice);
shareFileWith($this->file, $this->bob);
$this->fromAlice = FileComment::factory()->for($this->file)->inThreadOf($this->alice)->create([
'author_id' => $this->alice->id,
'body' => 'What is your best price?',
]);
});
function readableBy(User $viewer, File $file): array
{
return app(VisibleCommentScope::class)->for($viewer, $file)->pluck('id')->map(intval(...))->all();
}
test('a reply into a deleted client\'s thread is refused, not broadcast', function () {
$this->alice->delete();
$this->actingAs($this->admin)
->postJson("/files/{$this->file->id}/comments", [
'body' => 'For you only: 40% off.',
'visibility' => CommentVisibility::Clients->value,
'reply_to' => $this->fromAlice->id,
])
->assertForbidden();
expect(FileComment::query()->where('body', 'For you only: 40% off.')->exists())->toBeFalse()
->and(readableBy($this->bob, $this->file))->toBe([]);
});
test('a staff message to everybody still reaches everybody', function () {
// The null context is a real branch, not only a failure mode: staff
// writing fresh address every client on the file, and that must keep
// working.
$this->actingAs($this->admin)
->postJson("/files/{$this->file->id}/comments", [
'body' => 'The catalogue is attached.',
'visibility' => CommentVisibility::Clients->value,
])
->assertCreated();
$broadcast = FileComment::query()->where('body', 'The catalogue is attached.')->sole();
expect($broadcast->client_context_id)->toBeNull()
->and(readableBy($this->bob, $this->file))->toContain($broadcast->id);
});
test('a reply into a live client\'s thread still lands in that thread alone', function () {
$this->actingAs($this->admin)
->postJson("/files/{$this->file->id}/comments", [
'body' => 'For you only: 40% off.',
'visibility' => CommentVisibility::Clients->value,
'reply_to' => $this->fromAlice->id,
])
->assertCreated();
$reply = FileComment::query()->where('body', 'For you only: 40% off.')->sole();
expect($reply->client_context_id)->toBe($this->alice->id)
->and(readableBy($this->alice, $this->file))->toContain($reply->id)
->and(readableBy($this->bob, $this->file))->not->toContain($reply->id);
});
test('a deleted client\'s comment keeps their name instead of reading as a guest', function () {
$this->alice->delete();
expect($this->fromAlice->fresh()->authorName())->toBe('Alice Ltd');
});
test('a genuine guest comment is still anonymous', function () {
$guest = FileComment::factory()->for($this->file)->fromGuest()->create();
expect($guest->authorName())->not->toBe('Alice Ltd')
->and($guest->author_id)->toBeNull();
});
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Deleting a folder cascades to every file in its subtree, and a File's
* `deleted` hook removes the bytes from disk. So the folder route must
* not destroy what the file route refuses to hand over.
*
* MyFoldersController::destroy already draws this line for clients. These
* are the staff cases.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
});
function folderDeleteRole(array $permissions, bool $clientScoped = false): User
{
$role = Role::query()->create(['name' => 'Role '.Str::random(6), 'client_scoped' => $clientScoped]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
}
return User::factory()->create(['role_id' => $role->id]);
}
function folderDeleteUpload(User $as, string $name, ?int $folderId = null): File
{
test()->actingAs($as)->post('/files', [
'file' => UploadedFile::fake()->create($name.'.pdf', 4, 'application/pdf'),
'name' => '',
'description' => '',
'folder_id' => $folderId,
]);
return File::query()->latest('id')->firstOrFail();
}
test('a folder is not a way around delete_others_files', function () {
$staff = folderDeleteRole([
Permission::CreateOwnFolders, Permission::DeleteFiles,
Permission::Upload, Permission::EditFiles,
]);
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
$foreign = folderDeleteUpload($this->admin, 'someone-elses', $folder->id);
// The file route already refuses this one.
$this->actingAs($staff)->delete("/files/{$foreign->id}")->assertForbidden();
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
expect(File::query()->whereKey($foreign->id)->exists())->toBeTrue()
->and(Folder::query()->whereKey($folder->id)->exists())->toBeTrue();
});
test('the refusal says how many files are in the way', function () {
$staff = folderDeleteRole([
Permission::CreateOwnFolders, Permission::DeleteFiles,
Permission::Upload, Permission::EditFiles,
]);
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
folderDeleteUpload($this->admin, 'one', $folder->id);
folderDeleteUpload($this->admin, 'two', $folder->id);
$this->actingAs($staff)->delete("/folders/{$folder->id}")
->assertSessionHas('error', fn (string $message): bool => str_contains($message, '2 files'));
});
test('a nested file is reached too', function () {
$staff = folderDeleteRole([
Permission::CreateOwnFolders, Permission::DeleteFiles,
Permission::Upload, Permission::EditFiles,
]);
$parent = Folder::query()->create(['name' => 'Parent', 'created_by' => $staff->id]);
$child = Folder::query()->create([
'name' => 'Child', 'parent_id' => $parent->id,
'path' => "/{$parent->id}/", 'created_by' => $staff->id,
]);
$foreign = folderDeleteUpload($this->admin, 'deep', $child->id);
$this->actingAs($staff)->delete("/folders/{$parent->id}");
expect(File::query()->whereKey($foreign->id)->exists())->toBeTrue();
});
test('the library boundary is asked as well, not only the permission', function () {
$staff = folderDeleteRole([
Permission::CreateOwnFolders, Permission::DeleteFiles,
Permission::DeleteOthersFiles, Permission::Upload, Permission::EditFiles,
], clientScoped: true);
$folder = Folder::query()->create(['name' => 'Scoped', 'created_by' => $staff->id]);
$outside = folderDeleteUpload($this->admin, 'outside-the-library', $folder->id);
// Both delete permissions are held, so the permission half of
// FilePolicy::delete passes and only StaffLibraryScope can refuse --
// which is the half a per-permission check would have missed.
$this->actingAs($staff)->delete("/files/{$outside->id}")->assertForbidden();
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
expect(File::query()->whereKey($outside->id)->exists())->toBeTrue()
->and(Folder::query()->whereKey($folder->id)->exists())->toBeTrue();
});
test('a folder holding only the deleter own files still goes', function () {
$staff = folderDeleteRole([
Permission::CreateOwnFolders, Permission::DeleteFiles,
Permission::Upload, Permission::EditFiles,
]);
$folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $staff->id]);
$own = folderDeleteUpload($staff, 'my-own', $folder->id);
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
expect(File::query()->whereKey($own->id)->exists())->toBeFalse()
->and(Folder::query()->whereKey($folder->id)->exists())->toBeFalse();
});
test('an administrator holding both permissions is unaffected', function () {
$folder = Folder::query()->create(['name' => 'Anything', 'created_by' => $this->admin->id]);
$other = User::factory()->create();
$theirs = folderDeleteUpload($other, 'theirs', $folder->id);
$this->actingAs($this->admin)->delete("/folders/{$folder->id}")->assertRedirect();
expect(File::query()->whereKey($theirs->id)->exists())->toBeFalse();
});
+85
View File
@@ -596,3 +596,88 @@ test('a zip build is queued away from ordinary work', function () {
Queue::assertPushed(BuildZipDownloadJob::class, fn (BuildZipDownloadJob $job): bool => $job->queue === 'zips');
});
/*
|--------------------------------------------------------------------------
| A selection that reaches the same file more than once
|--------------------------------------------------------------------------
*/
test('a file reached by both a loose pick and a folder is added once', function () {
$folder = Folder::query()->create(['name' => 'Reports']);
$file = zipUploadFile($this->admin, 'report.pdf', $folder->id);
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
'file_ids' => [$file->id],
'folder_ids' => [$folder->id],
])->assertOk();
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
// The loose pick reaches it first, so that is where the one copy sits.
expect(zipEntryNames($zipDownload))->toBe(['report.pdf'])
->and($zipDownload->file_count)->toBe(1)
->and($zipDownload->total_size)->toBe($file->size)
->and($zipDownload->contained_file_ids)->toBe([$file->id]);
});
test('a folder inside another selected folder does not duplicate its contents', function () {
$parent = Folder::query()->create(['name' => 'Reports']);
$child = Folder::query()->create(['name' => 'Q1', 'parent_id' => $parent->id, 'path' => "/{$parent->id}/"]);
$file = zipUploadFile($this->admin, 'report.pdf', $child->id);
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
'folder_ids' => [$parent->id, $child->id],
])->assertOk();
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
// The outer folder wins, so the entry keeps the fuller path.
expect(zipEntryNames($zipDownload))->toBe(['Reports/Q1/report.pdf'])
->and($zipDownload->file_count)->toBe(1)
->and($zipDownload->total_size)->toBe($file->size);
});
test('the same file selected three ways is handed over once and charged once', function () {
$parent = Folder::query()->create(['name' => 'Reports']);
$child = Folder::query()->create(['name' => 'Q1', 'parent_id' => $parent->id, 'path' => "/{$parent->id}/"]);
$file = zipUploadFile($this->admin, 'report.pdf', $child->id);
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
'file_ids' => [$file->id],
'folder_ids' => [$parent->id, $child->id],
])->assertOk();
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
$this->actingAs($this->admin)->get("/zip-downloads/{$zipDownload->id}/download")->assertOk();
// Delivery logs one FileDownloaded per contained file, so as many
// copies as the archive holds must be as many as the log records —
// otherwise a file limited to one download leaves in several.
$logged = ActivityLog::query()
->where('action', Action::FileDownloaded)
->where('subject_id', $file->id)
->count();
expect(count(zipEntryNames($zipDownload)))->toBe(1)
->and($logged)->toBe(1);
});
test('two selected folders that merely share a name are both zipped', function () {
// The pruning above is about containment, not about names: neither of
// these is inside the other, so both belong in the archive, and the
// usual collision suffix keeps them apart.
$first = Folder::query()->create(['name' => 'Reports']);
$second = Folder::query()->create(['name' => 'Reports']);
zipUploadFile($this->admin, 'a.pdf', $first->id);
zipUploadFile($this->admin, 'b.pdf', $second->id);
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
'folder_ids' => [$first->id, $second->id],
])->assertOk();
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
expect($zipDownload->file_count)->toBe(2)
->and(zipEntryNames($zipDownload))->toContain('Reports/a.pdf');
});
@@ -420,3 +420,60 @@ test('an unscoped administrator manages every group exactly as before', function
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| The screen that edits a group, and its API twin
|--------------------------------------------------------------------------
*/
test('a group reaching past the library cannot even be opened', function () {
// update() and destroy() already refuse this group. Reading it was the
// one group route that did not.
$this->actingAs($this->rep)->get("/groups/{$this->strangerGroup->id}")->assertNotFound();
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
$this->withToken($token)->getJson("/api/v1/groups/{$this->strangerGroup->id}")->assertNotFound();
});
test('the edit screen stops naming clients this viewer has no business hearing about', function () {
// Nothing is shared with this group, so it reaches nowhere and stays
// open to the rep — which is exactly the case a reach guard alone
// would leave holding a stranger's address.
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
$ours->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
$props = $this->actingAs($this->rep)->get("/groups/{$ours->id}")->assertOk()->viewData('page')['props'];
expect(array_column($props['members'], 'name'))->toBe(['Mine'])
->and(array_column($props['members'], 'email'))->toBe([$this->mine->email])
// Every client the rep may reach is already in the group, so there
// is nobody left to add — rather than the stranger, whom they could
// not have added anyway.
->and($props['available_clients'])->toBe([]);
});
test('the API twin narrows the membership it hands back', function () {
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
$ours->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
$data = $this->withToken($token)->getJson("/api/v1/groups/{$ours->id}")->assertOk()->json('data');
expect(array_column($data['members'], 'name'))->toBe(['Mine'])
// members_count is left whole on purpose: a size is not an
// identity, and it is the same number the group listing reports.
->and($data['members_count'])->toBe(2);
});
test('an unscoped viewer keeps the whole roster and every member', function () {
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
$ours->members()->syncWithoutDetaching([$this->mine->id]);
$props = $this->actingAs($this->admin)->get("/groups/{$ours->id}")->assertOk()->viewData('page')['props'];
expect(array_column($props['members'], 'name'))->toBe(['Mine'])
->and(array_column($props['available_clients'], 'name'))->toBe(['Not Mine']);
$this->actingAs($this->admin)->get("/groups/{$this->strangerGroup->id}")->assertOk();
});
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
/**
* Deleting your own account goes through ProfileController, which asked
* for the current password and nothing else. Every other door that can
* remove an administrator asks StaffAccounts::guardLastAdministrator
* first; this one did not, and it is the one door where the account being
* removed is certainly signed in.
*
* The account is soft-deleted, so the row survives — but every "is this
* installation set up" check asked `exists()`, which does not see trashed
* rows. Zero live staff sends every request to the first-run setup form,
* and that form is registered without `guest`, without auth and without a
* throttle.
*/
function liveStaffCount(): int
{
return User::query()->where('type', UserType::Staff)->count();
}
function onlyStaffAccount(): User
{
User::query()->where('type', UserType::Staff)->forceDelete();
return User::factory()->create();
}
test('the last administrator cannot delete their own account', function () {
$admin = onlyStaffAccount();
$this->actingAs($admin)
->from('/settings/delete-account')
->delete('/settings/profile', ['password' => 'password'])
->assertSessionHasErrors('role_id');
expect(liveStaffCount())->toBe(1)
->and($admin->fresh())->not->toBeNull();
});
test('an administrator with a colleague still may', function () {
$admin = onlyStaffAccount();
$second = User::factory()->create();
$this->actingAs($admin)
->delete('/settings/profile', ['password' => 'password'])
->assertRedirect('/');
expect(liveStaffCount())->toBe(1)
->and(User::query()->whereKey($second->id)->exists())->toBeTrue()
->and(User::query()->whereKey($admin->id)->exists())->toBeFalse();
});
test('a staff member who is not an administrator still may', function () {
onlyStaffAccount();
$uploader = User::factory()->role(SystemRole::Uploader)->create();
$this->actingAs($uploader)
->delete('/settings/profile', ['password' => 'password'])
->assertRedirect('/');
expect(User::query()->whereKey($uploader->id)->exists())->toBeFalse();
});
test('a client can still close their own account', function () {
onlyStaffAccount();
$client = User::factory()->client()->create();
$this->actingAs($client)
->delete('/settings/profile', ['password' => 'password'])
->assertRedirect('/');
expect(User::query()->whereKey($client->id)->exists())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| The second lock: a trashed staff row still means "already set up"
|--------------------------------------------------------------------------
*/
test('setup stays shut once a staff account has existed, even trashed', function () {
$admin = onlyStaffAccount();
// Reached past the guard on purpose — the point of this half is that
// the window stays closed even if some future door forgets to ask.
$admin->delete();
expect(liveStaffCount())->toBe(0)
->and(User::query()->withTrashed()->where('type', UserType::Staff)->count())->toBe(1);
$this->get('/')->assertRedirect('/login');
$this->post('/setup', [
'site_name' => 'Taken Over',
'name' => 'Stranger',
'email' => 'stranger@example.com',
'password' => 'Str0ng-Passw0rd!x',
'password_confirmation' => 'Str0ng-Passw0rd!x',
])->assertRedirect(route('home'));
expect(User::query()->where('email', 'stranger@example.com')->exists())->toBeFalse();
});
test('a genuinely fresh installation still reaches setup', function () {
User::query()->withTrashed()->forceDelete();
expect(User::query()->withTrashed()->count())->toBe(0);
$this->get('/')->assertRedirect(route('setup'));
$this->post('/setup', [
'site_name' => 'Fresh',
'name' => 'First Administrator',
'email' => 'first@example.com',
'password' => 'Str0ng-Passw0rd!x',
'password_confirmation' => 'Str0ng-Passw0rd!x',
])->assertRedirect(route('setup.success'));
$created = User::query()->where('email', 'first@example.com')->sole();
$administrator = Role::query()->where('name', SystemRole::SystemAdministrator->value)->sole();
expect($created->type)->toBe(UserType::Staff)
->and($created->role_id)->toBe($administrator->id);
});
@@ -55,7 +55,12 @@ test('the 2fa setup screen itself and logout stay reachable under enforcement',
$this->actingAs(User::factory()->create());
$this->get('/settings/two-factor')->assertOk();
$this->post('/settings/two-factor')->assertRedirect();
// Named rather than bare: enabling is behind password.confirm, so the
// redirect it answers with is the confirm-password screen. A bare
// assertRedirect() passes on any target, including this middleware
// bouncing the request back to two-factor.show, which is the shape
// this file exists to refuse.
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
$this->post('/logout')->assertRedirect('/');
});
@@ -102,3 +107,46 @@ test('clients cannot access security settings', function () {
$this->get('/system/settings/security')->assertRedirect(route('dashboard'));
$this->patch('/system/settings/security', ['two_factor_enforcement' => 'all'])->assertForbidden();
});
/**
* The exemption list matches on route names, and only the GET half of the
* confirm-password screen had one. So the form rendered and its submission
* did not: enforcement sent the POST back to two-factor.show, the password
* was never confirmed, and enrolment -- the one exit enforcement leaves
* open -- could not be started by anybody.
*/
test('an enforced user can confirm their password, which is what enrolling needs', function () {
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
$this->actingAs(User::factory()->create());
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
$this->get('/confirm-password')->assertOk();
$this->post('/confirm-password', ['password' => 'password'])
->assertSessionHasNoErrors();
expect(session()->has('auth.password_confirmed_at'))->toBeTrue();
});
test('enrolment can actually be started under enforcement', function () {
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
$user = User::factory()->create();
$this->actingAs($user);
$this->post('/confirm-password', ['password' => 'password']);
// store() answers back(), so the target is the referer rather than a
// fixed route -- what matters is that it ran at all instead of being
// bounced to the confirm-password screen it can no longer get past.
$this->post('/settings/two-factor')->assertSessionHasNoErrors();
// The secret is what enabling writes; without it the enrolment screen
// has no QR code to show and there is nothing to confirm against.
expect($user->refresh()->two_factor_secret)->not->toBeNull();
$this->get('/settings/two-factor')->assertOk()->assertInertia(
fn (AssertableInertia $page) => $page->where('pending', true)
);
});
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Notifications\ResetPasswordNotification;
use Illuminate\Mail\Markdown;
/**
* Every notification carrying an action button repeats its URL in the
* subcopy, for somebody whose mail client will not let them click it.
*
* Laravel's own view writes that as `[$url]($url)`, which is right for
* the HTML half and wrong for the text one: nothing parses markdown in a
* text/plain body, so it arrives as literal brackets around a duplicated
* address — the shape a badly-built phishing mail has, on what is often
* the first message an installation ever sends anybody. Seen in the wild
* on a real password reset before it was fixed.
*/
beforeEach(function () {
User::factory()->create();
});
/** The two halves Laravel builds for one markdown notification. */
function renderResetMail(): array
{
$user = User::factory()->create();
$mail = (new ResetPasswordNotification(str_repeat('a', 64)))->toMail($user);
$mail->viewData['actionText'] = $mail->actionText;
$markdown = app(Markdown::class);
$view = $mail->markdown ?: 'notifications::email';
$data = array_merge($mail->toArray(), $mail->viewData);
return [
'text' => (string) $markdown->renderText($view, $data),
'html' => (string) $markdown->render($view, $data),
'url' => $mail->actionUrl,
];
}
it('spells the action URL out plainly in the text half', function () {
['text' => $text, 'url' => $url] = renderResetMail();
expect($text)->toContain($url)
->and($text)->not->toContain('](')
->and($text)->not->toContain('['.$url);
});
it('still links the action URL in the html half', function () {
['html' => $html, 'url' => $url] = renderResetMail();
// Twice: the button itself, and the subcopy that repeats it.
expect(substr_count($html, 'href="'.e($url).'"'))->toBe(2)
// The markdown must have been parsed, not passed through.
->and($html)->not->toContain('['.e($url).']');
});
it('does not repeat the URL more than the two places that need it', function () {
['text' => $text, 'url' => $url] = renderResetMail();
// Once after the button label, once in the subcopy. A third meant the
// markdown link had been left in place.
expect(substr_count($text, $url))->toBe(2);
});
@@ -111,7 +111,11 @@ test('installing wins over updating', function () {
});
test('completing setup raises the greeting', function () {
User::query()->delete();
// forceDelete, not delete: "a fresh installation" means no staff row
// at all. A soft-deleted one is evidence that setup already happened,
// and EnsureSetupIsComplete counts it as such so that losing the last
// administrator cannot reopen the first-run form to a stranger.
User::query()->forceDelete();
$this->post('/setup', [
'site_name' => 'Acme Files',
@@ -127,7 +131,7 @@ test('completing setup raises the greeting', function () {
// Unattended provisioning skips the setup screen entirely, and is how
// every container that came up from environment variables was installed.
test('provisioning from the command line raises it too', function () {
User::query()->delete();
User::query()->forceDelete();
$this->artisan('projectsend:admin', [
'--name' => 'Ada',
@@ -8,6 +8,9 @@ use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Validation\ValidationException;
use Inertia\Testing\AssertableInertia;
use Laravel\Sanctum\Sanctum;
/**
* A cap is only a cap if every door asks.
@@ -190,6 +193,53 @@ test('door: approving an account request', function () {
expect($pending->refresh()->account_requested)->toBeTrue();
});
test('door: approving a pending client through the edit screen', function () {
seatLimits(clients: 0);
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
$this->actingAs($this->admin)->patch("/clients/{$pending->id}", [
'name' => $pending->name,
'email' => $pending->email,
'active' => true,
])->assertSessionHasErrors('active');
// Nothing is written: the guard throws before save(), so a refused
// approval does not leave the name or the flag half-applied.
expect($pending->refresh()->account_requested)->toBeTrue()
->and($pending->active)->toBeFalse();
});
test('door: approving a pending client through the API', function () {
seatLimits(clients: 0);
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
Sanctum::actingAs($this->admin, ['*']);
$this->patchJson("/api/v1/clients/{$pending->id}", ['active' => true])
->assertStatus(422)
->assertJsonValidationErrors('active');
expect($pending->refresh()->account_requested)->toBeTrue();
});
test('the cap does not block editing a client the installation already holds', function () {
// The guard sits inside the approval branch. Above it, an installation
// sitting at its cap could not rename anybody.
seatLimits(clients: 0);
$client = User::factory()->client()->create(['account_requested' => false, 'active' => true]);
$this->actingAs($this->admin)->patch("/clients/{$client->id}", [
'name' => 'Renamed Ltd',
'email' => $client->email,
'active' => true,
])->assertSessionHasNoErrors();
expect($client->refresh()->name)->toBe('Renamed Ltd');
});
test('door: demoting a staff account to client', function () {
seatLimits(clients: 0);
@@ -202,6 +252,77 @@ test('door: demoting a staff account to client', function () {
expect($staffer->refresh()->isStaff())->toBeTrue();
});
// ------------------------------------------- saying so before anything is typed
/**
* A full installation is an ordinary state on a managed plan, not a fault.
*
* It used to read as one: the create screen opened, you invented a
* password, submitted, and the plan limit came back as a validation error
* under the email field — which looks like a complaint about the address.
* The guard stays where it is; these cases are about the screen in front
* of it.
*/
test('the create screen turns you away instead of taking a form it cannot accept', function () {
seatLimits(staff: 1); // the admin already fills it
$this->actingAs($this->admin)->get('/users/create')
->assertRedirect('/users')
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 1.'));
});
test('the client create screen does the same', function () {
seatLimits(clients: 0);
$this->actingAs($this->admin)->get('/clients/create')
->assertRedirect('/clients')
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 0.'));
});
test('room under the cap still opens the create screen', function () {
seatLimits(staff: 2, clients: 2);
$this->actingAs($this->admin)->get('/users/create')->assertOk();
$this->actingAs($this->admin)->get('/clients/create')->assertOk();
});
test('the list says where the installation stands, so the button can go dead with a reason', function () {
seatLimits(staff: 2);
$this->actingAs($this->admin)->get('/users')
->assertInertia(fn (AssertableInertia $page) => $page
->where('seats.limit', 2)
->where('seats.used', 1)
->where('seats.full', false)
// Nothing to explain while there is room.
->where('seats.message', null));
});
test('the list carries the refusal in the guard\'s own words once it is full', function () {
// One wording for one limit: two is how somebody ends up believing
// there are two limits.
seatLimits(clients: 1);
User::factory()->client()->create();
$this->actingAs($this->admin)->get('/clients')
->assertInertia(fn (AssertableInertia $page) => $page
->where('seats.full', true)
->where('seats.message', fn (string $message): bool => str_contains($message, 'limited to 1.')));
});
test('a self-hosted install is told nothing about seats at all', function () {
// No limit, so no counter, no dead button, and no invitation to
// wonder which plan it is on.
seatLimits();
$this->actingAs($this->admin)->get('/users')
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
$this->actingAs($this->admin)->get('/clients')
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
});
// --------------------------------------------------------- what must not change
test('the console command is deliberately not capped', function () {
@@ -234,3 +355,32 @@ test('room under the cap still lets an account through', function () {
expect(User::query()->where('email', 'second@example.test')->exists())->toBeTrue();
});
it('names the limit without putting a noun after the number', function () {
// The refusal used to read "limited to 1 staff accounts" — the number
// sat directly in front of a countable noun, so no single wording could
// be right for every value. English needs two forms; Polish, Czech and
// Russian need three, and inflect the noun by the number in front of
// it. Putting the number last means no language has to agree with it.
config()->set('projectsend.platform.max_staff_users', 1);
config()->set('projectsend.platform.max_clients', 1);
// Both seats have to be full for either guard to say anything at all.
User::factory()->client()->create();
$allowance = app(SeatAllowance::class);
foreach (['guardStaff', 'guardClient'] as $guard) {
try {
$allowance->{$guard}();
$this->fail($guard.'() should have refused at a limit of 1.');
} catch (ValidationException $e) {
$message = $e->validator->errors()->first();
expect($message)->toContain('limited to 1.')
// A trailing noun is exactly what this is here to stop.
->and($message)->not->toContain('1 staff accounts')
->and($message)->not->toContain('1 clients');
}
}
});
@@ -127,8 +127,13 @@ test('it names the command this kind of installation can actually run', function
]);
// The rollback story, asserted end to end: whatever the marker said, the
// command rewrites it to whatever is actually running.
// command rewrites it to whatever is actually running. Through the artisan
// seam, as everything that runs this command has to be — see
// Tests\Support\RecordingUpdate. The settings write this asserts is the
// real one.
test('running the update clears the notice', function () {
recordingUpdate();
applied('2.2.0');
expect(noticeFor($this->admin))->not->toBeNull();
+161 -2
View File
@@ -3,8 +3,14 @@
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
/**
* The probe a reconciler reads instead of being given a shell one-liner
@@ -14,13 +20,16 @@ beforeEach(function () {
$this->admin = User::factory()->create();
});
function statusJson(): array
function statusJson(bool $assoc = true): array
{
// Capturing the command's own output rather than asserting on lines,
// because the contract here is the document and not the wording.
Artisan::call('projectsend:status', ['--json' => true]);
return json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
// Decoded as objects where the shape itself is under test: {} and []
// are the same array once an associative decode has flattened them,
// and telling them apart is the point of those cases.
return (array) json_decode(Artisan::output(), $assoc, flags: JSON_THROW_ON_ERROR);
}
test('it reports the version, the edition and the capabilities that edition grants', function () {
@@ -73,3 +82,153 @@ test('the human form says unlimited in words', function () {
->expectsOutputToContain('of unlimited')
->assertSuccessful();
});
// -------------------------------------------------------- is anybody there
/**
* The one question a platform cannot answer from outside the container.
*
* Written against the real sign-in path rather than by inserting log rows:
* what makes this trustworthy is that a login writes the entry, and a test
* that writes its own entry would keep passing after the listener stopped.
*/
test('it reports when a staff account last signed in', function () {
$this->travelTo('2026-08-24 21:13:32');
Auth::login($this->admin);
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
});
test('it reports the most recent sign-in, not the first', function () {
$this->travelTo('2026-08-01 09:00:00');
Auth::login($this->admin);
$this->travelTo('2026-08-24 21:13:32');
Auth::login(User::factory()->create());
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
});
test('a client signing in is not a staff sign-in', function () {
// Clients using an installation says nothing about whether anybody is
// still administering it, which is the question being asked.
Auth::login(User::factory()->client()->create());
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
});
test('never is null, and the key is there to say so', function () {
// "Nobody has ever signed in" and "we got no answer from the probe"
// have to stay distinguishable, and a missing key collapses them.
$status = statusJson();
expect($status['activity'])->toHaveKey('last_staff_login_at')
->and($status['activity']['last_staff_login_at'])->toBeNull();
});
test('an API token is not somebody signing in', function () {
// An hourly integration must not make a dormant installation look
// busy. Only Laravel's Login event writes the entry this reads, and
// token authentication does not fire it.
Laravel\Sanctum\Sanctum::actingAs($this->admin, ['manage_users']);
$this->getJson('/api/v1/users')->assertOk();
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
});
test('the human form says never rather than nothing', function () {
$this->artisan('projectsend:status')
->expectsOutputToContain('Last staff login: never')
->assertSuccessful();
});
// ------------------------------------------------- what the disk cannot say
/**
* Storage is summed from the rows, not measured on the volume.
*
* Measuring the directory was right until external storage went live and
* silently stopped being: an upload that resolves to a bucket leaves
* nothing on the volume, so a figure taken from the filesystem freezes
* while the account keeps filling.
*/
test('storage is what the installation holds, wherever the bytes went', function () {
File::factory()->create(['size' => 100, 'disk' => 'files']);
File::factory()->create(['size' => 250, 'disk' => 'files']);
File::factory()->create(['size' => 1000, 'disk' => 'files_external']);
$storage = statusJson()['storage'];
expect($storage['bytes'])->toBe(1350)
->and($storage['files'])->toBe(3)
// Split by disk, which is the only way to see what is still
// sitting locally from before a cutover.
->and($storage['by_disk'])->toBe([
'files' => ['bytes' => 350, 'files' => 2],
'files_external' => ['bytes' => 1000, 'files' => 1],
]);
});
test('a trashed file is not still costing anything', function () {
// File's `deleted` hook takes the bytes off disk, so a soft-deleted
// row records something that is gone rather than something held.
$file = File::factory()->create(['size' => 500, 'disk' => 'files']);
File::factory()->create(['size' => 100, 'disk' => 'files']);
$file->delete();
expect(statusJson()['storage']['bytes'])->toBe(100);
});
test('an installation holding nothing still answers with a map', function () {
// An empty PHP array encodes as [], and a reader unmarshalling a map
// breaks on the day it happens to be empty rather than the day it is
// written.
expect(json_encode(statusJson(false)['storage']->by_disk))->toBe('{}');
});
test('it reports the health a container cannot show from outside', function () {
// A queue worker dying is invisible to anything watching the
// container: it is still up, and zips quietly stop building.
$health = statusJson()['health'];
expect($health)->toHaveKeys(['pending_migrations', 'failed_jobs', 'queues'])
->and($health['pending_migrations'])->toBe(0)
->and($health['failed_jobs'])->toBe(0)
->and($health['queues'])->toHaveKeys(['default', 'zips']);
});
test('the enforcement setting is echoed back as applied', function () {
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('all');
});
test('an unreadable enforcement value reports none, not something stricter', function () {
// Read the way EnforceTwoFactor reads it: reporting a stricter answer
// than the middleware actually enforces is worse than reporting none.
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'everybody-ish');
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('none');
});
// --------------------------------------------- what core cannot answer alone
test('a package can report what core has no way to know', function () {
// The managed storage backend and the version of the package that
// provides it live outside this repository. A platform knows what it
// asked for; only the installation knows what loaded.
Event::listen(ResolvingInstallationStatus::class, function (ResolvingInstallationStatus $event): void {
$event->report('cloud_modules', '1.1.0');
$event->report('managed_storage', 's3 bucket "psc-rebels"');
});
expect(statusJson()['modules'])->toBe([
'cloud_modules' => '1.1.0',
'managed_storage' => 's3 bucket "psc-rebels"',
]);
});
test('an installation running no packages answers with a map, not a list', function () {
expect(json_encode(statusJson(false)['modules']))->toBe('{}');
});
@@ -136,7 +136,21 @@ test('nothing is reported when uploads go to external storage instead', function
test('the dashboard carries the verdict to the system widget', function () {
$admin = User::factory()->create();
// Substituted rather than read live, for the same reason the rest of
// this file substitutes it: the real answer depends on whatever machine
// the suite runs on. What is under test here is that the verdict this
// class produced reaches the widget whole — the level, and the volume
// name the card prints alongside it.
app()->instance(
StorageDurability::class,
durability(mounts('/docker/volumes/projectsend_files/_data', storage_path('app/files'))),
);
$this->actingAs($admin)
->get('/dashboard')
->assertInertia(fn ($page) => $page->has('system'));
->assertInertia(fn ($page) => $page->where('system.storage_durability', [
'level' => StorageDurabilityLevel::DockerVolume->value,
'volume' => 'projectsend_files',
'source' => null,
]));
});
+43 -65
View File
@@ -8,68 +8,18 @@ use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\UpdateInstallation;
use Illuminate\Console\OutputStyle;
/**
* The ordering constraints inside UpdateInstallation are invisible in its
* result and expensive when wrong — a queue:restart before a cache clear
* leaves a worker on old code indefinitely, and config:cache breaks
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
* only thing that can assert them, so the artisan call is a seam.
* The command runs through Tests\Support\RecordingUpdate in every test
* here, including the ones below that assert the real wiring rather than
* the sequence — nothing in this file asserts that an artisan command
* actually ran, and running them for real reaches outside this test into
* a directory the whole suite shares. See the class, and the test at the
* end that pins it.
*/
class RecordingUpdate extends UpdateInstallation
{
/** @var list<string> */
public array $calls = [];
/** @var array<string, int> */
public array $exitCodes = [];
/** @var array{route: bool, event: bool, config: bool} */
public array $warm = ['route' => false, 'event' => false, 'config' => false];
/** The test database is always migrated, so this cannot be observed for real. */
public bool $existingInstall = true;
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
$this->calls[] = $command;
return $this->exitCodes[$command] ?? 0;
}
protected function warmCaches(): array
{
return $this->warm;
}
protected function hasRunMigrationsBefore(): bool
{
return $this->existingInstall;
}
}
/**
* @param array{route?: bool, event?: bool, config?: bool} $warm
* @param array<string, int> $exitCodes
*/
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
{
$fake = new RecordingUpdate(
app(Illuminate\Contracts\Foundation\Application::class),
app(App\Modules\Identity\Permissions\EnsureSystemRoles::class),
app(Settings::class),
app(App\Modules\Audit\ActivityLogger::class),
);
$fake->warm = [...$fake->warm, ...$warm];
$fake->exitCodes = $exitCodes;
app()->instance(UpdateInstallation::class, $fake);
return $fake;
}
beforeEach(function () {
recordingUpdate();
});
test('it migrates, ensures roles, links storage and restarts the queue', function () {
$fake = recordingUpdate();
@@ -154,8 +104,9 @@ test('it records the version it applied', function () {
->and(app(Settings::class)->get(Setting::AppliedVersionAt))->not->toBe('');
});
// The real thing, not the seam: both entrypoints run this on every boot,
// so a second run has to be as uneventful as the first.
// Both entrypoints run this on every boot, so a second run has to be as
// uneventful as the first. The settings writes it asserts are the real
// ones; only the artisan calls are recorded.
test('running it twice is uneventful', function () {
$this->artisan('projectsend:update')->assertSuccessful();
$this->artisan('projectsend:update')->assertSuccessful();
@@ -163,10 +114,11 @@ test('running it twice is uneventful', function () {
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
});
// Not through the seam: this is the one assertion that the real wiring
// runs, and EnsureSystemRoles is the part of an update that a migration
// cannot do for itself. AccountManager rather than Uploader — the latter
// is legacy and deliberately never seeded.
// The one assertion that the real wiring runs: EnsureSystemRoles is the
// part of an update that a migration cannot do for itself, and the double
// does not touch it — only the artisan calls are recorded. AccountManager
// rather than Uploader — the latter is legacy and deliberately never
// seeded.
test('it puts back a system role somebody deleted', function () {
Role::query()->where('name', SystemRole::AccountManager->value)->delete();
@@ -286,3 +238,29 @@ test('a rollback raises no welcome', function () {
expect(app(Settings::class)->get(Setting::UpdateWelcomeTo))->toBe('')
->and(ActivityLog::query()->where('action', Action::ApplicationUpdated)->count())->toBe(1);
});
// The seam is not a convenience. bootstrap/cache holds one packages.php and
// one services.php for the whole checkout, and `pest --parallel` gives eight
// worker processes the same one: `clear-compiled` deletes both for all of
// them at once. A worker that boots its application in the window between
// that delete and its own rebuild reads an empty package manifest —
// PackageManifest::getManifest() falls back to [] when the file it just
// wrote is gone again — registers no package service providers, and dies on
// the next page it renders with "Target [Inertia\Ssr\Gateway] is not
// instantiable". It surfaced as UpdateWelcomeTest failing roughly one run in
// six, in a file that has nothing to do with updates.
//
// Asserted on the files rather than on the recorded calls: what matters is
// that nothing left this test, and a future double that forgot to intercept
// one command would still pass a call-list assertion.
test('it leaves the compiled caches the rest of the suite is reading alone', function () {
$manifests = [app()->getCachedPackagesPath(), app()->getCachedServicesPath()];
// Both are written during the first application boot of any run, so by
// now they are there to be deleted.
expect(array_filter($manifests, 'file_exists'))->toBe($manifests);
$this->artisan('projectsend:update')->assertSuccessful();
expect(array_filter($manifests, 'file_exists'))->toBe($manifests);
});
@@ -67,6 +67,12 @@ class ProfileUpdateTest extends TestCase
{
$user = User::factory()->create();
// A second administrator, so what is under test is self-deletion
// and not the last-administrator refusal: the factory makes an
// administrator, and one on their own may no longer remove
// themselves — see SoleAdministratorSelfDeletionTest.
User::factory()->create();
$response = $this
->actingAs($user)
->delete('/settings/profile', [
@@ -107,6 +113,10 @@ class ProfileUpdateTest extends TestCase
$user = User::factory()->create();
// Same reason as above: this is about the grace period, not about
// who is allowed to go.
User::factory()->create();
// Deleting your account has its own screen; the profile form no
// longer carries the block or the grace period behind it.
$this->actingAs($user)
+34
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\File;
@@ -11,14 +12,19 @@ use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\UpdateInstallation;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PragmaRX\Google2FA\Google2FA;
use Tests\Support\RecordingUpdate;
/*
|--------------------------------------------------------------------------
@@ -282,3 +288,31 @@ function failAccountContentDisposal(): void
}
});
}
/**
* Swap the update in for one that records its artisan calls instead of
* running them, and return it.
*
* Used by every test that runs `projectsend:update`, in more than one file
* — see the class for why running the real commands is not an option in a
* parallel suite.
*
* @param array{route?: bool, event?: bool, config?: bool} $warm
* @param array<string, int> $exitCodes
*/
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
{
$fake = new RecordingUpdate(
app(Application::class),
app(EnsureSystemRoles::class),
app(Settings::class),
app(ActivityLogger::class),
);
$fake->warm = [...$fake->warm, ...$warm];
$fake->exitCodes = $exitCodes;
app()->instance(UpdateInstallation::class, $fake);
return $fake;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use App\Modules\Platform\Updates\UpdateInstallation;
use Illuminate\Console\OutputStyle;
/**
* The real update with its artisan calls written down instead of run.
*
* Two reasons, and the second one is why every test that runs the command
* uses this and not the genuine article.
*
* The ordering constraints inside UpdateInstallation are invisible in its
* result and expensive when wrong — a queue:restart before a cache clear
* leaves a worker on old code indefinitely, and config:cache breaks
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
* only thing that can assert them, so the artisan call is a seam.
*
* And those commands are not local. `clear-compiled` deletes
* bootstrap/cache/packages.php and bootstrap/cache/services.php, `view:clear`
* empties storage/framework/views, `storage:link` rewrites public/storage —
* one copy of each, shared by all eight workers of a parallel run. A worker
* that boots its application in the window between the delete and the
* rebuild reads an empty package manifest, registers no package service
* providers at all, and dies on the next page it renders with
* "Target [Inertia\Ssr\Gateway] is not instantiable". See the test in
* UpdateCommandTest that pins this.
*
* Everything above the artisan call stays real: EnsureSystemRoles, the
* settings writes, the activity log and the welcome marker all run, which
* is what the tests in both files actually assert.
*/
class RecordingUpdate extends UpdateInstallation
{
/** @var list<string> */
public array $calls = [];
/** @var array<string, int> */
public array $exitCodes = [];
/** @var array{route: bool, event: bool, config: bool} */
public array $warm = ['route' => false, 'event' => false, 'config' => false];
/** The test database is always migrated, so this cannot be observed for real. */
public bool $existingInstall = true;
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
{
$this->calls[] = $command;
return $this->exitCodes[$command] ?? 0;
}
protected function warmCaches(): array
{
return $this->warm;
}
protected function hasRunMigrationsBefore(): bool
{
return $this->existingInstall;
}
}