31 Commits

Author SHA1 Message Date
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00
ignacionelson c21658f6f7 Let a client account expire on a date
Staff can give a client an expiry date on the create and edit screens,
and through /api/v1/clients. When the date passes, the client is refused
at sign-in and on their next request, and their API access ends too.
Files and history stay, and a later date (or none) brings them back.

Access is checked through one predicate, User::maySignIn(), at every
door: sign-in, the web session, API tokens and the two-factor
challenge. An hourly sweep also switches `active` off, so the list,
its filter and seat counts agree. The sweep is not what enforces it,
so a scheduler that is not running cannot keep an account open.

An account cannot be active with a date that has passed. Reactivating
an expired client needs a new date in the same save.

The day-means-end-of-day-where-you-are rule moved out of FileExpiry
into a shared DateInput, so file and account expiry read dates the
same way.

Requested by @Drardollan in #1310.
2026-09-13 14:57:16 -03:00
ignacionelson 7c7ba7cd53 Stop a typed-in storage quota from 500ing when a client is created
Filling the "Storage quota (MB)" field on the new-client form raised a
TypeError and the request died with a 500. Leaving it blank worked, which
is why it reached a release: that path goes through `null ?? 0`, and the
0 is an int.

The `integer` validation rule checks that a value looks like an integer.
It does not convert it. `$request->validate()` returns the raw input, so
the form field arrives as the string "2048" -- and the create form types
that field as a string in React, so it is a string even over JSON. Both
controllers declare strict_types, so handing it to
`ClientAccounts::create()`'s `int $storageQuotaMb` is a TypeError.

Fixed on both surfaces that call create(): the staff screen and
/api/v1/clients. The API twin had the same defect, reachable by sending
the quota as a quoted JSON value or a form-encoded body -- its own create
test only ever sent a JSON number.

Two more call sites had the same shape and are cast too, though nothing
sends them a string today: the share-link download cap and a comment's
reply_to. Both are safe only because a frontend file happens to call
Number() first, which is a fact about that file rather than anything the
signature guarantees. The null in each is preserved rather than collapsed
to 0 -- "no cap" is not a cap of zero.

`storage_quota_mb` is also cast on User and Invitation. The column is an
unsignedInteger and both docblocks already promise int; it is read
straight into provision()'s typed parameter when an invitation is
redeemed, and which type a driver hands back is not something that call
site should depend on.

Found on the new files-test rehearsal instance, on its first real use,
against the same build the whole fleet is running.
2026-09-12 21:16:49 -03:00
ignacionelson 123ae68972 Give invitations their own place in the navigation
The "Invite client" button led to a history, which is not what it says. The
tabs were a way of housing two things that had nowhere else to live, and
now they do: Invitations is a sidebar entry between Custom fields and
Groups, and the button goes to the form.

That is also the shape every other list in this application already has --
Clients, Groups, Categories, Roles all sit in the sidebar with a "New X"
button leading to their own create screen -- so the tabs were the odd one
out rather than the pattern. Two URLs, each meaning one thing:
/clients/invitations is the history, /clients/invitations/create is the
form. Sending now returns to the history, where the invitation just sent
is the first row.

No badge on the sidebar entry, deliberately, unlike the two queues below
it. Account requests and Membership requests count things waiting on
somebody here; an outstanding invitation is waiting on the person who was
invited. A number there would say "you have three things to do" about
three things nobody in this installation can act on.

Translations move with it: "History (:count pending)" was the tab label and
is gone from all sixteen, and "Invite a client to share files with" comes
back -- it was the form's description before the tabs took the heading, and
had never been translated because it left the code in the same commit that
would have reported it missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 17:22:31 -03:00
ignacionelson fb3fd1766c Turn the second tab into a history of every invitation, and open on it
The tab listed only what was still live, which cannot answer the question
somebody actually arrives with: did we ever invite this person, and what
happened? An invitation that was accepted, revoked or replaced by a newer
one simply vanished from the screen, and the activity log was the only
place left to look.

So the tab is a history now -- every invitation ever sent, newest first,
each carrying the state it ended in -- with a status filter for reading one
slice of it. And it opens first, because arriving here the question is
usually about what has already been sent, including to the person you were
about to invite again. ?tab=send still goes straight to the form.

Five states, and two of them needed deciding:

- "Expired" is not a stored status and deliberately is not one: nothing
  writes it, a row becomes expired by the clock passing rather than by
  anybody acting, and storing it would need a scheduled task to stay true.
  Invitation::state() derives it, once, and both the badge and the filter
  read that -- two copies of the rule is how they start disagreeing about a
  row whose expiry passed a second ago.
- "Replaced" is what superseded says to somebody who is not reading the
  source. It is a different fact from expired, and worth telling apart: one
  ran out, the other was retired by a newer invitation to the same address.

The count in the tab label stays a count of live invitations rather than of
the rows below. The history is mostly settled, and the number worth
carrying in a label is the one that says whether anybody is still waiting --
which is also why it is counted over the table rather than the filtered
page, so narrowing the list cannot change it.

Revoke appears only on a row that still has something to revoke, and the
expiry column is blank on a settled one: the date is still stored and still
true, and printing it invites somebody to wonder what expires about an
invitation that was accepted.

The screen is called Invitations now, in the heading and the breadcrumb.
With the history first it is no longer a form with a list under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 16:23:50 -03:00
ignacionelson 38187dcf1a Stop an expired invitation being renewed for ever
The expired page offers a "send me a new one" button that re-issues the
invitation with no staff member involved. On its own that is reasonable --
the new link goes to the address on the invitation, never to whoever
clicked, so holding a leaked URL gets nobody a working one, and it is the
same shape as a password reset.

What it spent was the operator's expiry window. A link could be renewed
from a dead link, indefinitely, so a window set to 72 hours was only ever
as short as the longest anybody bothered to wait. That matters in the case
expiry is actually for: a link sitting somewhere it should not be -- a
forwarded thread, a shared inbox, a mailbox that changed hands.

So the chain gets a limit: three renewals, then a staff member has to send
a new invitation. The count is carried forward on each renewal rather than
stored per row, which is what makes it apply to the chain; a staff-sent
invitation starts at zero, because sending one is somebody deciding to.

A renewal beyond the limit answers in exactly the same words as a spent,
unknown or revoked token, and sends nothing. Four situations, one sentence:
telling them apart is how this door would become a way to learn which
addresses an installation has invited.

Renewals are now logged, which they were not -- sending and redeeming
already were, and renewing was the one step that moved an invitation along
with nobody behind it and left no trace. The limit bounds how many rows an
anonymous door can write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:57:27 -03:00
ignacionelson a502a26075 Let staff cancel an invitation nobody has used
An invitation could be sent and never taken back. There was no list of
outstanding ones and no revoke, so the only way to withdraw a link sent to
the wrong address was to let it expire -- and the expired page's own "send
me a new one" button undoes exactly that, silently, for anybody still
holding the link. The one cancel the feature had could be reversed by the
person it was aimed at.

So: a new STATUS_REVOKED, outside the pending() scope that both the
redemption and the resend doors look through. A revoked link is dead to all
three things a live one can do -- opening the form, redeeming it, and
asking for a replacement -- and nothing but sending a fresh invitation
brings it back.

The list sits under the invite form rather than on a screen of its own,
because the person who wants to cancel an invitation is the person who just
sent one. It shows outstanding invitations only: pending, expired ones
included. An expired invitation is not inert until it is revoked, so hiding
it would hide the rows most worth a decision -- which is why they sort to
the top, soonest expiry first.

Revoking is gated by create_clients, the same authority as sending:
whoever may invite somebody may take it back. It is logged, like sending
and redeeming already were. The row is kept rather than deleted, for the
reason a superseded one is kept -- the activity log names who invited this
address and when, and that trail should still lead somewhere.

Verified in a browser, not only in tests: the screen mounts, both rows
render, and the expired one carries its badge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:55:17 -03:00
ignacionelson 74d1de2d6e Say why an invitation was refused when the installation is full
Two halves of the same gap. Redemption always provisions with
autoApprove: true, so it always meets SeatAllowance::guardClient(), which
refuses on the `email` field -- and the redemption form's email input is
read-only, never submitted, and had nowhere to render an error. The account
was correctly not created and the person was told nothing at all: the form
simply came back. The field now renders errors.email, which is where every
other account form's refusal already lands.

The other half is the button. "New client" has been seat-limited since the
limit existed -- it goes dead with the reason beside it, rather than
offering a form that cannot be submitted. "Invite client" sat next to it,
live, on a full installation. Worse than the original complaint, because
the refusal is met by the invited person rather than by the staff member
who caused it.

So the invite button is seat-limited too, and sending guards as well as
redeeming. An outstanding invitation is still not a client and is still not
counted as one -- the rule a pending account request follows, for the
reason SeatAllowance spells out -- so this reserves nothing. It refuses to
send a link a full installation could not honour, and redemption keeps its
own guard, because the seat can be taken by somebody else in the days
between.

SeatLimitedAction's usage caption is now optional, and the invite button
omits it. Two buttons governed by one limit, each captioned with the same
sentence, reads as two limits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 15:50:21 -03:00
ignacionelson cd2ce960d3 Refuse an invitation whose address was taken while the link was live
An invitation stays live for days -- 72 hours by default -- and the address
it names can be claimed in that window: staff got impatient and created the
account by hand, or the person used the public registration form instead.
Redemption never asked, so User::create() met the unique index on
users.email and raised a QueryException. A 500, on the screen of somebody
who had just chosen a password, having done nothing wrong.

ClientProvisioning::addressIsFree() exists for exactly this, and its
docblock says who must call it: the paths with no form to validate. LDAP
asks. Redemption is the third such path and did not.

It now refuses with a message that says what happened, rather than the
generic "this invitation is no longer valid" the expired case uses. There
is nothing to withhold here -- whoever holds the link already knows the
address, because it is the one the invitation was sent to -- and being told
to sign in instead is the only useful thing to say.

The invitation stays pending rather than being retired. It is the account
that resolved the situation, not the link, and a retired row would only
make the second attempt read as expired.

The address rule spans soft-deleted accounts, the same as it does
everywhere else, so a deleted account still holds its address until erasure
takes the row away. Both cases are tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 14:30:42 -03:00
mash2k3 856c13b09c Invite a client to register instead of handing them a password (#1780)
Staff can now invite a specific address to register instead of typing a
password for somebody and finding a way to get it to them. The invited
person sets their own, the link is locked to the address it was sent to,
and an invitation always activates the account regardless of the
auto-approve setting -- naming an address is already the decision the
approval queue exists to make for one nobody named.

Two fixes ride along: outgoing mail now reads the installation's own site
name in its title, header and signature rather than the one baked into
config('app.name') at install time, and the CSRF cookie name is read per
request rather than captured once at load.

Follow-up work, tracked separately: an invitation cannot be cancelled --
there is no pending-invitations screen and no revoke, so letting one expire
is the only way to take it back, which the self-service resend button then
undoes. Redemption also needs the address-availability check every other
non-form caller of ClientProvisioning makes.

Thanks @mash2k3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe
2026-09-12 14:28:47 -03:00
ignacionelson 62c763d04e Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.

So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.

ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:

  - The client create and edit screens. The edit screen mirrors that
    resolution client-side to draw the usage bar, so handed the raw
    setting on a floored installation it computed an effective quota of
    zero, printed "unlimited" and hid the bar entirely -- for a client
    whose next upload was about to be rejected for exceeding a limit the
    screen said did not exist.

  - projectsend:status, which gains clients_can_register and
    default_client_storage_quota_mb. Both defaults are the permissive
    ones, both are invisible from outside, and a document reporting the
    setting while uploads obeyed the floor would say the ceiling was
    missing on an installation that has one.

The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
2026-09-11 00:41:31 -03:00
ignacionelson 0a28e239d6 One home for what a client account is
Three surfaces create client accounts now: the staff screens,
/api/v1/clients, and the platform control plane in the private package.
Two of them held their own copy of the type, the role, the active flag,
the "0 means inherit the site default" quota, the verified stamp, the
activity entry and the seat guard — and the third could not have a copy
at all, because a package cannot import a host class.

ClientAccounts is that one definition, reached by name from outside.
What stays with each caller is what genuinely differs: its validation,
its response, its custom fields, and who is asking.

Two things changed rather than moved:

The seat cap is now checked inside create(), before anything is written,
instead of at the top of each controller. That is what makes a leaked
platform token an incident rather than an unbounded one — a guard that
ran only where somebody remembered it is not a guard.

email_verified_at is written with forceFill. It is deliberately absent
from User::$fillable, so every client-creation path passed it into a mass
assignment and lost it in silence. StaffAccounts already noted this and
named the other paths; this closes the client half.
2026-09-08 16:23:34 -03:00
ignacionelson 9c6f4df5bc Merge pull request #1736 from denkfabrik-li/fix/scoped-creator-keeps-client
A client-scoped staff member with create_clients created a client and lost it in the same request. guardTarget() answers 404 for anything off their roster, and StaffLibraryScope::clients() leaves it out of their list -- so the record existed, was logged, was welcomed by email, and was invisible to the person who made it. store() redirects to the edit page, which is exactly where they landed on a 404. The API twin had the same shape: a scoped token got a 404 from every route that binds the client it had just created.

The new client is now attached to the creator's roster when the creator is client-scoped, on both sides. That is where a client they created belongs -- the roster is the same list assignedClients already uses for everything else they may reach. Unscoped creators gain nothing: they see every client already, and a roster entry would change what assignedClients means for them. Nothing is attached retroactively.

The widening this involves is self-limited: the only thing added is an account the creator just made, which starts with no files, no folders and no group memberships, so assignableClientIds gains nothing to reach. Seats do not move either, since they are counted from active and account_requested.

Verified before merging: 34 passed across both suites on the trial-merge, 2 failed / 32 passed with app/ reset. This is the busiest file set of the series -- it shares ClientsController with #1718 and Api/ClientsController plus the API test file with #1723 -- so the merged result was read rather than trusted: #1718's reassign_candidates gating and #1723's patchCustomFieldValues are both intact alongside it. scramble:export reproduces the committed docs/api/openapi.json byte for byte.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:20:44 -03:00
ignacionelson 262cb2457a Merge pull request #1723 from denkfabrik-li/fix/api-patch-custom-fields
Api\ClientsController::update() states the rule eighteen lines above the bug: "PATCH semantics, unlike the web form which always submits every field: an absent key means 'leave alone', not 'clear'." Every column obeyed it. The custom fields did not -- they went through saveCustomFieldValues(), which is create()'s pass: it walks every field there is and writes null for the ones the request did not carry. A PATCH naming one field emptied all the others, with nothing in the response to say so and no second copy of the value anywhere.

The write pass is still shared but is now entered two ways: create() keeps writing every field, and update() writes only the fields the request named. Creating a client is deliberately unchanged -- it is not a partial update, and a checkbox nobody ticked is a recorded "no" rather than an absent row. Clearing a field by naming it with an empty value still clears it, and the validation rules are untouched.

Verified before merging: 23 passed on the trial-merge, 1 failed / 22 passed with app/ reset. The two guard tests -- a named empty value still clears, create still records every field -- are green either way, so the write path was not simply switched off. The keys reaching whereIn() are stripped to real field ids by validateCustomFieldValues() before they get there. scramble:export re-run on the merged tree produces a docs/api/openapi.json identical to main's, so the published spec does not move.

Reported and fixed by @denkfabrik-li.
2026-08-28 16:47:40 -03:00
denkfabrik-li 776d3d99f4 Put a client on the roster of the scoped staff member who created them
A client-scoped staff member with create_clients creates a client and
loses it immediately. guardTarget() answers 404 for anything off their
roster, and StaffLibraryScope::clients() leaves it out of their list -- so
the record exists, is logged, is welcomed by email, and is invisible to
the person who made it. store() redirects to the edit page, which is where
they land:

  POST /clients        → 302 → /clients/4
  on_creator_roster    → false
  GET /clients/4/edit  → 404
  clients listed       → ["Mine"]     the new client is not there

Their own roster is where a client they created belongs, so it is attached
there. An unscoped creator gains nothing: they see every client already,
and a roster entry would change what assignedClients means for them.

The API twin does the same, for the same reason -- a scoped token gets a
404 from every route that binds the client it just created.

Three tests: the scoped creator can open and list the client, an unscoped
creator gains no roster entry, and the API twin behaves like the web. The
first and third go red without the fix.
2026-08-28 06:40:50 +02:00
denkfabrik-li 250e8664d3 Stop a client PATCH clearing custom fields it never mentioned
update() states the rule eighteen lines above the bug: "PATCH semantics,
unlike the web form which always submits every field: an absent key means
'leave alone', not 'clear'." Every column obeys it. The custom fields did
not, because they went through create()'s pass, which walks every field
there is and writes null for the ones the request did not carry.

Two fields filled, a PATCH naming one:

  status         → 200
  named field    → "Robin"
  the other one  → null      (was "ATU12345678")

Nothing says so in the response, and there is no other copy of the value.

The write pass is now shared but entered two ways: create() keeps writing
every field, since a new client has no values and a checkbox nobody ticked
is a recorded "no"; update() writes only the fields the request named.

Three tests: the untouched field survives, a named empty value still
clears, and create still records every field. Without the fix the first
goes red.
2026-08-28 06:40:47 +02:00
denkfabrik-li 9d4b096c19 Narrow the reassignment picker to what a viewer may see
`reassign_candidates` is the delete dialog's picker: every active account
in the installation, by name and by role label. The same list is shared
on the clients index, the users index, both edit screens and privacy
settings, and it was narrowed by nothing.

Two lines above it on the clients index sits the listing itself, narrowed
through `scope->clients($viewer)` with a comment saying why: "a
client-scoped staff member is not shown the name and email of somebody
they can reach nothing of". The picker beside it handed over every client
in the installation, plus every staff account and its role name. The
filter by `can('delete_clients')` happens in React, which decides what is
rendered, not what is sent.

So the client half of the candidate list goes through the same
StaffLibraryScope as the listing, and each screen sends the picker only to
a viewer holding the delete permission it exists for. Staff accounts are
not narrowed -- they are not narrowed anywhere else either -- and an
unscoped viewer's list is unchanged, because StaffLibraryScope::clients()
returns every client for them.

Privacy settings keeps the whole installation on purpose: that picker sets
the erasure default stored once for everybody, behind edit_settings, so
narrowing it by whoever happens to be editing would store the wrong
answer. The parameter is nullable for that one caller, and the docblock
says so.

Four tests. Without the fix three go red; the fourth is the guard that an
administrator still sees every active account.
2026-08-28 06:40:45 +02: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
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 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
ignacionelson 463e86f82b Refuse an account past the seat count an operator sold
Opening user management on cloud (623ad68) left a managed tenant able to
create staff accounts without limit. This is the other half, and the two
belong in the same release.

max_clients and max_staff_users are numbers the platform sells and does
not enforce — grep finds them only being passed to screens. The
application is the only process that can count against them, so it
accepts the number from the environment and refuses to exceed it. That is
not the same as inventing a plan tier, which is what config/api.php
declines to do when it will not key a rate limit off billing: nothing
here knows what a plan is.

## One definition

staffUsed() and clientUsed() are public and are what the guards read. A
control plane showing "2 of 3 used" from its own query, beside an
application refusing the fourth from a different one, disagrees
eventually — over an inactive account, or a deleted one — and the
disagreement reads as a billing fault rather than a counting one.

## What counts, and the consequences somebody has to explain

An inactive staff account occupies its seat. Excluding it would make
deactivation a way around the cap rather than a way to revoke access,
since reactivating is one click. The cost is an awkward incentive —
deactivating is the safe removal and keeps paying, deleting frees the
seat and asks what happens to the files — and it is better explained than
hidden.

A client awaiting approval does not. Self-registration is open to
strangers, and counting a pending request would let anybody exhaust a
paid limit from the outside, turning a pricing tier into an availability
control. The seat is spent at approval, which is where the guard sits.

A soft-deleted account frees its seat, though not its address —
AvailableEmailRule holds that until erasure. So a seat can be free while
re-adding the same person is still refused, which is the address rule
rather than this one.

## Eight doors, eight tests

There is no single User::create() to guard. StaffAccounts::create()
covers both staff controllers, but a promotion takes a staff seat without
creating anything, a demotion takes a client seat, ClientProvisioning
serves registration and LDAP and social sign-in alike, and approval turns
an uncounted request into a counted client.

A cap is only a cap if every door asks, so there is a test per door and
each was verified to fail without its guard — eight red, with the two
"must not change" cases green either way. DownloadAllowance's shape for
DownloadAllowance's reason: the failure mode is one of them quietly not
asking, invisible from everywhere except the door that forgot.

projectsend:admin is deliberately uncapped and has a test saying so. It
is the recovery path, and anyone who can run it can also edit the
environment the cap comes from.
2026-08-27 02:31:12 -03:00
ignacionelson 9fc5042f4e Merge pull request #1688 from denkfabrik-li/fix/atomic-account-deletion
Delete an account and dispose of its content in one transaction

Resolved the conflict with #1678 the way that PR's merge note predicted:
the erasure stamp goes inside the new transaction, so a deletion that
rolls back cannot leave a live account carrying a date on which it would
be erased.
2026-08-26 22:28:32 -03:00
Ignacio Nelson c5d32c06f6 Merge pull request #1684 from denkfabrik-li/fix/create-only-redirect-403
Land a successful create where a create-only role can actually go
2026-08-26 22:24:48 -03:00
ignacionelson ad4d75d8fe Merge pull request #1678 from denkfabrik-li/fix/deleted-account-email-reserved
Let a deleted account's email address come back into use

Closes #1648, and with it the last open item of #1647's audit of unique
indexes on soft-deleting tables.

Resolved a trivial conflict in both ClientsControllers: this branch and
today's e7b5b6a each add a constructor property at the same line, so both
are kept. Nothing else overlapped.
2026-08-26 22:20:02 -03:00
ignacionelson 12a8ebe380 Rank top clients by roster, not by library, and factor the client guard
Two things found by checking #1696 and #1699 -- open branches carrying
the same fixes I wrote this morning -- against what I actually shipped.

**topClientsByStorage was scoped with the wrong question.** 4b8220a
narrowed it with StaffLibraryScope::files(), which is right for the two
widgets that name files and wrong for the one that names clients: a
stranger client's upload can sit legitimately inside a scoped viewer's
library, shared with a group one of their own clients belongs to. So the
file was theirs to read and the uploader's name was not theirs to see.
Measured: "Stranger Client Ltd", on nobody's roster, ranked on a scoped
dashboard. assignableClientIds is what the widget is actually asking, and
it is what #1699 used. Their version was right and mine was not.

**The client guard is one method now, not eight copies.** #1696 wrote it
as a private guardTarget() rather than repeating viewer-resolve plus
abort at each site, which is better, and this is a change whose whole
argument is that a rule stated in many places drifts. Behaviour is
identical; the eight sites now read as one rule.

The published document reorders a 404 below a 422 on one path. Scramble
reads abort_unless out of a method body but not out of a helper it calls,
so the 404 now comes from route model binding instead of from the inline
abort -- same response, different position. #1701's body names this trap;
worth knowing it costs ordering and not content.

Credit where it is due: both come from denkfabrik-li's #1696 and #1699,
which were open while I was writing the same fixes. Those two are closed
against this and against e7b5b6a, 4b8220a and 67e9204.
2026-08-26 18:24:13 -03:00
ignacionelson e7b5b6a757 Hold client records to the same boundary the rest of the library uses
The other half of the sweep. ClientsController and its API twin checked
`abort_unless($client->isClient(), 404)` and nothing else -- a type
check, not a boundary, which is the phrase #1701 used about the group
membership routes for exactly the same reason.

Measured before the fix, with a client-scoped role holding the client
permissions:

  GET    /clients            every client on the installation, name + email
  GET    /clients/{stranger} 200
  PATCH  /clients/{stranger} 302, name actually changed
  DELETE /clients/{stranger} 302, client gone

The tell was one route over. ClientFilesController::index already draws
this line with StaffLibraryScope::canAssignClient and calls it "the same
boundary StaffLibraryScope enforces everywhere else in the library". Its
neighbours in the same family did not.

So the predicate is not new here. What is new is StaffLibraryScope::clients(),
the listing half of canAssignClient, so a screen narrows by the rule its
own buttons are guarded with instead of restating it -- restating it is
how this went wrong, and how the last four of these went wrong.

Eight actions take it: edit, update, destroy and the two-factor reset on
both surfaces, plus both listings. Answering 404 rather than 403, since a
client outside the roster should not be distinguishable from one that is
not there -- matching the isClient() guard already above it.

Account requests stay installation-wide on purpose: a self-registered
client who has not been approved belongs to nobody yet, so there is no
roster to narrow by and narrowing would empty the screen.

The published API document is unchanged -- both routes already documented
the 404 that the type check produced.
2026-08-26 16:01:27 -03:00
denkfabrik-li 4806b81dc3 Let a deleted account's email address come back into use
An account deleted by an administrator was soft-deleted with erase_after
null, so projectsend:purge-erasures — which filters on
whereNotNull('erase_after') — never reached it, and the unique index on
users.email kept the address reserved forever. Anyone re-creating the
account got "The email has already been taken", naming a conflict nothing
on any screen could show or clear (#1648).

Both halves of the issue's option 3:

Every deletion path now schedules the erasure. The stamp lives in
ErasureSchedule — self-deletion switched to it, and StaffAccounts::delete
(shared by the web screen and the API) and both client controllers call
it right before delete(). Same grace period, same purge, whoever deleted
the account. Deliberately no backfill for rows deleted before this
change: stamping them during an update would start a countdown to data
erasure that nobody chose at deletion time; the message below covers
them instead.

The staff creation paths swap unique:users,email for AvailableEmailRule,
which refuses exactly the same things but can explain the one refusal
the stock message can't: an address held by a deleted account now names
the date it becomes available, and one deleted before scheduling existed
points at projectsend:erase-account. A living account keeps the stock
message, and public registration keeps the stock rule — telling an
anonymous visitor the address belongs to a deleted account would confirm
it had an account here.
2026-08-26 04:04:48 +02:00
denkfabrik-li 61c385e423 Delete an account and dispose of its content in one transaction
Deleting a staff or client account is two writes: soft-delete the account,
then cascade or reassign the files and folders it owns. All four destroy()
paths (Users + Clients, web + API) ran them one after the other with nothing
tying them together.

If the second write throws, the account is already gone but its content is
not handled. The concrete way in is the reassign branch: validate() checks
reassign_to_id with exists(active), but apply() re-resolves it with
findOrFail() a moment later (AccountContentDeletion:108), so a target
deactivated or deleted in between throws — leaving a soft-deleted account
whose files still point at it, and a UserDeleted log for a deletion that did
not finish.

Wrap the delete()+apply() pair in a single DB::transaction() in each of the
four destroy() methods. validate() and the authorization guards stay outside
it: they are read-only and must be able to reject before anything is written.
cascadeDelete()/reassignTo() already open their own transaction, which nests
as a savepoint under this one, so the account soft-delete, its activity log,
and the content work now commit or roll back together.

Tests: a DeletedAccountContent double that reports content to handle and then
throws while handling it (tests/Helpers.php) drives one test per destroy()
endpoint asserting the account survives the failure and no UserDeleted entry
is written; each goes red against the un-wrapped controller.
2026-08-25 23:03:13 +02:00
denkfabrik-li e6dc271f27 Land a successful create where a create-only role can actually go
Four create flows redirected to the new record's edit page on success,
but store is gated by create_* while the edit page is gated by edit_*,
and PermissionChecker has no create-implies-edit rule. A role holding
create_* without edit_* would create the record -- write, activity log
and notifications all run -- and then meet a 403 on the success
redirect, with no way to tell the action worked and every reason to
submit a duplicate. Categories is reachable with plain UI clicks, since
the sidebar shows it from create_categories alone.

Keep landing on the edit page for anyone who may edit, and divert only
those who can't -- to the create form, which shares store's own gate
and is therefore reachable by exactly whoever just created the record;
the success toast shows there. The index would not do: Clients/Groups
lists are gated by manage_*, which store itself does not require.
Implying edit_* from create_* would not do either -- edit has no
own/others split here, so it would silently hand a deliberately narrow
create-only role edit (two-factor reset included) on every existing
record.
2026-08-25 21:45:37 +02:00
ignacionelson 88c182cf3b Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and
PDF. v2 previewed only images, and not by decision: preview shipped as part
of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a
synonym for "GD can decode it". FileThumbnailController::preview() gated on
ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same
four types, and the dialog was a hardcoded <img>.

Rather than widen that list — it drives pathFor(), extensionFor(),
generate() and FileDiskCleanup, and a video reaching getimagesize() is a
500 — this separates the two questions. PreviewKind now answers "may these
bytes be served inline, and what element renders them?", while
ThumbnailGenerator keeps answering the narrower "can this app decode it
itself?", which is what renditions, the cache and the watermark hook
actually depend on. Image delegates to it so the two cannot drift.

The allowlist stays a security boundary: mime_type is sniffed from the
bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is
deliberately narrower than "formats a browser might cope with" — no
quicktime, avi or matroska, because an embedded player for those shows a
black rectangle. Those still download exactly as before.

docs/security-audit-2026-08-05.md finding 1 recorded that adding
application/pdf "should be a conscious decision". This is that decision,
and three things were measured rather than assumed:

- An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer
  in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without
  allow-same-origin) — the attribute removes the feature, it does not
  harden it.
- nginx's `Content-Security-Policy: sandbox; default-src 'none'` on
  /protected-files/ does work (a <video> frame lands in an opaque origin),
  but Chrome exempts its PDF viewer from it, so it is not what protects
  the PDF case.
- What does is the allowlist plus the browser's own PDF sandbox, where PDF
  JavaScript has no DOM and no cookies.

Range requests were verified end to end: 206 with a correct Content-Range,
a byte-perfect file reassembled from three ranges, and a real browser
seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on
the X-Accel path, so there is no collision.

Two settings, both defaulting on so no installation loses what it has:
clients_can_preview_files and public_listing_preview_enabled. Staff are
never gated. The anonymous side needed a route of its own — there was no
public preview endpoint — with its own throttle bucket, since a bare
throttle: shares one counter across that whole block.

A preview now logs at most one FilePreviewed per viewer per file per five
minutes: a <video> turns one deliberate act into a long tail of Range
requests, and a row each would bury the log.

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00