From c21658f6f7dc29ec2e588564b7c870ea30194c73 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sun, 13 Sep 2026 14:57:16 -0300 Subject: [PATCH 1/6] 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. --- app/Models/User.php | 31 ++ .../Middleware/EnsureApiAccountIsActive.php | 6 +- app/Modules/Audit/Action.php | 6 + app/Modules/Clients/ClientAccounts.php | 30 +- .../Clients/ClientsServiceProvider.php | 6 + .../Console/ExpireClientAccountsCommand.php | 64 ++++ .../Controllers/Api/ClientsController.php | 39 ++- .../Http/Controllers/ClientsController.php | 41 ++- .../Http/Resources/Api/ClientResource.php | 4 + app/Modules/Files/Editing/FileExpiry.php | 41 +-- app/Modules/Identity/AccountConversion.php | 4 + .../TwoFactorChallengeController.php | 2 +- .../Http/Middleware/EnsureAccountIsActive.php | 10 +- app/Modules/Identity/SignIn.php | 13 +- .../SchedulerMonitoringController.php | 1 + .../Platform/Localization/DateInput.php | 63 ++++ ...3_090000_add_expires_at_to_users_table.php | 29 ++ docs/api/openapi.json | 25 ++ .../js/components/client-expiry-field.tsx | 45 +++ resources/js/pages/clients/create.tsx | 5 + resources/js/pages/clients/edit.tsx | 14 + resources/js/pages/clients/index.tsx | 19 +- routes/console.php | 4 + tests/Feature/Clients/ClientExpiryTest.php | 308 ++++++++++++++++++ .../Platform/SchedulerMonitoringTest.php | 2 +- 25 files changed, 761 insertions(+), 51 deletions(-) create mode 100644 app/Modules/Clients/Console/ExpireClientAccountsCommand.php create mode 100644 app/Modules/Platform/Localization/DateInput.php create mode 100644 database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php create mode 100644 resources/js/components/client-expiry-field.tsx create mode 100644 tests/Feature/Clients/ClientExpiryTest.php diff --git a/app/Models/User.php b/app/Models/User.php index 4b779231..5b475699 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -32,6 +32,7 @@ use Laravel\Sanctum\HasApiTokens; * @property int|null $dashboard_columns * @property int $storage_quota_mb * @property Carbon|null $erase_after + * @property \Carbon\Carbon|null $expires_at * @property-read Role|null $role */ class User extends Authenticatable implements HasLocalePreference @@ -96,6 +97,32 @@ class User extends Authenticatable implements HasLocalePreference return $this->isStaff() && $this->role?->client_scoped === true; } + /** + * Whether this account's expiry date has passed. Only client accounts + * are given one (see the client screens and /api/v1/clients). + */ + public function hasExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * The one question every door into the application asks of an account + * that has already proved who it is: sign-in, every web request, every + * API request, and the second-factor challenge. + * + * Expiry is checked here as well as by the hourly sweep that switches + * `active` off, and neither is enough alone. The sweep is what keeps + * everything else that reads `active` — lists, filters, seat counts — + * in step. But a sweep runs on a schedule, and a scheduler that is not + * running would leave an expired account working forever. So access + * is refused the moment the date passes, whatever the flag says. + */ + public function maySignIn(): bool + { + return $this->active && ! $this->hasExpired(); + } + public function hasTwoFactorEnabled(): bool { return $this->two_factor_confirmed_at !== null; @@ -183,6 +210,10 @@ class User extends Authenticatable implements HasLocalePreference // is not something those call sites should depend on. 'storage_quota_mb' => 'integer', 'erase_after' => 'datetime', + // Deliberately absent from $fillable too: when an account stops + // working is decided by staff, never by a payload the account + // itself could send (the profile form fills from its request). + 'expires_at' => 'datetime', 'email_verified_at' => 'datetime', 'password' => 'hashed', 'two_factor_secret' => 'encrypted', diff --git a/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php index b4e2bf01..8e17fa29 100644 --- a/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php +++ b/app/Modules/Api/Http/Middleware/EnsureApiAccountIsActive.php @@ -10,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response; /** * The token twin of Identity's EnsureAccountIsActive: deactivating an - * account revokes its API access on the very next request, without anyone - * having to hunt down the tokens it minted. + * account, or its expiry date passing, revokes its API access on the very + * next request, without anyone having to hunt down the tokens it minted. * * Deleted accounts need no equivalent — users are soft-deleted and the * default query scope means Sanctum simply fails to resolve the tokenable, @@ -26,7 +26,7 @@ class EnsureApiAccountIsActive { $user = $request->user(); - if ($user !== null && ! $user->active) { + if ($user !== null && ! $user->maySignIn()) { abort(401); } diff --git a/app/Modules/Audit/Action.php b/app/Modules/Audit/Action.php index 530dc2ac..64b6190b 100644 --- a/app/Modules/Audit/Action.php +++ b/app/Modules/Audit/Action.php @@ -44,6 +44,10 @@ enum Action: string case ClientInvitationRedeemed = 'client.invitation_redeemed'; case ClientInvitationRevoked = 'client.invitation_revoked'; case ClientInvitationResent = 'client.invitation_resent'; + // Logged by the hourly sweep, so it has no actor: nobody switched the + // account off, its date passed. Distinct from UserDeactivated for the + // same reason TwoFactorReset is distinct from TwoFactorDisabled. + case ClientExpired = 'client.expired'; // Files case FileUploaded = 'file.uploaded'; case FileUpdated = 'file.updated'; @@ -173,6 +177,7 @@ enum Action: string self::ClientInvitationRedeemed => 'Registered a client account from an invitation', self::ClientInvitationRevoked => 'Revoked the invitation sent to :email', self::ClientInvitationResent => 'A new invitation link was requested for :email', + self::ClientExpired => 'The client account ":name" expired and was deactivated', self::FileUploaded => 'Uploaded the file ":subject"', self::FileUpdated => 'Updated the file ":subject"', self::FileDeleted => 'Deleted the file ":name"', @@ -279,6 +284,7 @@ enum Action: string self::ClientInvitationRedeemed => 'A client registered an account from an invitation', self::ClientInvitationRevoked => 'An invitation was revoked before it was used', self::ClientInvitationResent => 'An invited person asked for a replacement link', + self::ClientExpired => 'A client account reached its expiry date and was deactivated', self::FileUploaded => 'A file was uploaded', self::FileUpdated => 'A file was updated', self::FileDeleted => 'A file was deleted', diff --git a/app/Modules/Clients/ClientAccounts.php b/app/Modules/Clients/ClientAccounts.php index 6d327d11..6e859e70 100644 --- a/app/Modules/Clients/ClientAccounts.php +++ b/app/Modules/Clients/ClientAccounts.php @@ -14,6 +14,8 @@ use App\Modules\Identity\UserType; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; +use Carbon\Carbon; +use Illuminate\Validation\ValidationException; /** * Creating a client account — the rules and the side effects, shared by @@ -56,6 +58,9 @@ class ClientAccounts * enforcement time — see * ClientStorageUsage::quotaMb(). It does * not mean unlimited. + * @param Carbon|null $expiresAt when the account stops working; + * null for never. Must be in the + * future — see guardExpiry(). * @param bool $welcome whether this installation should email the * new account. A caller that sends its own * welcome passes false rather than having the @@ -68,6 +73,7 @@ class ClientAccounts int $storageQuotaMb = 0, bool $welcome = true, string $emailField = 'email', + ?Carbon $expiresAt = null, ): User { // Before anything is written, and deliberately not left to the // caller. The platform sets this cap and the platform is also what @@ -76,6 +82,7 @@ class ClientAccounts // that only ran on the surfaces that remembered it would not be a // guard. $this->seats->guardClient($emailField); + $this->guardExpiry($expiresAt, active: true); $client = User::create([ 'type' => UserType::Client, @@ -98,7 +105,10 @@ class ClientAccounts // confirm and nobody to confirm it to. (Inert today, since // MustVerifyEmail is not enabled on the model, but the column is // what a later switch would read.) - $client->forceFill(['email_verified_at' => now()])->save(); + // + // expires_at is written the same way for its own reason: see the + // note on its cast in User. + $client->forceFill(['email_verified_at' => now(), 'expires_at' => $expiresAt])->save(); $this->activity->log(Action::UserCreated, subject: $client); @@ -108,4 +118,22 @@ class ClientAccounts return $client; } + + /** + * An account cannot be both active and past its expiry date. + * + * Every surface that writes either value asks this before saving, + * because the combination is not a state anybody means: an account + * that looks switched on and refuses every sign-in, until the hourly + * sweep quietly switches it off again. Somebody reactivating an + * expired client has to give them a new date, or none. + */ + public function guardExpiry(?Carbon $expiresAt, bool $active, string $field = 'expires_at'): void + { + if ($active && $expiresAt !== null && $expiresAt->isPast()) { + throw ValidationException::withMessages([ + $field => __('This date has already passed. Choose a later date, or leave it empty for an account that never expires.'), + ]); + } + } } diff --git a/app/Modules/Clients/ClientsServiceProvider.php b/app/Modules/Clients/ClientsServiceProvider.php index da6b07b7..8d13b3c8 100644 --- a/app/Modules/Clients/ClientsServiceProvider.php +++ b/app/Modules/Clients/ClientsServiceProvider.php @@ -36,5 +36,11 @@ class ClientsServiceProvider extends ServiceProvider // was sent to is worse than one that lands a click short. url: fn (array $data): string => route('clients.index', ['search' => $data['clientEmail']]), )); + + if ($this->app->runningInConsole()) { + $this->commands([ + Console\ExpireClientAccountsCommand::class, + ]); + } } } diff --git a/app/Modules/Clients/Console/ExpireClientAccountsCommand.php b/app/Modules/Clients/Console/ExpireClientAccountsCommand.php new file mode 100644 index 00000000..b2286af3 --- /dev/null +++ b/app/Modules/Clients/Console/ExpireClientAccountsCommand.php @@ -0,0 +1,64 @@ +where('type', UserType::Client) + ->where('active', true) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now) + ->get(['id', 'name', 'expires_at']); + + foreach ($due as $client) { + // Conditional on the row still being due, not a plain save(): + // an administrator who moved the date forward between the read + // above and this write has just decided the account should keep + // working, and must not be overruled by a list that is a few + // milliseconds old. + $switchedOff = User::query() + ->whereKey($client->id) + ->where('active', true) + ->where('expires_at', '<=', $now) + ->update(['active' => false]); + + if ($switchedOff === 1) { + $activity->logSystem(Action::ClientExpired, ['name' => $client->name, 'id' => $client->id]); + $expired++; + } + } + + $this->info("Deactivated {$expired} expired client account(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Modules/Clients/Http/Controllers/Api/ClientsController.php b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php index bc9010f1..fcc7bf03 100644 --- a/app/Modules/Clients/Http/Controllers/Api/ClientsController.php +++ b/app/Modules/Clients/Http/Controllers/Api/ClientsController.php @@ -13,6 +13,7 @@ use App\Modules\Clients\ClientAccounts; use App\Modules\Clients\ClientCustomFieldType; use App\Modules\Clients\ClientStorageUsage; use App\Modules\Files\Access\StaffLibraryScope; +use App\Modules\Platform\Localization\DateInput; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Clients\Http\Resources\Api\ClientResource; use App\Modules\Clients\Models\ClientCustomField; @@ -62,6 +63,7 @@ class ClientsController extends Controller private readonly SeatAllowance $seats, private readonly ClientAccounts $clients, private readonly ErasureSchedule $erasure, + private readonly DateInput $dates, ) {} public function index(Request $request): AnonymousResourceCollection @@ -130,11 +132,19 @@ class ClientsController extends Controller // installation may be refused on another. 'password' => ['required', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + // When the account stops working; omit or send null for never. + // A bare date (`2026-12-31`) means the end of that day in the + // token owner's timezone; a full timestamp is used as given. + // Must be in the future. + 'expires_at' => ['nullable', 'string', 'date'], 'custom_field_values' => ['array'], ]); $validated['custom_field_values'] = $this->validateCustomFieldValues($request); + $creator = $request->user(); + assert($creator !== null); + // The invariants — the seat guard, the type, the role, the quota's // "0 means inherit" — live in ClientAccounts, shared with the staff // screens and with the platform control plane. What stays here is @@ -151,11 +161,9 @@ class ClientsController extends Controller // this file is strict_types. storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0), welcome: false, + expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator), ); - $creator = $request->user(); - assert($creator !== null); - // A client-scoped creator would otherwise lose the client they just // made. guardTarget() answers 404 for anything off their roster, so // the record they created is not theirs to open, and @@ -191,6 +199,11 @@ class ClientsController extends Controller 'active' => ['sometimes', 'boolean'], 'password' => ['sometimes', 'nullable', Password::defaults()], 'storage_quota_mb' => ['sometimes', 'nullable', 'integer', 'min:0'], + // Send null to remove the expiry. Read the same way as on + // create. An account cannot be active with a date that has + // passed, so reactivating an expired client needs a new date + // (or null) in the same request. + 'expires_at' => ['sometimes', 'nullable', 'string', 'date'], 'custom_field_values' => ['sometimes', 'array'], ]); @@ -209,6 +222,26 @@ class ClientsController extends Controller $client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0; } + // Asked only when this request touches one of the two values, so a + // PATCH renaming a client whose date passed an hour ago is not + // refused over a field it never sent. Resolved with boolean() for + // the reason given in the web controller. + if (array_key_exists('expires_at', $validated) || array_key_exists('active', $validated)) { + $editor = $request->user(); + assert($editor !== null); + + $expiresAt = array_key_exists('expires_at', $validated) + ? $this->dates->instant($validated['expires_at'], $editor) + : $client->expires_at; + + $this->clients->guardExpiry( + $expiresAt, + active: array_key_exists('active', $validated) ? $request->boolean('active') : $client->active, + ); + + $client->expires_at = $expiresAt; + } + // 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. diff --git a/app/Modules/Clients/Http/Controllers/ClientsController.php b/app/Modules/Clients/Http/Controllers/ClientsController.php index da1ef5f6..cf9e7b7d 100644 --- a/app/Modules/Clients/Http/Controllers/ClientsController.php +++ b/app/Modules/Clients/Http/Controllers/ClientsController.php @@ -12,6 +12,7 @@ use App\Modules\Clients\ClientAccounts; use App\Modules\Clients\ClientCustomFieldType; use App\Modules\Clients\ClientStorageUsage; use App\Modules\Files\Access\StaffLibraryScope; +use App\Modules\Platform\Localization\DateInput; use App\Modules\Platform\Seats\SeatAllowance; use App\Modules\Clients\Models\ClientCustomField; use App\Modules\Clients\Models\ClientCustomFieldValue; @@ -51,6 +52,7 @@ class ClientsController extends Controller private readonly SeatAllowance $seats, private readonly ClientAccounts $clients, private readonly ErasureSchedule $erasure, + private readonly DateInput $dates, ) {} public function index(Request $request): Response @@ -89,6 +91,12 @@ class ClientsController extends Controller 'email' => $client->email, 'active' => $client->active, 'account_requested' => $client->account_requested, + // A calendar day in the viewer's zone, as the edit form shows + // it, plus whether it has passed: the sweep that switches an + // expired account off runs hourly, and the list should not + // call an account that already refuses sign-ins "Active". + 'expires_on' => $this->dates->asShown($client->expires_at, $viewer), + 'expired' => $client->hasExpired(), 'created_at' => $client->created_at?->toIso8601String(), 'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0], ]); @@ -146,8 +154,12 @@ class ClientsController extends Controller 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule], 'password' => ['required', 'confirmed', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + 'expires_at' => ['nullable', 'string', 'date'], ], $this->customFieldRules())); + $creator = $request->user(); + assert($creator !== null); + // The seat guard, the type, the role, and the quota's "0 means // inherit the site default" all live in ClientAccounts, shared // with the API and the control plane. A client created here is @@ -166,11 +178,9 @@ class ClientsController extends Controller // survived to the fleet. storageQuotaMb: (int) ($validated['storage_quota_mb'] ?? 0), welcome: false, + expiresAt: $this->dates->instant($validated['expires_at'] ?? null, $creator), ); - $creator = $request->user(); - assert($creator !== null); - // A client-scoped creator would otherwise lose the client they just // made. guardTarget() answers 404 for anything off their roster, so // the record they created is not theirs to open, and @@ -234,6 +244,10 @@ class ClientsController extends Controller 'account_requested' => $client->account_requested, 'storage_quota_mb' => $client->storage_quota_mb, 'two_factor_enabled' => $client->hasTwoFactorEnabled(), + // update() compares the posted value against this same + // string — see there. + 'expires_at' => $this->dates->asShown($client->expires_at, $request->user()), + 'expired' => $client->hasExpired(), ], // Resolved, not raw — see create() above. 'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(), @@ -260,8 +274,27 @@ class ClientsController extends Controller 'active' => ['required', 'boolean'], 'password' => ['nullable', 'confirmed', Password::defaults()], 'storage_quota_mb' => ['nullable', 'integer', 'min:0'], + 'expires_at' => ['nullable', 'string', 'date'], ], $this->customFieldRules())); + $editor = $request->user(); + assert($editor !== null); + + // The form was rendered with the stored instant read back as a day + // in the editor's zone, and posts that string again with every + // other edit. Only a different string is a new date; re-deriving + // an unchanged one would move the expiry by the difference between + // two editors' zones each time either of them renamed the client. + // Same rule as a file's expiry (FilesController::update). + $postedExpiry = $validated['expires_at'] ?? null; + $expiresAt = $postedExpiry !== $this->dates->asShown($client->expires_at, $editor) + ? $this->dates->instant($postedExpiry, $editor) + : $client->expires_at; + + // Request::boolean(), not the validated value: `boolean` accepts + // "1" and "0" without converting them. + $this->clients->guardExpiry($expiresAt, active: $request->boolean('active')); + $wasActive = $client->active; $passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== ''; @@ -276,6 +309,8 @@ class ClientsController extends Controller 'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0, ]); + $client->expires_at = $expiresAt; + // Activating a pending account through the edit screen counts as // approval and clears the request flag — which is the moment a // seat is spent, so the cap is asked here for the same reason diff --git a/app/Modules/Clients/Http/Resources/Api/ClientResource.php b/app/Modules/Clients/Http/Resources/Api/ClientResource.php index f8148dcf..286d952c 100644 --- a/app/Modules/Clients/Http/Resources/Api/ClientResource.php +++ b/app/Modules/Clients/Http/Resources/Api/ClientResource.php @@ -79,6 +79,10 @@ class ClientResource extends JsonResource // caller needs to see before removing it. The secret and the // recovery codes stay where they are. 'two_factor_enabled' => $this->hasTwoFactorEnabled(), + // Null when the account never expires. Once this passes the + // client can no longer sign in, and `active` turns false within + // the hour. + 'expires_at' => $this->expires_at?->toIso8601String(), 'created_at' => $this->created_at?->toIso8601String(), 'updated_at' => $this->updated_at?->toIso8601String(), ]; diff --git a/app/Modules/Files/Editing/FileExpiry.php b/app/Modules/Files/Editing/FileExpiry.php index c6ddd850..d6264ddf 100644 --- a/app/Modules/Files/Editing/FileExpiry.php +++ b/app/Modules/Files/Editing/FileExpiry.php @@ -6,35 +6,25 @@ namespace App\Modules\Files\Editing; use App\Models\User; use App\Modules\Files\Models\File; -use App\Modules\Platform\Localization\LocalDay; -use App\Modules\Platform\Localization\TimezoneRegistry; +use App\Modules\Platform\Localization\DateInput; use Carbon\Carbon; /** * Reading and writing a file's expiry in the zone of whoever is looking. * - * The stored value is an instant. What a person sets is a calendar day, - * and "the 12th" means the end of the 12th where *they* live — otherwise a - * file asked to expire on the 12th dies partway through the 11th for - * anyone west of Greenwich, and gives anyone east of it most of a day - * nobody promised. + * The rule itself — a posted day means the end of that day where the + * setter lives, and a form posts back what asShown() gave it — is + * DateInput's, shared with a client account's expiry. This stays as the + * file-shaped door onto it. * - * The two halves have to agree, which is the whole reason they sit - * together: a form is rendered with asShown() and posts the same string - * back untouched with every other edit, so a caller compares against - * asShown() to tell "the editor changed the date" from "the editor renamed - * the file and the date came along for the ride". Re-deriving on every - * save instead moves the expiry by the difference between two people's - * zones each time somebody edits anything. - * - * Was three private copies — the staff editor, the API, and now the client + * Was three private copies — the staff editor, the API, and the client * portal — of which the API's was the only one that could read a * timestamp. */ class FileExpiry { public function __construct( - private readonly TimezoneRegistry $timezones, + private readonly DateInput $dates, ) {} /** @@ -43,25 +33,14 @@ class FileExpiry */ public function asShown(File $file, ?User $viewer): ?string { - return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString(); + return $this->dates->asShown($file->expires_at, $viewer); } /** - * The instant a submitted value actually names. - * - * A bare `YYYY-MM-DD` is a calendar day and means the end of it where - * the setter is — what every date input posts. Anything carrying a - * time is an instant somebody named on purpose and is stored as it - * arrives: the API can express a moment, and a date input cannot. + * The instant a submitted value actually names. See DateInput::instant(). */ public function instant(?string $value, ?User $setter): ?Carbon { - if ($value === null) { - return null; - } - - return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 - ? LocalDay::end($value, $this->timezones->resolve($setter)) - : Carbon::parse($value); + return $this->dates->instant($value, $setter); } } diff --git a/app/Modules/Identity/AccountConversion.php b/app/Modules/Identity/AccountConversion.php index 810b7a9f..6f88395e 100644 --- a/app/Modules/Identity/AccountConversion.php +++ b/app/Modules/Identity/AccountConversion.php @@ -260,6 +260,10 @@ class AccountConversion // `ldap_dn` is deliberately kept: it is the record of where // the account came from, and a demotion makes it live again. 'auth_source' => AuthSource::Local, + // Only client accounts carry an expiry, and no staff screen + // shows one. Kept, it would switch a staff member off on a + // date nobody who manages staff can see or change. + 'expires_at' => null, ]); if ($newPassword !== null) { diff --git a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php index 33160330..d7c32e0d 100644 --- a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php +++ b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php @@ -90,6 +90,6 @@ class TwoFactorChallengeController extends Controller $user = User::query()->find($id); - return $user instanceof User && $user->active && $user->hasTwoFactorEnabled() ? $user : null; + return $user instanceof User && $user->maySignIn() && $user->hasTwoFactorEnabled() ? $user : null; } } diff --git a/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php index 517d5dcc..8af00792 100644 --- a/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php +++ b/app/Modules/Identity/Http/Middleware/EnsureAccountIsActive.php @@ -11,8 +11,8 @@ use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; /** - * A deactivated account loses access immediately, not at next login: - * any open session is terminated on the following request. + * A deactivated or expired account loses access immediately, not at next + * login: any open session is terminated on the following request. */ class EnsureAccountIsActive { @@ -20,14 +20,16 @@ class EnsureAccountIsActive { $user = $request->user(); - if ($user !== null && ! $user->active) { + if ($user !== null && ! $user->maySignIn()) { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return WriteSafeRedirect::apply($request, redirect()->route('login')->withErrors([ - 'email' => __('Your account has been deactivated.'), + 'email' => $user->hasExpired() + ? __('Your account has expired.') + : __('Your account has been deactivated.'), ])); } diff --git a/app/Modules/Identity/SignIn.php b/app/Modules/Identity/SignIn.php index 0b3ab64d..1d84ce9d 100644 --- a/app/Modules/Identity/SignIn.php +++ b/app/Modules/Identity/SignIn.php @@ -51,12 +51,19 @@ class SignIn */ public function refusalReason(User $user): ?string { - if ($user->active) { + if ($user->maySignIn()) { return null; } - return $user->account_requested - ? __('Your account request has not been approved yet.') + if (! $user->active && $user->account_requested) { + return __('Your account request has not been approved yet.'); + } + + // Ahead of "deactivated", because the hourly sweep also switches + // an expired account off — and "expired" is the reason its owner + // can do something about, by asking for more time. + return $user->hasExpired() + ? __('Your account has expired.') : __('Your account has been deactivated.'); } diff --git a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php index d603092a..f5200c11 100644 --- a/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php +++ b/app/Modules/Platform/Http/Controllers/SchedulerMonitoringController.php @@ -53,6 +53,7 @@ class SchedulerMonitoringController extends Controller return [ 'projectsend:purge-erasures' => (string) __('Purge erased accounts'), 'projectsend:purge-stale-uploads' => (string) __('Purge stale chunked uploads'), + 'projectsend:expire-client-accounts' => (string) __('Deactivate expired client accounts'), 'projectsend:purge-zip-downloads' => (string) __('Purge zip downloads'), 'projectsend:check-for-updates' => (string) __('Check for updates'), 'projectsend:fetch-news' => (string) __('Fetch dashboard news'), diff --git a/app/Modules/Platform/Localization/DateInput.php b/app/Modules/Platform/Localization/DateInput.php new file mode 100644 index 00000000..3cf0a321 --- /dev/null +++ b/app/Modules/Platform/Localization/DateInput.php @@ -0,0 +1,63 @@ +copy()->setTimezone($this->timezones->resolve($viewer))->toDateString(); + } + + /** + * The instant a submitted value actually names. + * + * A bare `YYYY-MM-DD` is a calendar day and means the end of it where + * the setter is — what every date input posts. Anything carrying a + * time is an instant somebody named on purpose and is stored as it + * arrives: the API can express a moment, and a date input cannot. + */ + public function instant(?string $value, ?User $setter): ?Carbon + { + if ($value === null) { + return null; + } + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 + ? LocalDay::end($value, $this->timezones->resolve($setter)) + : Carbon::parse($value); + } +} diff --git a/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php b/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php new file mode 100644 index 00000000..a243054d --- /dev/null +++ b/database/migrations/2026_09_13_090000_add_expires_at_to_users_table.php @@ -0,0 +1,29 @@ +timestamp('expires_at')->nullable()->after('erase_after')->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropIndex(['expires_at']); + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3d44f966..5906f69c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -808,6 +808,14 @@ ], "minimum": 0 }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the account stops working; omit or send null for never.\nA bare date (`2026-12-31`) means the end of that day in the\ntoken owner's timezone; a full timestamp is used as given.\nMust be in the future." + }, "custom_field_values": { "type": "array", "items": { @@ -945,6 +953,14 @@ ], "minimum": 0 }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Send null to remove the expiry. Read the same way as on\ncreate. An account cannot be active with a date that has\npassed, so reactivating an expired client needs a new date\n(or null) in the same request." + }, "custom_field_values": { "type": "array", "items": { @@ -3534,6 +3550,7 @@ "client.invitation_redeemed", "client.invitation_revoked", "client.invitation_resent", + "client.expired", "file.uploaded", "file.updated", "file.deleted", @@ -3730,6 +3747,13 @@ "type": "boolean", "description": "Whether, not what: the state of the second factor is what a\ncaller needs to see before removing it. The secret and the\nrecovery codes stay where they are." }, + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "Null when the account never expires. Once this passes the\nclient can no longer sign in, and `active` turns false within\nthe hour." + }, "created_at": { "type": [ "string", @@ -3810,6 +3834,7 @@ "active", "account_requested", "two_factor_enabled", + "expires_at", "created_at", "updated_at", "storage", diff --git a/resources/js/components/client-expiry-field.tsx b/resources/js/components/client-expiry-field.tsx new file mode 100644 index 00000000..c001070b --- /dev/null +++ b/resources/js/components/client-expiry-field.tsx @@ -0,0 +1,45 @@ +import InputError from '@/components/input-error'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useFormatDate } from '@/hooks/use-format-date'; +import { useTranslation } from '@/hooks/use-translation'; + +/** + * The date a client account stops working, shared by the create and edit + * screens. The value is a bare `YYYY-MM-DD`: the server reads it as the end + * of that day in the editor's timezone (DateInput::instant()). + */ +export function ClientExpiryField({ + value, + onChange, + error, + expired = false, +}: { + value: string; + onChange: (value: string) => void; + error?: string; + /** The stored date has already passed — edit screen only. */ + expired?: boolean; +}) { + const { t } = useTranslation(); + const { calendarDate } = useFormatDate(); + + return ( +
+ + onChange(e.target.value)} /> + {expired && value !== '' ? ( +

+ {t('This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.', { + date: calendarDate(value), + })} +

+ ) : ( +

+ {t('After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.')} +

+ )} + +
+ ); +} diff --git a/resources/js/pages/clients/create.tsx b/resources/js/pages/clients/create.tsx index 1fff8d7e..bdfd611d 100644 --- a/resources/js/pages/clients/create.tsx +++ b/resources/js/pages/clients/create.tsx @@ -3,6 +3,7 @@ import { Head, useForm } from '@inertiajs/react'; import { FormEventHandler } from 'react'; import { ClientCustomFieldsSection, type CustomFieldDefinition } from '@/components/client-custom-fields-section'; +import { ClientExpiryField } from '@/components/client-expiry-field'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -19,6 +20,7 @@ interface ClientFormData { password: string; password_confirmation: string; storage_quota_mb: string; + expires_at: string; custom_field_values: Record; } @@ -43,6 +45,7 @@ export default function ClientsCreate({ custom_fields, default_storage_quota_mb // Empty = inherit the site default rather than baking in today's // numeric value — see the field's own hint text below. storage_quota_mb: '', + expires_at: '', custom_field_values: Object.fromEntries(custom_fields.map((field) => [field.id, field.type === 'checkbox' ? '0' : ''])), }); @@ -125,6 +128,8 @@ export default function ClientsCreate({ custom_fields, default_storage_quota_mb + setData('expires_at', value)} error={errors.expires_at} /> + ; } @@ -73,6 +77,9 @@ export default function ClientsEdit({ // input shows the resolved default as a placeholder instead of a // number the admin has to know to type themselves. storage_quota_mb: client.storage_quota_mb > 0 ? String(client.storage_quota_mb) : '', + // Posted back exactly as received when untouched — the server + // compares against this string to tell a new date from an old one. + expires_at: client.expires_at ?? '', custom_field_values: Object.fromEntries( custom_fields.map((field) => [field.id, custom_field_values[field.id] ?? (field.type === 'checkbox' ? '0' : '')]), ), @@ -155,6 +162,13 @@ export default function ClientsEdit({ )} + setData('expires_at', value)} + error={errors.expires_at} + expired={client.expired && data.expires_at === (client.expires_at ?? '')} + /> +
().props; const can = (permission: string) => auth.permissions.includes(permission); @@ -58,7 +62,20 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca return {t('Pending approval')}; } - return {client.active ? t('Active') : t('Inactive')}; + // Ahead of the flag: the sweep that switches an expired account off + // runs hourly, and in between the account already refuses sign-ins. + if (client.expired) { + return {t('Expired')}; + } + + return ( +
+ {client.active ? t('Active') : t('Inactive')} + {client.active && client.expires_on && ( + {t('Until :date', { date: calendarDate(client.expires_on) })} + )} +
+ ); }; return ( diff --git a/routes/console.php b/routes/console.php index 709b596e..8a83a213 100644 --- a/routes/console.php +++ b/routes/console.php @@ -14,6 +14,10 @@ Schedule::command('projectsend:purge-erasures')->daily(); // and the sweep noticing is a gap where somebody cannot upload. Daily made // that gap up to two days wide. Schedule::command('projectsend:purge-stale-uploads')->hourly(); +// Hourly for the same reason: an expired client still marked active holds +// a seat on a managed plan. Access itself does not wait for this — see +// User::maySignIn(). +Schedule::command('projectsend:expire-client-accounts')->hourly(); Schedule::command('projectsend:purge-zip-downloads')->daily(); Schedule::command('projectsend:check-for-updates')->daily(); Schedule::command('projectsend:fetch-news')->daily(); diff --git a/tests/Feature/Clients/ClientExpiryTest.php b/tests/Feature/Clients/ClientExpiryTest.php new file mode 100644 index 00000000..c170474d --- /dev/null +++ b/tests/Feature/Clients/ClientExpiryTest.php @@ -0,0 +1,308 @@ +admin = User::factory()->create(); +}); + +/* +|-------------------------------------------------------------------------- +| Access ends when the date passes, without waiting for the sweep +|-------------------------------------------------------------------------- +*/ + +test('a client whose date has passed cannot sign in, even while still marked active', function () { + $client = User::factory()->client()->create(['email' => 'late@example.com']); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + expect($client->refresh()->active)->toBeTrue(); + + $this->post('/login', ['email' => 'late@example.com', 'password' => 'password']) + ->assertSessionHasErrors(['email' => 'Your account has expired.']); + + $this->assertGuest(); +}); + +test('a client whose date is still ahead signs in normally', function () { + $client = User::factory()->client()->create(['email' => 'early@example.com']); + $client->forceFill(['expires_at' => now()->addDay()])->save(); + + $this->post('/login', ['email' => 'early@example.com', 'password' => 'password']) + ->assertSessionHasNoErrors(); + + $this->assertAuthenticatedAs($client); +}); + +test('an open session ends on the first request after the date passes', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addHour()])->save(); + + $this->actingAs($client)->get('/my-files')->assertOk(); + + $this->travel(2)->hours(); + + $this->actingAs($client)->get('/my-files') + ->assertRedirect(route('login')) + ->assertSessionHasErrors(['email' => 'Your account has expired.']); +}); + +test('an expired account\'s API token stops working', function () { + // Only clients are given a date, and tokens are staff-only — so this + // is the gate proven on the one account type that can hold a token. + $staff = User::factory()->create(); + $token = $staff->createToken('t', [Permission::ManageClients->value])->plainTextToken; + $staff->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->withToken($token)->getJson('/api/v1/clients')->assertUnauthorized(); +}); + +/* +|-------------------------------------------------------------------------- +| The hourly sweep +|-------------------------------------------------------------------------- +*/ + +test('the sweep deactivates expired clients and nobody else', function () { + $expired = User::factory()->client()->create(['name' => 'Gone']); + $expired->forceFill(['expires_at' => now()->subMinute()])->save(); + + $future = User::factory()->client()->create(); + $future->forceFill(['expires_at' => now()->addDay()])->save(); + + $never = User::factory()->client()->create(); + + // Only client accounts are swept; a staff row carrying a stray date is + // refused at the door by maySignIn(), but not switched off here. + $staff = User::factory()->create(); + $staff->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->artisan('projectsend:expire-client-accounts')->assertSuccessful(); + + expect($expired->refresh()->active)->toBeFalse() + ->and($future->refresh()->active)->toBeTrue() + ->and($never->refresh()->active)->toBeTrue() + ->and($staff->refresh()->active)->toBeTrue(); + + $entries = ActivityLog::query()->where('action', Action::ClientExpired)->get(); + expect($entries)->toHaveCount(1) + ->and($entries->first()->context)->toBe(['name' => 'Gone', 'id' => $expired->id]); +}); + +test('the sweep does not log a client that is already inactive', function () { + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDay()])->save(); + + $this->artisan('projectsend:expire-client-accounts')->assertSuccessful(); + + expect(ActivityLog::query()->where('action', Action::ClientExpired)->count())->toBe(0); +}); + +/* +|-------------------------------------------------------------------------- +| Staff screens +|-------------------------------------------------------------------------- +*/ + +test('a date set on the create screen means the end of that day where the creator is', function () { + $this->admin->forceFill(['timezone' => 'America/Argentina/Buenos_Aires'])->save(); + + $this->actingAs($this->admin)->post('/clients', [ + 'name' => 'Seasonal', + 'email' => 'seasonal@example.com', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + 'expires_at' => '2030-01-10', + ])->assertSessionHasNoErrors(); + + $client = User::query()->where('email', 'seasonal@example.com')->sole(); + + // 23:59:59 in Buenos Aires (UTC-3) is 02:59:59 the next morning in UTC. + expect($client->expires_at?->utc()->toDateTimeString())->toBe('2030-01-11 02:59:59'); +}); + +test('a client cannot be created already expired', function () { + $this->actingAs($this->admin)->post('/clients', [ + 'name' => 'Too Late', + 'email' => 'too-late@example.com', + 'password' => 'super-secret-password', + 'password_confirmation' => 'super-secret-password', + 'expires_at' => now()->subDays(2)->toDateString(), + ])->assertSessionHasErrors('expires_at'); + + expect(User::query()->where('email', 'too-late@example.com')->exists())->toBeFalse(); +}); + +test('saving the edit screen without touching the date leaves the stored instant alone', function () { + // Set by somebody in Tokyo, then re-saved by somebody in Buenos Aires + // who only changed the name. Re-deriving the posted day would move the + // expiry by twelve hours. + $client = User::factory()->client()->create(); + $stored = Carbon::parse('2030-06-15 14:59:59', 'UTC'); + $client->forceFill(['expires_at' => $stored])->save(); + + $this->admin->forceFill(['timezone' => 'America/Argentina/Buenos_Aires'])->save(); + + $shown = null; + $this->actingAs($this->admin)->get("/clients/{$client->id}")->assertInertia( + function (AssertableInertia $page) use (&$shown) { + $shown = $page->toArray()['props']['client']['expires_at']; + }, + ); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => 'Renamed', + 'email' => $client->email, + 'active' => true, + 'expires_at' => $shown, + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->expires_at?->utc()->toDateTimeString())->toBe('2030-06-15 14:59:59') + ->and($client->name)->toBe('Renamed'); +}); + +test('an expired client cannot be switched back on without a new date', function () { + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDays(3)])->save(); + + $shown = now()->subDays(3)->toDateString(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => $shown, + ])->assertSessionHasErrors('expires_at'); + + expect($client->refresh()->active)->toBeFalse(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => now()->addMonth()->toDateString(), + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->active)->toBeTrue() + ->and($client->hasExpired())->toBeFalse(); +}); + +test('clearing the date removes the expiry', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addWeek()])->save(); + + $this->actingAs($this->admin)->patch("/clients/{$client->id}", [ + 'name' => $client->name, + 'email' => $client->email, + 'active' => true, + 'expires_at' => '', + ])->assertSessionHasNoErrors(); + + expect($client->refresh()->expires_at)->toBeNull(); +}); + +test('the list calls an expired client expired before the sweep has run', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->actingAs($this->admin)->get('/clients')->assertInertia( + fn (AssertableInertia $page) => $page + ->where('clients.0.expired', true) + ->where('clients.0.active', true), + ); +}); + +test('converting a client to staff drops the expiry', function () { + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->addWeek()])->save(); + + $roleId = (int) \App\Modules\Identity\Models\Role::query()->where('name', SystemRole::AccountManager->value)->value('id'); + + app(AccountConversion::class)->toStaff($client, $roleId, [], 'a-brand-new-password-123'); + + expect($client->refresh()->expires_at)->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| API +|-------------------------------------------------------------------------- +*/ + +function expiryToken(User $admin): string +{ + return $admin->createToken('t', [ + Permission::ManageClients->value, + Permission::CreateClients->value, + Permission::EditClients->value, + ])->plainTextToken; +} + +test('the API creates, shows and clears an expiry', function () { + $token = expiryToken($this->admin); + + $id = $this->withToken($token)->postJson('/api/v1/clients', [ + 'name' => 'Api Client', + 'email' => 'api-expiry@example.com', + 'password' => 'super-secret-password', + 'expires_at' => '2030-03-01T12:00:00Z', + ])->assertCreated() + ->assertJsonPath('data.expires_at', '2030-03-01T12:00:00+00:00') + ->json('data.id'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$id}", ['expires_at' => null]) + ->assertOk() + ->assertJsonPath('data.expires_at', null); +}); + +test('the API refuses to reactivate an expired client without a new date', function () { + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(['active' => false]); + $client->forceFill(['expires_at' => now()->subDay()])->save(); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['active' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('expires_at'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['active' => true, 'expires_at' => null]) + ->assertOk() + ->assertJsonPath('data.active', true); +}); + +test('an API rename is not refused over a date it did not send', function () { + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(); + $client->forceFill(['expires_at' => now()->subMinute()])->save(); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['name' => 'Just A Rename']) + ->assertOk() + ->assertJsonPath('data.name', 'Just A Rename'); +}); + +test('a JSON number where a date belongs is a validation error, not a 500', function () { + // `date` accepts some numbers (20301231 parses as a calendar day) + // and does not convert them, and DateInput::instant() is strictly + // typed to a string. A form post is always text; a JSON body is not. + $token = expiryToken($this->admin); + $client = User::factory()->client()->create(); + + $this->withToken($token)->postJson('/api/v1/clients', [ + 'name' => 'Numeric', + 'email' => 'numeric@example.com', + 'password' => 'super-secret-password', + 'expires_at' => 20301231, + ])->assertUnprocessable()->assertJsonValidationErrors('expires_at'); + + $this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['expires_at' => 20301231]) + ->assertUnprocessable() + ->assertJsonValidationErrors('expires_at'); +}); diff --git a/tests/Feature/Platform/SchedulerMonitoringTest.php b/tests/Feature/Platform/SchedulerMonitoringTest.php index fb80228b..6a14a231 100644 --- a/tests/Feature/Platform/SchedulerMonitoringTest.php +++ b/tests/Feature/Platform/SchedulerMonitoringTest.php @@ -45,7 +45,7 @@ test('the scheduler page lists every known command, flagging ones that have neve ]); $response = $this->actingAs($this->admin)->get('/system/settings/scheduler'); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 11)); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 12)); $tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command'); expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success') From 495f3ae471d033f854375bcd19e24fd154635787 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sun, 13 Sep 2026 15:05:40 -0300 Subject: [PATCH 2/6] Let each role, and each person, choose where they land after signing in A role now has a start page: the dashboard, files, upload, groups, clients or the activity log (the last two for staff only). Anyone can override their role's choice in their profile. The administrator role takes a start page too, while everything else about it stays locked. A choice is only used if the account can open that page now. Otherwise the next one down is tried, ending at the dashboard, so a permission removed later never lands somebody on a 403. A role cannot be saved with a start page its own permissions block. A link followed before signing in still wins, and a waiting getting-started or what's-new page still goes first. Applies to password, two-factor and provider sign-ins, and to the site root for someone already signed in. StartPageTest opens every page for real, with and without its permission, so the enum cannot drift from the routes. Requested by @Zodiac1978 in #1777. --- .../Auth/AuthenticatedSessionController.php | 8 +- .../Settings/ProfileController.php | 9 + .../Settings/ProfileUpdateRequest.php | 8 + app/Models/User.php | 4 + .../Http/Controllers/RolesController.php | 72 +++- .../Controllers/SocialLoginController.php | 4 +- .../TwoFactorChallengeController.php | 4 +- app/Modules/Identity/Models/Role.php | 1 + app/Modules/Identity/StartPage.php | 110 ++++++ app/Modules/Identity/StartPages.php | 119 +++++++ ...0000_add_start_page_to_roles_and_users.php | 38 +++ resources/js/components/start-page-select.tsx | 70 ++++ resources/js/pages/roles/create.tsx | 17 +- resources/js/pages/roles/edit.tsx | 57 +++- resources/js/pages/settings/profile.tsx | 18 + routes/web.php | 10 +- tests/Feature/Auth/SocialLoginTest.php | 13 + tests/Feature/Identity/StartPageTest.php | 314 ++++++++++++++++++ 18 files changed, 859 insertions(+), 17 deletions(-) create mode 100644 app/Modules/Identity/StartPage.php create mode 100644 app/Modules/Identity/StartPages.php create mode 100644 database/migrations/2026_09_13_100000_add_start_page_to_roles_and_users.php create mode 100644 resources/js/components/start-page-select.tsx create mode 100644 tests/Feature/Identity/StartPageTest.php diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index f86ce468..2824e7b8 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Modules\Identity\StartPages; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; use Illuminate\Http\RedirectResponse; @@ -30,7 +31,7 @@ class AuthenticatedSessionController extends Controller /** * Handle an incoming authentication request. */ - public function store(LoginRequest $request): RedirectResponse + public function store(LoginRequest $request, StartPages $startPages): RedirectResponse { if ($request->authenticate()) { return redirect()->route('two-factor.challenge'); @@ -38,7 +39,10 @@ class AuthenticatedSessionController extends Controller $request->session()->regenerate(); - return redirect()->intended(route('dashboard', absolute: false)); + $user = $request->user(); + assert($user !== null); + + return redirect()->intended($startPages->pathFor($user)); } /** diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 01a64b3a..8d9bc4e8 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -10,6 +10,8 @@ use App\Modules\Clients\ClientFieldContext; use App\Modules\Clients\ClientPortalCustomFields; use App\Modules\Identity\Erasure\ErasureSchedule; use App\Modules\Identity\StaffAccounts; +use App\Modules\Identity\StartPage; +use App\Modules\Identity\StartPages; use App\Modules\Platform\Localization\TimezoneRegistry; use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Settings; @@ -26,6 +28,7 @@ class ProfileController extends Controller private readonly ClientPortalCustomFields $customFields, private readonly TimezoneRegistry $timezones, private readonly StaffAccounts $accounts, + private readonly StartPages $startPages, ) {} /** @@ -44,6 +47,12 @@ class ProfileController extends Controller // browser was detected as, not something they ever chose. 'timezone' => $this->timezones->resolve($user), 'timezones' => $this->timezones->options(), + // Stored, not resolved: an empty choice means "follow my role", + // and the form has to be able to say that rather than show the + // role's page as if the person had picked it. + 'start_page' => $user->start_page, + 'start_page_options' => $this->startPages->personalOptions($user), + 'role_start_page' => (string) __(($this->startPages->roleDefault($user) ?? StartPage::Dashboard)->label($user->type)), 'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [], 'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [], ]); diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 31e13806..df074c97 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Modules\Clients\ClientFieldContext; use App\Modules\Clients\ClientPortalCustomFields; use App\Modules\Identity\AuthSource; +use App\Modules\Identity\StartPages; use App\Support\Rules; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -66,6 +67,13 @@ class ProfileUpdateRequest extends FormRequest $user = $this->user(); + // Only the pages this person can open right now. `sometimes` for + // the same reason as timezone; empty clears the choice and follows + // the role again. + $rules['start_page'] = ['sometimes', 'nullable', 'string', Rule::in( + $user === null ? [] : array_column(app(StartPages::class)->personalOptions($user), 'value'), + )]; + // An account whose credentials live in a directory or at an // identity provider holds a local password nobody knows — see // LdapProvisioner, which stores Str::password(64) exactly so it diff --git a/app/Models/User.php b/app/Models/User.php index 5b475699..ebaac6a5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,6 +29,7 @@ use Laravel\Sanctum\HasApiTokens; * @property bool $account_requested * @property string|null $locale * @property string|null $timezone + * @property string|null $start_page a StartPage value; see StartPages * @property int|null $dashboard_columns * @property int $storage_quota_mb * @property Carbon|null $erase_after @@ -55,6 +56,9 @@ class User extends Authenticatable implements HasLocalePreference 'password', 'locale', 'timezone', + // A personal preference, like timezone: the profile form fills it + // from its own validated request. See StartPages. + 'start_page', 'dashboard_columns', 'storage_quota_mb', ]; diff --git a/app/Modules/Identity/Http/Controllers/RolesController.php b/app/Modules/Identity/Http/Controllers/RolesController.php index 395784fb..598a3c34 100644 --- a/app/Modules/Identity/Http/Controllers/RolesController.php +++ b/app/Modules/Identity/Http/Controllers/RolesController.php @@ -13,6 +13,9 @@ use App\Modules\Identity\Permissions\Permission; use App\Modules\Identity\Permissions\PermissionCategory; use App\Modules\Identity\Permissions\PermissionChecker; use App\Modules\Identity\Permissions\SystemRole; +use App\Modules\Identity\StartPage; +use App\Modules\Identity\StartPages; +use App\Modules\Identity\UserType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -31,6 +34,7 @@ class RolesController extends Controller public function __construct( private readonly ActivityLogger $activity, private readonly PermissionChecker $permissions, + private readonly StartPages $startPages, ) {} public function index(Request $request): Response @@ -75,6 +79,9 @@ class RolesController extends Controller { return Inertia::render('roles/create', [ 'catalog' => $this->catalog(), + // A role made here is always a staff role: the Client role is + // built in, and there is no second one. + 'start_page_options' => $this->startPages->roleOptions(UserType::Staff), ]); } @@ -85,9 +92,11 @@ class RolesController extends Controller 'client_scoped' => ['boolean'], 'permissions' => ['array'], 'permissions.*' => [Rule::enum(Permission::class)], + 'start_page' => $this->startPageRules(UserType::Staff), ]); $this->guardGrantablePermissions($request, $validated['permissions'] ?? []); + $this->guardStartPage($validated['start_page'] ?? null, UserType::Staff, $validated['permissions'] ?? []); $clientScoped = $request->boolean('client_scoped'); $this->guardScopeRemoval($request, removesScope: ! $clientScoped); @@ -95,6 +104,7 @@ class RolesController extends Controller $role = Role::query()->create([ 'name' => $validated['name'], 'client_scoped' => $clientScoped, + 'start_page' => $validated['start_page'] ?? null, ]); $this->syncPermissions($role, $validated['permissions'] ?? []); @@ -115,17 +125,35 @@ class RolesController extends Controller 'client_scoped' => $role->client_scoped, 'users_count' => $role->users()->count(), 'permissions' => $role->permissions()->pluck('permission')->all(), + 'start_page' => $role->start_page, ], 'catalog' => $this->catalog(), + 'start_page_options' => $this->startPages->roleOptions(StartPages::typeOf($role)), ]); } public function update(Request $request, Role $role): RedirectResponse { + $type = StartPages::typeOf($role); + + // The one thing about the administrator role that is not + // authority: where its members land. Everything else stays locked, + // and a request carrying anything more is refused rather than + // quietly half-applied. if ($role->is_administrator) { - throw ValidationException::withMessages([ - 'permissions' => __('The administrator role always has every permission and cannot be edited.'), - ]); + if ($request->hasAny(['name', 'client_scoped', 'permissions'])) { + throw ValidationException::withMessages([ + 'permissions' => __('The administrator role always has every permission and cannot be edited.'), + ]); + } + + $validated = $request->validate(['start_page' => $this->startPageRules($type)]); + + $role->update(['start_page' => $validated['start_page'] ?? null]); + + $this->activity->log(Action::RoleUpdated, subject: $role); + + return back()->with('success', __('Role updated.')); } $validated = $request->validate([ @@ -133,8 +161,12 @@ class RolesController extends Controller 'client_scoped' => ['boolean'], 'permissions' => ['array'], 'permissions.*' => [Rule::enum(Permission::class)], + 'start_page' => $this->startPageRules($type), ]); + $this->guardStartPage($validated['start_page'] ?? null, $type, $validated['permissions'] ?? []); + $role->start_page = $validated['start_page'] ?? null; + // Built-in roles have fixed names and a fixed scope flag; only their // permission set is editable. Custom roles can change name + scope. if (! $role->is_system) { @@ -158,6 +190,10 @@ class RolesController extends Controller $this->syncPermissions($role, $newPermissions); + // Built-in roles skip the update() above, so the start page is + // saved here for every role alike. + $role->save(); + $this->activity->log(Action::RoleUpdated, subject: $role, context: [ 'permissions_added' => array_values(array_diff($newPermissions, $oldPermissions)), 'permissions_removed' => array_values(array_diff($oldPermissions, $newPermissions)), @@ -258,6 +294,36 @@ class RolesController extends Controller ]); } + /** + * @return list + */ + private function startPageRules(UserType $type): array + { + return ['nullable', 'string', Rule::in(array_map(fn (StartPage $page): string => $page->value, StartPage::optionsFor($type)))]; + } + + /** + * A role cannot send its members to a page its own permissions keep + * them out of. Checked against the permissions saved in the same + * request, so granting "Manage clients" and choosing Clients as the + * start page is one save, not two. StartPages would fall back to the + * dashboard anyway; this says so at the moment it can be fixed. + * + * @param list $permissions + */ + private function guardStartPage(?string $value, UserType $type, array $permissions): void + { + $required = $value === null ? null : StartPage::tryFrom($value)?->requiredPermission($type); + + if ($required !== null && ! in_array($required->value, $permissions, true)) { + throw ValidationException::withMessages([ + 'start_page' => __('This role cannot open that page. Give it the ":permission" permission, or choose another start page.', [ + 'permission' => __($required->label()), + ]), + ]); + } + } + /** * @param list $permissions */ diff --git a/app/Modules/Identity/Http/Controllers/SocialLoginController.php b/app/Modules/Identity/Http/Controllers/SocialLoginController.php index a30fd227..055083cf 100644 --- a/app/Modules/Identity/Http/Controllers/SocialLoginController.php +++ b/app/Modules/Identity/Http/Controllers/SocialLoginController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Controller; use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLogger; use App\Modules\Identity\SignIn; +use App\Modules\Identity\StartPages; use App\Modules\Identity\Social\SocialAuthenticator; use App\Modules\Identity\Social\SocialGateway; use App\Modules\Identity\Social\SocialIdentity; @@ -40,6 +41,7 @@ class SocialLoginController extends Controller private readonly SocialAuthenticator $authenticator, private readonly SignIn $signIn, private readonly ActivityLogger $activity, + private readonly StartPages $startPages, ) {} /** @@ -128,7 +130,7 @@ class SocialLoginController extends Controller $request->session()->regenerate(); - return redirect()->intended(route('dashboard', absolute: false)); + return redirect()->intended($this->startPages->pathFor($resolution->user)); } private function begin(Request $request, string $provider, string $intent): Response diff --git a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php index d7c32e0d..6a4047e4 100644 --- a/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php +++ b/app/Modules/Identity/Http/Controllers/TwoFactorChallengeController.php @@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Controllers; use App\Http\Controllers\Controller; use App\Models\User; use App\Modules\Identity\SignIn; +use App\Modules\Identity\StartPages; use App\Modules\Identity\TwoFactor\TwoFactorService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -25,6 +26,7 @@ class TwoFactorChallengeController extends Controller { public function __construct( private readonly TwoFactorService $twoFactor, + private readonly StartPages $startPages, ) {} public function create(Request $request): Response|RedirectResponse @@ -77,7 +79,7 @@ class TwoFactorChallengeController extends Controller $request->session()->forget(SignIn::TWO_FACTOR_ID); $request->session()->regenerate(); - return redirect()->intended(route('dashboard', absolute: false)); + return redirect()->intended($this->startPages->pathFor($user)); } private function pendingUser(Request $request): ?User diff --git a/app/Modules/Identity/Models/Role.php b/app/Modules/Identity/Models/Role.php index 776b249b..9f87acb5 100644 --- a/app/Modules/Identity/Models/Role.php +++ b/app/Modules/Identity/Models/Role.php @@ -15,6 +15,7 @@ use RuntimeException; * @property bool $is_system * @property bool $is_administrator * @property bool $client_scoped + * @property string|null $start_page a StartPage value; see StartPages * @property-read int $users_count * @property-read int $permissions_count */ diff --git a/app/Modules/Identity/StartPage.php b/app/Modules/Identity/StartPage.php new file mode 100644 index 00000000..110b1369 --- /dev/null +++ b/app/Modules/Identity/StartPage.php @@ -0,0 +1,110 @@ + + */ + public static function optionsFor(UserType $type): array + { + return array_values(array_filter(self::cases(), fn (self $page): bool => $page->appliesTo($type))); + } + + public function appliesTo(UserType $type): bool + { + return match ($this) { + self::Clients, self::Activity => $type === UserType::Staff, + default => true, + }; + } + + /** + * What an account of this type needs to open the page, or null when + * every account of that type can. + */ + public function requiredPermission(UserType $type): ?Permission + { + $staff = $type === UserType::Staff; + + return match ($this) { + self::Dashboard => null, + self::Files => $staff ? Permission::Upload : null, + self::Upload => Permission::Upload, + self::Groups => $staff ? Permission::ManageGroups : null, + self::Clients => Permission::ManageClients, + self::Activity => Permission::ViewActionsLog, + }; + } + + public function routeName(UserType $type): string + { + $staff = $type === UserType::Staff; + + return match ($this) { + self::Dashboard => 'dashboard', + self::Files => $staff ? 'files.index' : 'my-files.index', + self::Upload => $staff ? 'files.create' : 'my-files.upload.create', + self::Groups => $staff ? 'groups.index' : 'my-groups.index', + self::Clients => 'clients.index', + self::Activity => 'activity.index', + }; + } + + /** + * English, and the translation key: the same words the navigation + * already uses for each page, so they are already translated. + */ + public function label(UserType $type): string + { + $staff = $type === UserType::Staff; + + return match ($this) { + self::Dashboard => 'Dashboard', + self::Files => $staff ? 'Files' : 'My files', + self::Upload => 'Upload files', + self::Groups => $staff ? 'Groups' : 'My groups', + self::Clients => 'Clients', + self::Activity => 'Activity log', + }; + } + + public function isReachableBy(User $user): bool + { + if (! $this->appliesTo($user->type)) { + return false; + } + + $permission = $this->requiredPermission($user->type); + + return $permission === null || $user->can($permission->value); + } +} diff --git a/app/Modules/Identity/StartPages.php b/app/Modules/Identity/StartPages.php new file mode 100644 index 00000000..c4a0d1e8 --- /dev/null +++ b/app/Modules/Identity/StartPages.php @@ -0,0 +1,119 @@ +intended(). + */ + public function pathFor(User $user): string + { + $dashboard = route('dashboard', absolute: false); + + if ($this->installation->isWaitingFor($user) || $this->update->isWaitingFor($user)) { + return $dashboard; + } + + $page = $this->resolve($user); + + return $page === null ? $dashboard : route($page->routeName($user->type), absolute: false); + } + + /** + * The start page in force for this account, or null for the dashboard. + */ + public function resolve(User $user): ?StartPage + { + foreach ([$user->start_page, $user->role?->start_page] as $value) { + $page = is_string($value) ? StartPage::tryFrom($value) : null; + + if ($page !== null && $page->isReachableBy($user)) { + return $page; + } + } + + return null; + } + + /** + * The role's default as it applies to this account: null when the role + * names none, or names one this account cannot open. + */ + public function roleDefault(User $user): ?StartPage + { + $value = $user->role?->start_page; + $page = is_string($value) ? StartPage::tryFrom($value) : null; + + return $page !== null && $page->isReachableBy($user) ? $page : null; + } + + /** + * What a person may pick for themselves: the pages they can open. + * + * @return list + */ + public function personalOptions(User $user): array + { + return array_values(array_map( + fn (StartPage $page): array => ['value' => $page->value, 'label' => (string) __($page->label($user->type))], + array_filter(StartPage::optionsFor($user->type), fn (StartPage $page): bool => $page->isReachableBy($user)), + )); + } + + /** + * What a role may name as its default. Every page its kind of account + * can have; RolesController checks the choice against the permissions + * saved with it. + * + * @return list + */ + public function roleOptions(UserType $type): array + { + return array_map( + fn (StartPage $page): array => [ + 'value' => $page->value, + 'label' => (string) __($page->label($type)), + 'permission' => $page->requiredPermission($type)?->value, + ], + StartPage::optionsFor($type), + ); + } + + /** + * The kind of account a role is for. The Client system role holds + * clients; every other role, built-in or custom, holds staff. + */ + public static function typeOf(Role $role): UserType + { + return $role->name === Permissions\SystemRole::Client->value && $role->is_system + ? UserType::Client + : UserType::Staff; + } +} diff --git a/database/migrations/2026_09_13_100000_add_start_page_to_roles_and_users.php b/database/migrations/2026_09_13_100000_add_start_page_to_roles_and_users.php new file mode 100644 index 00000000..f6475579 --- /dev/null +++ b/database/migrations/2026_09_13_100000_add_start_page_to_roles_and_users.php @@ -0,0 +1,38 @@ +string('start_page', 32)->nullable()->after('client_scoped'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->string('start_page', 32)->nullable()->after('timezone'); + }); + } + + public function down(): void + { + Schema::table('roles', function (Blueprint $table) { + $table->dropColumn('start_page'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('start_page'); + }); + } +}; diff --git a/resources/js/components/start-page-select.tsx b/resources/js/components/start-page-select.tsx new file mode 100644 index 00000000..cd41e56c --- /dev/null +++ b/resources/js/components/start-page-select.tsx @@ -0,0 +1,70 @@ +import InputError from '@/components/input-error'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useTranslation } from '@/hooks/use-translation'; + +export interface StartPageOption { + value: string; + /** Already translated by the server. */ + label: string; + /** The permission a role needs for this page — role screens only. */ + permission?: string | null; +} + +// Radix Select cannot hold an empty value, so "no choice of my own" needs +// a stand-in. Converted back to null on the way out. +const INHERIT = '__inherit'; + +/** + * Where somebody lands after signing in. Used on the role screens (the + * default for everyone in the role) and on the profile (a person's own + * choice). See StartPages on the server for how the two combine. + */ +export function StartPageSelect({ + value, + onChange, + options, + error, + description, + inheritLabel, + grantedPermissions, +}: { + value: string | null; + onChange: (value: string | null) => void; + options: StartPageOption[]; + error?: string; + description: string; + /** When set, offers "no choice of my own" under this label; otherwise null shows as the dashboard. */ + inheritLabel?: string; + /** On a role screen: the permissions being saved, so pages the role could not open are disabled. */ + grantedPermissions?: string[]; +}) { + const { t } = useTranslation(); + + const selected = value ?? (inheritLabel ? INHERIT : 'dashboard'); + + return ( +
+ + +

{description}

+ +
+ ); +} diff --git a/resources/js/pages/roles/create.tsx b/resources/js/pages/roles/create.tsx index d40577bd..47b9736f 100644 --- a/resources/js/pages/roles/create.tsx +++ b/resources/js/pages/roles/create.tsx @@ -4,22 +4,25 @@ import { FormEventHandler } from 'react'; import Heading from '@/components/heading'; import { PermissionCatalogCategory, RoleForm } from '@/components/role-form'; +import { StartPageSelect, type StartPageOption } from '@/components/start-page-select'; import { Button } from '@/components/ui/button'; import { useTranslation } from '@/hooks/use-translation'; import AppLayout from '@/layouts/app-layout'; interface RolesCreateProps { catalog: PermissionCatalogCategory[]; + start_page_options: StartPageOption[]; } interface RoleFormData { - [key: string]: string | string[] | boolean; + [key: string]: string | string[] | boolean | null; name: string; client_scoped: boolean; permissions: string[]; + start_page: string | null; } -export default function RolesCreate({ catalog }: RolesCreateProps) { +export default function RolesCreate({ catalog, start_page_options }: RolesCreateProps) { const { t } = useTranslation(); const breadcrumbs: BreadcrumbItem[] = [ @@ -31,6 +34,7 @@ export default function RolesCreate({ catalog }: RolesCreateProps) { name: '', client_scoped: false, permissions: [], + start_page: null, }); const submit: FormEventHandler = (e) => { @@ -59,6 +63,15 @@ export default function RolesCreate({ catalog }: RolesCreateProps) { errors={errors} /> + setData('start_page', value)} + options={start_page_options} + grantedPermissions={data.permissions} + error={errors.start_page} + description={t('Where people with this role land after signing in. Each person can still choose their own in their profile.')} + /> + diff --git a/resources/js/pages/roles/edit.tsx b/resources/js/pages/roles/edit.tsx index fcb2cea5..e6230305 100644 --- a/resources/js/pages/roles/edit.tsx +++ b/resources/js/pages/roles/edit.tsx @@ -8,6 +8,7 @@ import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { PermissionCatalogCategory, RoleForm } from '@/components/role-form'; import { SavedIndicator } from '@/components/save-button'; +import { StartPageSelect, type StartPageOption } from '@/components/start-page-select'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { useTranslation } from '@/hooks/use-translation'; @@ -22,18 +23,21 @@ interface RolesEditProps { client_scoped: boolean; users_count: number; permissions: string[]; + start_page: string | null; }; catalog: PermissionCatalogCategory[]; + start_page_options: StartPageOption[]; } interface RoleFormData { - [key: string]: string | string[] | boolean; + [key: string]: string | string[] | boolean | null; name: string; client_scoped: boolean; permissions: string[]; + start_page: string | null; } -export default function RolesEdit({ role, catalog }: RolesEditProps) { +export default function RolesEdit({ role, catalog, start_page_options }: RolesEditProps) { const { t } = useTranslation(); const displayName = role.is_system ? t(role.name) : role.name; @@ -47,10 +51,17 @@ export default function RolesEdit({ role, catalog }: RolesEditProps) { name: role.name, client_scoped: role.client_scoped, permissions: role.permissions, + start_page: role.start_page, }); + // The administrator role's permissions are fixed, so its form sends the + // start page alone — anything more is refused by the server. + const adminForm = useForm<{ start_page: string | null }>({ start_page: role.start_page }); + const deleteForm = useForm({}); + const startPageDescription = t('Where people with this role land after signing in. Each person can still choose their own in their profile.'); + const submit: FormEventHandler = (e) => { e.preventDefault(); patch(route('roles.update', role.id)); @@ -64,10 +75,35 @@ export default function RolesEdit({ role, catalog }: RolesEditProps) { {role.is_administrator ? ( - - - {t('The administrator role always has every permission and cannot be edited.')} - +
+ + + {t('The administrator role always has every permission and cannot be edited.')} + + +
{ + e.preventDefault(); + adminForm.patch(route('roles.update', role.id)); + }} + className="space-y-6" + > + adminForm.setData('start_page', value)} + options={start_page_options} + error={adminForm.errors.start_page} + description={startPageDescription} + /> + +
+ + +
+ +
) : (
+ setData('start_page', value)} + options={start_page_options} + grantedPermissions={data.permissions} + error={errors.start_page} + description={startPageDescription} + /> +
+ setData('start_page', value)} + options={start_page_options} + inheritLabel={t('Default (:page)', { page: role_start_page })} + error={errors.start_page} + description={t('The page you land on after signing in.')} + /> + route(auth()->check() ? 'dashboard' : 'login'); +// A signed-in visitor goes where signing in would have sent them — their +// own start page, their role's, or the dashboard (see StartPages). +Route::get('/', function (Request $request, StartPages $startPages) { + $user = $request->user(); + + return $user === null ? redirect()->route('login') : redirect($startPages->pathFor($user)); })->name('home'); Route::put('locale', [LocaleController::class, 'update']) diff --git a/tests/Feature/Auth/SocialLoginTest.php b/tests/Feature/Auth/SocialLoginTest.php index e01d2f27..2e29b177 100644 --- a/tests/Feature/Auth/SocialLoginTest.php +++ b/tests/Feature/Auth/SocialLoginTest.php @@ -7,6 +7,8 @@ use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLog; use App\Modules\Groups\Models\Group; use App\Modules\Identity\AuthSource; +use App\Modules\Identity\Models\Role; +use App\Modules\Identity\Permissions\SystemRole; use App\Modules\Identity\Social\SocialAccount; use App\Modules\Identity\Social\SocialGateway; use App\Modules\Identity\Social\SocialIdentity; @@ -486,3 +488,14 @@ test('the takeover refusal explains what to do instead', function () { ->component('auth/login') ->where('flash.error', 'An account already uses this email address, and Google did not confirm that you own it. Sign in with your password and connect Google from your settings instead.')); }); + +test('a provider sign-in lands on the account\'s start page, like a password sign-in', function () { + socialSettings(); + Role::query() + ->where('name', SystemRole::Client->value) + ->update(['start_page' => 'files']); + User::factory()->client()->create(['email' => 'client@example.test']); + fakeProvider(identity(email: 'client@example.test', verified: true)); + + signInWith()->assertRedirect('/my-files'); +}); diff --git a/tests/Feature/Identity/StartPageTest.php b/tests/Feature/Identity/StartPageTest.php new file mode 100644 index 00000000..a07daf64 --- /dev/null +++ b/tests/Feature/Identity/StartPageTest.php @@ -0,0 +1,314 @@ +admin = User::factory()->create(); + + // A waiting greeting sends everyone to the dashboard, and Settings + // survive RefreshDatabase's rollback in the cache — so state both. + app(Settings::class)->set(Setting::GettingStartedPending, false); + app(Settings::class)->set(Setting::UpdateWelcomeTo, ''); +}); + +/** + * @param list $permissions + */ +function staffStartingOn(array $permissions, ?string $startPage = null): User +{ + $role = Role::query()->create(['name' => 'Role '.uniqid(), 'start_page' => $startPage]); + + if ($permissions !== []) { + RolePermission::query()->insert(array_map( + fn (Permission $p): array => ['role_id' => $role->id, 'permission' => $p->value], + $permissions, + )); + } + + return User::factory()->create(['role_id' => $role->id]); +} + +function startPageClientRole(): Role +{ + return Role::query()->where('name', SystemRole::Client->value)->sole(); +} + +/* +|-------------------------------------------------------------------------- +| The vocabulary agrees with the routes +|-------------------------------------------------------------------------- +| +| requiredPermission() is a second statement of what each route's +| middleware asks. If the two drift, somebody is sent to a 403 after +| signing in — so this opens every page for real, with and without the +| permission, instead of trusting the enum. +| +*/ + +test('every staff start page opens for staff holding its permission, and not for staff without it', function () { + foreach (StartPage::optionsFor(UserType::Staff) as $page) { + $required = $page->requiredPermission(UserType::Staff); + $path = route($page->routeName(UserType::Staff), absolute: false); + + $holder = staffStartingOn($required === null ? [] : [$required]); + expect($page->isReachableBy($holder))->toBeTrue(); + $this->actingAs($holder)->get($path)->assertOk(); + + if ($required !== null) { + $without = staffStartingOn([]); + expect($page->isReachableBy($without))->toBeFalse(); + expect($this->actingAs($without)->get($path)->status())->not->toBe(200, "{$page->value} opened without {$required->value}"); + } + } +}); + +test('every client start page opens for a client holding its permission, and not for one without it', function () { + foreach (StartPage::optionsFor(UserType::Client) as $page) { + $required = $page->requiredPermission(UserType::Client); + $path = route($page->routeName(UserType::Client), absolute: false); + + RolePermission::query()->where('role_id', startPageClientRole()->id)->delete(); + if ($required !== null) { + RolePermission::query()->insert(['role_id' => startPageClientRole()->id, 'permission' => $required->value]); + } + forgetRequestState(); + + $holder = User::factory()->client()->create(); + expect($page->isReachableBy($holder))->toBeTrue(); + $this->actingAs($holder)->get($path)->assertOk(); + + if ($required !== null) { + RolePermission::query()->where('role_id', startPageClientRole()->id)->delete(); + forgetRequestState(); + $without = User::factory()->client()->create(); + expect($page->isReachableBy($without))->toBeFalse(); + expect($this->actingAs($without)->get($path)->status())->not->toBe(200, "{$page->value} opened without {$required->value}"); + } + } +}); + +test('a client is never offered a staff-only page', function () { + $values = array_map(fn (StartPage $p) => $p->value, StartPage::optionsFor(UserType::Client)); + + expect($values)->not->toContain('clients')->not->toContain('activity'); +}); + +/* +|-------------------------------------------------------------------------- +| Where a sign-in lands +|-------------------------------------------------------------------------- +*/ + +test('signing in lands on the role\'s start page', function () { + $user = staffStartingOn([Permission::ManageClients], startPage: 'clients'); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/clients'); +}); + +test('a personal choice beats the role\'s', function () { + $user = staffStartingOn([Permission::ManageClients, Permission::ViewActionsLog], startPage: 'clients'); + $user->update(['start_page' => 'activity']); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/activity'); +}); + +test('an explicit personal Dashboard beats a role default', function () { + $user = staffStartingOn([Permission::ManageClients], startPage: 'clients'); + $user->update(['start_page' => 'dashboard']); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/dashboard'); +}); + +test('a choice the account can no longer open falls back to the role, then to the dashboard', function () { + // Saved while they could open it; the permission went away later. + $user = staffStartingOn([Permission::ManageClients], startPage: 'clients'); + $user->update(['start_page' => 'activity']); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/clients'); + + Auth::logout(); + RolePermission::query()->where('role_id', $user->role_id)->delete(); + forgetRequestState(); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/dashboard'); +}); + +test('a value no version offers any more falls back to the dashboard instead of failing', function () { + $user = staffStartingOn([], startPage: 'something-removed'); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/dashboard'); +}); + +test('a page somebody was trying to reach still wins over the start page', function () { + $user = staffStartingOn([Permission::ManageClients, Permission::ViewActionsLog], startPage: 'clients'); + + $this->get('/activity')->assertRedirect(route('login')); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect('/activity'); +}); + +test('a waiting greeting sends the administrator to the dashboard first', function () { + Role::query()->whereKey($this->admin->role_id)->update(['start_page' => 'clients']); + app(Settings::class)->set(Setting::GettingStartedPending, true); + + $this->post('/login', ['email' => $this->admin->email, 'password' => 'password']) + ->assertRedirect('/dashboard'); +}); + +test('a client lands on their role\'s start page', function () { + startPageClientRole()->update(['start_page' => 'files']); + $client = User::factory()->client()->create(); + + $this->post('/login', ['email' => $client->email, 'password' => 'password']) + ->assertRedirect('/my-files'); +}); + +test('the site root sends a signed-in account to its start page', function () { + $user = staffStartingOn([Permission::ManageClients], startPage: 'clients'); + + $this->actingAs($user)->get('/')->assertRedirect('/clients'); +}); + +test('finishing a two-factor challenge lands on the start page', function () { + $user = staffStartingOn([Permission::ManageClients], startPage: 'clients'); + enableTwoFactor($user); + + Auth::logout(); + $this->flushSession(); + + $this->post('/login', ['email' => $user->email, 'password' => 'password']) + ->assertRedirect(route('two-factor.challenge')); + + $code = app(Google2FA::class)->getCurrentOtp((string) $user->refresh()->two_factor_secret); + + $this->post('/two-factor-challenge', ['code' => $code])->assertRedirect('/clients'); +}); + +/* +|-------------------------------------------------------------------------- +| Role screens +|-------------------------------------------------------------------------- +*/ + +test('a role cannot start on a page its own permissions keep it out of', function () { + $this->actingAs($this->admin)->post('/roles', [ + 'name' => 'No clients', + 'permissions' => [Permission::Upload->value], + 'start_page' => 'clients', + ])->assertSessionHasErrors('start_page'); + + expect(Role::query()->where('name', 'No clients')->exists())->toBeFalse(); +}); + +test('granting the permission and choosing the page is one save', function () { + $this->actingAs($this->admin)->post('/roles', [ + 'name' => 'Client desk', + 'permissions' => [Permission::ManageClients->value], + 'start_page' => 'clients', + ])->assertSessionHasNoErrors(); + + expect(Role::query()->where('name', 'Client desk')->value('start_page'))->toBe('clients'); +}); + +test('a built-in role keeps its name but takes a start page', function () { + $manager = Role::query()->where('name', SystemRole::AccountManager->value)->sole(); + + $this->actingAs($this->admin)->patch("/roles/{$manager->id}", [ + 'name' => $manager->name, + 'permissions' => $manager->permissions()->pluck('permission')->all(), + 'start_page' => 'activity', + ])->assertSessionHasNoErrors(); + + expect($manager->refresh()->start_page)->toBe('activity'); +}); + +test('the Client role cannot be given a staff-only page', function () { + $role = startPageClientRole(); + + $this->actingAs($this->admin)->patch("/roles/{$role->id}", [ + 'name' => $role->name, + 'permissions' => $role->permissions()->pluck('permission')->all(), + 'start_page' => 'activity', + ])->assertSessionHasErrors('start_page'); +}); + +test('the administrator role takes a start page and nothing else', function () { + $adminRole = Role::query()->where('is_administrator', true)->sole(); + + $this->actingAs($this->admin)->patch("/roles/{$adminRole->id}", ['start_page' => 'files']) + ->assertSessionHasNoErrors(); + + expect($adminRole->refresh()->start_page)->toBe('files'); + + $this->actingAs($this->admin)->patch("/roles/{$adminRole->id}", [ + 'start_page' => 'clients', + 'permissions' => [], + ])->assertSessionHasErrors('permissions'); + + expect($adminRole->refresh()->start_page)->toBe('files') + ->and(RolePermission::query()->where('role_id', $adminRole->id)->count())->toBe(0); +}); + +/* +|-------------------------------------------------------------------------- +| Profile +|-------------------------------------------------------------------------- +*/ + +test('a person can choose their own start page, and clear it to follow their role', function () { + $user = staffStartingOn([Permission::ManageClients]); + + $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'start_page' => 'clients', + ])->assertSessionHasNoErrors(); + + expect($user->refresh()->start_page)->toBe('clients'); + + $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'start_page' => '', + ])->assertSessionHasNoErrors(); + + expect($user->refresh()->start_page)->toBeNull(); +}); + +test('a person cannot choose a page they cannot open', function () { + $user = staffStartingOn([]); + + $this->actingAs($user)->patch('/settings/profile', [ + 'name' => $user->name, + 'email' => $user->email, + 'start_page' => 'clients', + ])->assertSessionHasErrors('start_page'); + + $client = User::factory()->client()->create(); + + $this->actingAs($client)->patch('/settings/profile', [ + 'name' => $client->name, + 'email' => $client->email, + 'start_page' => 'activity', + ])->assertSessionHasErrors('start_page'); +}); From b0a95f953dcb2f5eff4c2a590af2077c0baaed8b Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sun, 13 Sep 2026 15:37:21 -0300 Subject: [PATCH 3/6] Translate the fourteen strings client expiry and start pages added Sixteen locales, fourteen strings each: the expiry field and its two hints, the expired badge text, the sign-in refusal, the scheduler label, two activity-log entries, and the start page picker with its three hints and the role permission error. Wording follows each catalogue rather than being chosen fresh: every locale already had its own words for expired, deactivated, sign in and default, and these reuse them. Formality follows each file too. Every :placeholder was checked to survive in every locale. --- lang/ca.json | 16 +++++++++++++++- lang/cs.json | 16 +++++++++++++++- lang/de.json | 16 +++++++++++++++- lang/es.json | 16 +++++++++++++++- lang/fr.json | 16 +++++++++++++++- lang/id.json | 16 +++++++++++++++- lang/it.json | 16 +++++++++++++++- lang/ja.json | 16 +++++++++++++++- lang/nl.json | 16 +++++++++++++++- lang/pl.json | 16 +++++++++++++++- lang/pt_BR.json | 16 +++++++++++++++- lang/ru.json | 16 +++++++++++++++- lang/sw.json | 16 +++++++++++++++- lang/tr.json | 16 +++++++++++++++- lang/vi.json | 16 +++++++++++++++- lang/zh_CN.json | 16 +++++++++++++++- 16 files changed, 240 insertions(+), 16 deletions(-) diff --git a/lang/ca.json b/lang/ca.json index 9bcb6591..d184c5cc 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "S'ha creat el teu compte. Podràs iniciar la sessió quan s'aprovi.", "Invite a client to share files with": "Convida un client amb qui compartir fitxers", ":clientName (:clientEmail) registered a client account": "Ha registrat un compte de client :clientName (:clientEmail)", - "A new client registered an account": "S'ha registrat un compte de client nou" + "A new client registered an account": "S'ha registrat un compte de client nou", + "Account expires": "El compte caduca", + "A client account reached its expiry date and was deactivated": "Un compte de client ha arribat a la seva data de caducitat i s'ha desactivat", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Després d'aquest dia el client ja no pot iniciar la sessió i el compte es desactiva. Els seus fitxers es conserven. Deixa-ho buit per a un compte que no caduqui mai.", + "Deactivate expired client accounts": "Desactiva els comptes de client caducats", + "Default (:page)": "Per defecte (:page)", + "Start page": "Pàgina d'inici", + "The client account \":name\" expired and was deactivated": "El compte de client \":name\" ha caducat i s'ha desactivat", + "The page you land on after signing in.": "La pàgina on arribes després d'iniciar la sessió.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Aquest compte va caducar el :date i ja no pot iniciar la sessió. Per tornar-li a donar accés, tria una data posterior o esborra-la, i assegura't que el compte és actiu.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Aquesta data ja ha passat. Tria una data posterior, o deixa-ho buit per a un compte que no caduqui mai.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Aquest rol no pot obrir aquesta pàgina. Dona-li el permís \":permission\" o tria una altra pàgina d'inici.", + "Until :date": "Fins al :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "On arriben les persones amb aquest rol després d'iniciar la sessió. Cada persona pot triar la seva al seu perfil.", + "Your account has expired.": "El teu compte ha caducat." } diff --git a/lang/cs.json b/lang/cs.json index 0dd0ea87..150ed638 100644 --- a/lang/cs.json +++ b/lang/cs.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Váš účet byl vytvořen. Přihlásit se budete moci, jakmile bude schválen.", "Invite a client to share files with": "Pozvěte klienta, se kterým budete sdílet soubory", ":clientName (:clientEmail) registered a client account": "Klient :clientName (:clientEmail) si zaregistroval účet", - "A new client registered an account": "Zaregistroval se nový klient" + "A new client registered an account": "Zaregistroval se nový klient", + "Account expires": "Platnost účtu vyprší", + "A client account reached its expiry date and was deactivated": "Platnost klientského účtu vypršela a účet byl deaktivován", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Po tomto dni se klient už nepřihlásí a účet bude deaktivován. Jeho soubory zůstanou. Nechte prázdné, pokud platnost účtu nemá nikdy vypršet.", + "Deactivate expired client accounts": "Deaktivovat klientské účty s vypršelou platností", + "Default (:page)": "Výchozí (:page)", + "Start page": "Úvodní stránka", + "The client account \":name\" expired and was deactivated": "Platnost klientského účtu \":name\" vypršela a účet byl deaktivován", + "The page you land on after signing in.": "Stránka, na kterou se dostanete po přihlášení.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Platnost tohoto účtu vypršela :date a klient se už nemůže přihlásit. Chcete-li mu přístup vrátit, zvolte pozdější datum nebo ho smažte a ujistěte se, že je účet aktivní.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Toto datum už uplynulo. Zvolte pozdější datum, nebo nechte prázdné, pokud platnost účtu nemá nikdy vypršet.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Tato role nemůže tuto stránku otevřít. Přidejte jí oprávnění \":permission\", nebo zvolte jinou úvodní stránku.", + "Until :date": "Do :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Kam se lidé s touto rolí dostanou po přihlášení. Každý si může ve svém profilu zvolit vlastní.", + "Your account has expired.": "Platnost vašeho účtu vypršela." } diff --git a/lang/de.json b/lang/de.json index 346125bc..2ad93ffc 100644 --- a/lang/de.json +++ b/lang/de.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Ihr Konto wurde erstellt. Sie können sich anmelden, sobald es genehmigt wurde.", "Invite a client to share files with": "Laden Sie einen Kunden ein, mit dem Sie Dateien teilen", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) hat ein Kundenkonto registriert", - "A new client registered an account": "Ein neuer Kunde hat ein Konto registriert" + "A new client registered an account": "Ein neuer Kunde hat ein Konto registriert", + "Account expires": "Konto läuft ab", + "A client account reached its expiry date and was deactivated": "Ein Kundenkonto hat sein Ablaufdatum erreicht und wurde deaktiviert", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Nach diesem Tag kann sich der Kunde nicht mehr anmelden, und das Konto wird deaktiviert. Seine Dateien bleiben erhalten. Leer lassen für ein Konto, das nie abläuft.", + "Deactivate expired client accounts": "Abgelaufene Kundenkonten deaktivieren", + "Default (:page)": "Standard (:page)", + "Start page": "Startseite", + "The client account \":name\" expired and was deactivated": "Das Kundenkonto \":name\" ist abgelaufen und wurde deaktiviert", + "The page you land on after signing in.": "Die Seite, auf der Sie nach der Anmeldung landen.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Dieses Konto ist am :date abgelaufen und kann sich nicht mehr anmelden. Um den Zugang wiederherzustellen, wählen Sie ein späteres Datum oder leeren Sie das Feld, und stellen Sie sicher, dass das Konto aktiv ist.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Dieses Datum liegt bereits in der Vergangenheit. Wählen Sie ein späteres Datum oder lassen Sie das Feld leer für ein Konto, das nie abläuft.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Diese Rolle kann diese Seite nicht öffnen. Geben Sie ihr die Berechtigung \":permission\" oder wählen Sie eine andere Startseite.", + "Until :date": "Bis :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Wo Personen mit dieser Rolle nach der Anmeldung landen. Jede Person kann in ihrem Profil trotzdem eine eigene wählen.", + "Your account has expired.": "Ihr Konto ist abgelaufen." } diff --git a/lang/es.json b/lang/es.json index f79cd603..ef38f262 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Tu cuenta ha sido creada. Podrás iniciar sesión en cuanto se apruebe.", "Invite a client to share files with": "Invita a un cliente con quien compartir archivos", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) registró una cuenta de cliente", - "A new client registered an account": "Un cliente nuevo registró una cuenta" + "A new client registered an account": "Un cliente nuevo registró una cuenta", + "Account expires": "La cuenta vence", + "A client account reached its expiry date and was deactivated": "Una cuenta de cliente llegó a su fecha de vencimiento y se desactivó", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Después de este día el cliente ya no puede iniciar sesión y la cuenta se desactiva. Sus archivos se conservan. Déjalo vacío si la cuenta nunca vence.", + "Deactivate expired client accounts": "Desactivar cuentas de cliente vencidas", + "Default (:page)": "Predeterminado (:page)", + "Start page": "Página de inicio", + "The client account \":name\" expired and was deactivated": "La cuenta de cliente \":name\" venció y se desactivó", + "The page you land on after signing in.": "La página a la que llegas después de iniciar sesión.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Esta cuenta venció el :date y ya no puede iniciar sesión. Para que vuelva a entrar, elige una fecha posterior o bórrala, y asegúrate de que la cuenta esté activa.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Esta fecha ya pasó. Elige una fecha posterior, o déjalo vacío si la cuenta nunca vence.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Este rol no puede abrir esa página. Dale el permiso \":permission\" o elige otra página de inicio.", + "Until :date": "Hasta el :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Donde llegan las personas con este rol después de iniciar sesión. Cada persona puede elegir la suya en su perfil.", + "Your account has expired.": "Tu cuenta ha vencido." } diff --git a/lang/fr.json b/lang/fr.json index 707c03dd..ca3331df 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Votre compte a été créé. Vous pourrez vous connecter une fois qu'il aura été approuvé.", "Invite a client to share files with": "Invitez un client avec qui partager des fichiers", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) a créé un compte client", - "A new client registered an account": "Un nouveau client a créé un compte" + "A new client registered an account": "Un nouveau client a créé un compte", + "Account expires": "Le compte expire", + "A client account reached its expiry date and was deactivated": "Un compte client a atteint sa date d'expiration et a été désactivé", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Après ce jour, le client ne peut plus se connecter et le compte est désactivé. Ses fichiers sont conservés. Laissez vide pour un compte qui n'expire jamais.", + "Deactivate expired client accounts": "Désactiver les comptes clients expirés", + "Default (:page)": "Par défaut (:page)", + "Start page": "Page d'accueil", + "The client account \":name\" expired and was deactivated": "Le compte client \":name\" a expiré et a été désactivé", + "The page you land on after signing in.": "La page sur laquelle vous arrivez après vous être connecté.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Ce compte a expiré le :date et ne peut plus se connecter. Pour lui rendre l'accès, choisissez une date ultérieure ou effacez-la, et vérifiez que le compte est actif.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Cette date est déjà passée. Choisissez une date ultérieure, ou laissez vide pour un compte qui n'expire jamais.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Ce rôle ne peut pas ouvrir cette page. Donnez-lui la permission \":permission\" ou choisissez une autre page d'accueil.", + "Until :date": "Jusqu'au :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "La page où arrivent les personnes ayant ce rôle après s'être connectées. Chacun peut toujours choisir la sienne dans son profil.", + "Your account has expired.": "Votre compte a expiré." } diff --git a/lang/id.json b/lang/id.json index 6848115a..1d09713a 100644 --- a/lang/id.json +++ b/lang/id.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Akun Anda telah dibuat. Anda bisa masuk setelah akun disetujui.", "Invite a client to share files with": "Undang klien untuk berbagi berkas", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) mendaftarkan akun klien", - "A new client registered an account": "Klien baru mendaftarkan akun" + "A new client registered an account": "Klien baru mendaftarkan akun", + "Account expires": "Akun kedaluwarsa", + "A client account reached its expiry date and was deactivated": "Sebuah akun klien mencapai tanggal kedaluwarsanya dan dinonaktifkan", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Setelah hari ini klien tidak bisa masuk lagi, dan akunnya dinonaktifkan. Berkasnya tetap tersimpan. Kosongkan agar akun tidak pernah kedaluwarsa.", + "Deactivate expired client accounts": "Nonaktifkan akun klien yang kedaluwarsa", + "Default (:page)": "Bawaan (:page)", + "Start page": "Halaman awal", + "The client account \":name\" expired and was deactivated": "Akun klien \":name\" kedaluwarsa dan dinonaktifkan", + "The page you land on after signing in.": "Halaman yang Anda buka setelah masuk.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Akun ini kedaluwarsa pada :date dan tidak bisa masuk lagi. Untuk memberinya akses kembali, pilih tanggal yang lebih lambat atau kosongkan, dan pastikan akunnya aktif.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Tanggal ini sudah lewat. Pilih tanggal yang lebih lambat, atau kosongkan agar akun tidak pernah kedaluwarsa.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Peran ini tidak bisa membuka halaman tersebut. Berikan izin \":permission\", atau pilih halaman awal lain.", + "Until :date": "Sampai :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Halaman yang dibuka orang dengan peran ini setelah masuk. Setiap orang tetap bisa memilih sendiri di profilnya.", + "Your account has expired.": "Akun Anda telah kedaluwarsa." } diff --git a/lang/it.json b/lang/it.json index d952e595..642eda96 100644 --- a/lang/it.json +++ b/lang/it.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Il tuo account è stato creato. Potrai accedere non appena verrà approvato.", "Invite a client to share files with": "Invita un cliente con cui condividere i file", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) ha registrato un account cliente", - "A new client registered an account": "Un nuovo cliente ha registrato un account" + "A new client registered an account": "Un nuovo cliente ha registrato un account", + "Account expires": "L'account scade", + "A client account reached its expiry date and was deactivated": "Un account cliente ha raggiunto la data di scadenza ed è stato disattivato", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Dopo questo giorno il cliente non può più accedere e l'account viene disattivato. I suoi file restano. Lascia vuoto per un account che non scade mai.", + "Deactivate expired client accounts": "Disattiva gli account cliente scaduti", + "Default (:page)": "Predefinito (:page)", + "Start page": "Pagina iniziale", + "The client account \":name\" expired and was deactivated": "L'account cliente \":name\" è scaduto ed è stato disattivato", + "The page you land on after signing in.": "La pagina in cui arrivi dopo aver effettuato l'accesso.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Questo account è scaduto il :date e non può più accedere. Per ridargli l'accesso, scegli una data successiva o cancellala, e assicurati che l'account sia attivo.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Questa data è già passata. Scegli una data successiva, oppure lascia vuoto per un account che non scade mai.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Questo ruolo non può aprire quella pagina. Assegnagli il permesso \":permission\" oppure scegli un'altra pagina iniziale.", + "Until :date": "Fino al :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Dove arrivano le persone con questo ruolo dopo l'accesso. Ognuno può comunque sceglierne una propria nel profilo.", + "Your account has expired.": "Il tuo account è scaduto." } diff --git a/lang/ja.json b/lang/ja.json index 16c19281..c8cded64 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "アカウントを作成しました。承認されるとログインできます。", "Invite a client to share files with": "ファイルを共有するクライアントを招待します", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) がクライアントアカウントを登録しました", - "A new client registered an account": "新しいクライアントがアカウントを登録しました" + "A new client registered an account": "新しいクライアントがアカウントを登録しました", + "Account expires": "アカウントの有効期限", + "A client account reached its expiry date and was deactivated": "クライアントアカウントが有効期限に達し、無効化されました", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "この日を過ぎると、クライアントはサインインできなくなり、アカウントは無効化されます。ファイルは残ります。期限なしのアカウントにするには空欄のままにしてください。", + "Deactivate expired client accounts": "期限切れのクライアントアカウントを無効化", + "Default (:page)": "既定 (:page)", + "Start page": "開始ページ", + "The client account \":name\" expired and was deactivated": "クライアントアカウント「:name」の有効期限が切れ、無効化されました", + "The page you land on after signing in.": "サインイン後に表示されるページです。", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "このアカウントは :date に期限切れとなり、サインインできません。再びアクセスできるようにするには、より後の日付を選ぶか日付を消去し、アカウントが有効になっていることを確認してください。", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "この日付はすでに過ぎています。より後の日付を選ぶか、期限なしのアカウントにするには空欄のままにしてください。", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "このロールはそのページを開けません。「:permission」の権限を付与するか、別の開始ページを選んでください。", + "Until :date": ":date まで", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "このロールのユーザーがサインイン後に表示されるページです。各ユーザーはプロフィールで自分用のページを選ぶこともできます。", + "Your account has expired.": "アカウントの有効期限が切れました。" } diff --git a/lang/nl.json b/lang/nl.json index 8df43b34..f4dd95a4 100644 --- a/lang/nl.json +++ b/lang/nl.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Je account is aangemaakt. Je kunt inloggen zodra het is goedgekeurd.", "Invite a client to share files with": "Nodig een klant uit om bestanden mee te delen", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) heeft een klantaccount geregistreerd", - "A new client registered an account": "Een nieuwe klant heeft een account geregistreerd" + "A new client registered an account": "Een nieuwe klant heeft een account geregistreerd", + "Account expires": "Account verloopt", + "A client account reached its expiry date and was deactivated": "Een klantaccount heeft de vervaldatum bereikt en is gedeactiveerd", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Na deze dag kan de klant niet meer inloggen en wordt het account gedeactiveerd. De bestanden blijven bewaard. Laat leeg voor een account dat nooit verloopt.", + "Deactivate expired client accounts": "Verlopen klantaccounts deactiveren", + "Default (:page)": "Standaard (:page)", + "Start page": "Startpagina", + "The client account \":name\" expired and was deactivated": "Het klantaccount \":name\" is verlopen en gedeactiveerd", + "The page you land on after signing in.": "De pagina waar je terechtkomt nadat je bent ingelogd.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Dit account is verlopen op :date en kan niet meer inloggen. Om weer toegang te geven, kies je een latere datum of maak je het veld leeg, en zorg je dat het account actief is.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Deze datum is al voorbij. Kies een latere datum, of laat leeg voor een account dat nooit verloopt.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Deze rol kan die pagina niet openen. Geef de rol de machtiging \":permission\", of kies een andere startpagina.", + "Until :date": "Tot :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Waar mensen met deze rol terechtkomen na het inloggen. Iedereen kan in zijn profiel nog steeds een eigen pagina kiezen.", + "Your account has expired.": "Je account is verlopen." } diff --git a/lang/pl.json b/lang/pl.json index fa082b02..3bf278de 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Twoje konto zostało utworzone. Zalogujesz się, gdy zostanie zatwierdzone.", "Invite a client to share files with": "Zaproś klienta, któremu będziesz udostępniać pliki", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) zarejestrował konto klienta", - "A new client registered an account": "Nowy klient zarejestrował konto" + "A new client registered an account": "Nowy klient zarejestrował konto", + "Account expires": "Konto wygasa", + "A client account reached its expiry date and was deactivated": "Konto klienta osiągnęło datę wygaśnięcia i zostało dezaktywowane", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Po tym dniu klient nie może się już zalogować, a konto zostaje dezaktywowane. Jego pliki pozostają. Zostaw puste, aby konto nigdy nie wygasało.", + "Deactivate expired client accounts": "Dezaktywuj wygasłe konta klientów", + "Default (:page)": "Domyślne (:page)", + "Start page": "Strona startowa", + "The client account \":name\" expired and was deactivated": "Konto klienta \":name\" wygasło i zostało dezaktywowane", + "The page you land on after signing in.": "Strona, na którą trafiasz po zalogowaniu.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "To konto wygasło :date i nie może się już zalogować. Aby przywrócić dostęp, wybierz późniejszą datę lub ją wyczyść i upewnij się, że konto jest aktywne.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Ta data już minęła. Wybierz późniejszą datę albo zostaw puste, aby konto nigdy nie wygasało.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Ta rola nie może otworzyć tej strony. Nadaj jej uprawnienie \":permission\" albo wybierz inną stronę startową.", + "Until :date": "Do :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Strona, na którą trafiają osoby z tą rolą po zalogowaniu. Każdy nadal może wybrać własną w swoim profilu.", + "Your account has expired.": "Twoje konto wygasło." } diff --git a/lang/pt_BR.json b/lang/pt_BR.json index c63b4a07..aa57d8ec 100644 --- a/lang/pt_BR.json +++ b/lang/pt_BR.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Sua conta foi criada. Você poderá entrar assim que ela for aprovada.", "Invite a client to share files with": "Convide um cliente com quem compartilhar arquivos", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) cadastrou uma conta de cliente", - "A new client registered an account": "Um novo cliente cadastrou uma conta" + "A new client registered an account": "Um novo cliente cadastrou uma conta", + "Account expires": "A conta expira", + "A client account reached its expiry date and was deactivated": "Uma conta de cliente chegou à data de expiração e foi desativada", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Depois deste dia, o cliente não consegue mais entrar e a conta é desativada. Os arquivos dele continuam. Deixe em branco para uma conta que nunca expira.", + "Deactivate expired client accounts": "Desativar contas de cliente expiradas", + "Default (:page)": "Padrão (:page)", + "Start page": "Página inicial", + "The client account \":name\" expired and was deactivated": "A conta de cliente \":name\" expirou e foi desativada", + "The page you land on after signing in.": "A página em que você chega depois de entrar.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Esta conta expirou em :date e não consegue mais entrar. Para devolver o acesso, escolha uma data posterior ou limpe o campo, e confira se a conta está ativa.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Esta data já passou. Escolha uma data posterior ou deixe em branco para uma conta que nunca expira.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Esta função não consegue abrir essa página. Dê a ela a permissão \":permission\" ou escolha outra página inicial.", + "Until :date": "Até :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Onde as pessoas com esta função chegam depois de entrar. Cada pessoa ainda pode escolher a sua no próprio perfil.", + "Your account has expired.": "Sua conta expirou." } diff --git a/lang/ru.json b/lang/ru.json index 17446a2f..bb472ed0 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Ваша учётная запись создана. Вы сможете войти, как только её одобрят.", "Invite a client to share files with": "Пригласите клиента, с которым будете делиться файлами", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) зарегистрировал учётную запись клиента", - "A new client registered an account": "Новый клиент зарегистрировал учётную запись" + "A new client registered an account": "Новый клиент зарегистрировал учётную запись", + "Account expires": "Срок действия учётной записи", + "A client account reached its expiry date and was deactivated": "Срок действия учётной записи клиента истёк, и она отключена", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "После этого дня клиент больше не сможет войти, а учётная запись будет отключена. Его файлы сохранятся. Оставьте пустым, чтобы срок действия учётной записи не истекал.", + "Deactivate expired client accounts": "Отключать учётные записи клиентов с истёкшим сроком", + "Default (:page)": "По умолчанию (:page)", + "Start page": "Начальная страница", + "The client account \":name\" expired and was deactivated": "Срок действия учётной записи клиента «:name» истёк, и она отключена", + "The page you land on after signing in.": "Страница, на которую вы попадаете после входа.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Срок действия этой учётной записи истёк :date, войти в неё больше нельзя. Чтобы вернуть доступ, выберите более позднюю дату или очистите поле и убедитесь, что учётная запись активна.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Эта дата уже прошла. Выберите более позднюю дату или оставьте поле пустым, чтобы срок действия учётной записи не истекал.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Эта роль не может открыть эту страницу. Выдайте ей разрешение «:permission» или выберите другую начальную страницу.", + "Until :date": "До :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Страница, на которую попадают пользователи с этой ролью после входа. Каждый может выбрать свою в профиле.", + "Your account has expired.": "Срок действия вашей учётной записи истёк." } diff --git a/lang/sw.json b/lang/sw.json index cee80d63..8f59260a 100644 --- a/lang/sw.json +++ b/lang/sw.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Akaunti yako imeundwa. Utaweza kuingia mara itakapoidhinishwa.", "Invite a client to share files with": "Alika mteja wa kushirikiana naye mafaili", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) amesajili akaunti ya mteja", - "A new client registered an account": "Mteja mpya amesajili akaunti" + "A new client registered an account": "Mteja mpya amesajili akaunti", + "Account expires": "Akaunti inaisha muda", + "A client account reached its expiry date and was deactivated": "Akaunti ya mteja imefikia tarehe yake ya kuisha muda na imezimwa", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Baada ya siku hii mteja hawezi kuingia tena, na akaunti inazimwa. Mafaili yake yanabaki. Acha wazi ili akaunti isiishe muda kamwe.", + "Deactivate expired client accounts": "Zima akaunti za wateja zilizoisha muda", + "Default (:page)": "Chaguomsingi (:page)", + "Start page": "Ukurasa wa kuanzia", + "The client account \":name\" expired and was deactivated": "Akaunti ya mteja \":name\" imeisha muda na imezimwa", + "The page you land on after signing in.": "Ukurasa unaofika baada ya kuingia.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Akaunti hii iliisha muda tarehe :date na haiwezi kuingia tena. Ili kumrudishia ufikiaji, chagua tarehe ya baadaye au ifute, na hakikisha akaunti inatumika.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Tarehe hii imeshapita. Chagua tarehe ya baadaye, au acha wazi ili akaunti isiishe muda kamwe.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Jukumu hili haliwezi kufungua ukurasa huo. Lipe ruhusa ya \":permission\", au chagua ukurasa mwingine wa kuanzia.", + "Until :date": "Hadi :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Mahali ambapo watu wenye jukumu hili hufika baada ya kuingia. Kila mtu bado anaweza kuchagua wake kwenye wasifu wake.", + "Your account has expired.": "Akaunti yako imeisha muda." } diff --git a/lang/tr.json b/lang/tr.json index 5c42a691..96b6aae9 100644 --- a/lang/tr.json +++ b/lang/tr.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Hesabınız oluşturuldu. Onaylandığında giriş yapabileceksiniz.", "Invite a client to share files with": "Dosya paylaşacağınız bir müşteriyi davet edin", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) bir müşteri hesabı oluşturdu", - "A new client registered an account": "Yeni bir müşteri hesap oluşturdu" + "A new client registered an account": "Yeni bir müşteri hesap oluşturdu", + "Account expires": "Hesabın sona erme tarihi", + "A client account reached its expiry date and was deactivated": "Bir müşteri hesabı sona erme tarihine ulaştı ve devre dışı bırakıldı", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Bu günden sonra müşteri artık oturum açamaz ve hesap devre dışı bırakılır. Dosyaları kalır. Hiç sona ermeyen bir hesap için boş bırakın.", + "Deactivate expired client accounts": "Süresi dolmuş müşteri hesaplarını devre dışı bırak", + "Default (:page)": "Varsayılan (:page)", + "Start page": "Başlangıç sayfası", + "The client account \":name\" expired and was deactivated": "\":name\" müşteri hesabının süresi doldu ve devre dışı bırakıldı", + "The page you land on after signing in.": "Oturum açtıktan sonra karşınıza çıkan sayfa.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Bu hesabın süresi :date tarihinde doldu ve artık oturum açamaz. Erişimi geri vermek için daha sonraki bir tarih seçin ya da tarihi temizleyin ve hesabın etkin olduğundan emin olun.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Bu tarih zaten geçti. Daha sonraki bir tarih seçin ya da hiç sona ermeyen bir hesap için boş bırakın.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Bu rol o sayfayı açamaz. Role \":permission\" iznini verin ya da başka bir başlangıç sayfası seçin.", + "Until :date": ":date tarihine kadar", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Bu roldeki kişilerin oturum açtıktan sonra karşılaştığı sayfa. Herkes profilinden yine de kendi sayfasını seçebilir.", + "Your account has expired.": "Hesabınızın süresi doldu." } diff --git a/lang/vi.json b/lang/vi.json index 5984bcd2..e481b112 100644 --- a/lang/vi.json +++ b/lang/vi.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "Tài khoản của bạn đã được tạo. Bạn có thể đăng nhập khi tài khoản được duyệt.", "Invite a client to share files with": "Mời một khách hàng để chia sẻ tệp", ":clientName (:clientEmail) registered a client account": ":clientName (:clientEmail) đã đăng ký tài khoản khách hàng", - "A new client registered an account": "Một khách hàng mới đã đăng ký tài khoản" + "A new client registered an account": "Một khách hàng mới đã đăng ký tài khoản", + "Account expires": "Tài khoản hết hạn", + "A client account reached its expiry date and was deactivated": "Một tài khoản khách hàng đã đến ngày hết hạn và bị vô hiệu hóa", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "Sau ngày này, khách hàng không thể đăng nhập nữa và tài khoản bị vô hiệu hóa. Các tệp của họ vẫn được giữ. Để trống nếu muốn tài khoản không bao giờ hết hạn.", + "Deactivate expired client accounts": "Vô hiệu hóa các tài khoản khách hàng đã hết hạn", + "Default (:page)": "Mặc định (:page)", + "Start page": "Trang bắt đầu", + "The client account \":name\" expired and was deactivated": "Tài khoản khách hàng “:name” đã hết hạn và bị vô hiệu hóa", + "The page you land on after signing in.": "Trang bạn thấy sau khi đăng nhập.", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "Tài khoản này đã hết hạn vào :date và không thể đăng nhập nữa. Để cho họ truy cập lại, hãy chọn một ngày muộn hơn hoặc xóa ngày, và đảm bảo tài khoản đang hoạt động.", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "Ngày này đã qua. Hãy chọn một ngày muộn hơn, hoặc để trống nếu muốn tài khoản không bao giờ hết hạn.", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "Vai trò này không thể mở trang đó. Hãy cấp quyền “:permission” hoặc chọn một trang bắt đầu khác.", + "Until :date": "Đến :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "Trang mà người có vai trò này thấy sau khi đăng nhập. Mỗi người vẫn có thể tự chọn trang của mình trong hồ sơ.", + "Your account has expired.": "Tài khoản của bạn đã hết hạn." } diff --git a/lang/zh_CN.json b/lang/zh_CN.json index d693843b..ace18b2b 100644 --- a/lang/zh_CN.json +++ b/lang/zh_CN.json @@ -2075,5 +2075,19 @@ "Your account has been created. You will be able to log in once it is approved.": "你的账户已创建,通过审核后即可登录。", "Invite a client to share files with": "邀请一位与你共享文件的客户", ":clientName (:clientEmail) registered a client account": ":clientName(:clientEmail)注册了客户账户", - "A new client registered an account": "新客户注册了账户" + "A new client registered an account": "新客户注册了账户", + "Account expires": "账户到期时间", + "A client account reached its expiry date and was deactivated": "一个客户账户已到期并被停用", + "After this day the client can no longer sign in, and the account is deactivated. Their files stay. Leave empty for an account that never expires.": "过了这一天,客户将无法再登录,账户会被停用。其文件会保留。留空表示账户永不过期。", + "Deactivate expired client accounts": "停用已过期的客户账户", + "Default (:page)": "默认(:page)", + "Start page": "起始页", + "The client account \":name\" expired and was deactivated": "客户账户“:name”已过期并被停用", + "The page you land on after signing in.": "你登录后进入的页面。", + "This account expired on :date and can no longer sign in. To let them back in, choose a later date or clear it, and make sure the account is active.": "此账户已于 :date 过期,无法再登录。要恢复其访问权限,请选择更晚的日期或清除日期,并确认账户已启用。", + "This date has already passed. Choose a later date, or leave it empty for an account that never expires.": "这个日期已经过去。请选择更晚的日期,或留空表示账户永不过期。", + "This role cannot open that page. Give it the \":permission\" permission, or choose another start page.": "此角色无法打开该页面。请授予它“:permission”权限,或选择其他起始页。", + "Until :date": "至 :date", + "Where people with this role land after signing in. Each person can still choose their own in their profile.": "拥有此角色的人登录后进入的页面。每个人仍可在个人资料中选择自己的起始页。", + "Your account has expired.": "你的账户已过期。" } From 2768c87b27e1ed1bb4c6bef9c5fa75ba10b40be5 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Sun, 13 Sep 2026 15:38:11 -0300 Subject: [PATCH 4/6] Widen the start page picker so a translated default fits "Predeterminado (Panel de control)" was cut off in the old fixed width. The picker now takes the column's width, up to a readable maximum, and still fits a phone screen. --- resources/js/components/start-page-select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/start-page-select.tsx b/resources/js/components/start-page-select.tsx index cd41e56c..74bfde21 100644 --- a/resources/js/components/start-page-select.tsx +++ b/resources/js/components/start-page-select.tsx @@ -47,7 +47,7 @@ export function StartPageSelect({
)} + {uploader_options.length > 0 && ( + + + + )} + {role_options.length > 0 && ( + + + + )} + + + + + + + + +
not->toContain('Offroster Client'); }); +test('the uploader filter does not answer for a client the viewer may not identify', function () { + $stranger = fileFromStranger(); + + // The listing already withholds this client's name from the row (the + // test directly above). Left unguarded, the filter would hand the same + // identity back as a row count instead: filtering by an id that returns + // a file proves that client uploaded something here, which is the fact + // the redaction exists to withhold. So the id matches nothing -- + // indistinguishable from a client who has uploaded nothing at all. + $this->actingAs($this->manager)->get("/files?uploader={$this->offRoster->id}") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->has('files', 0)); + + // The file itself stays reachable; it is only the question about its + // uploader that goes unanswered. + $this->actingAs($this->manager)->get('/files') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->has('files', 1) + ->where('files.0.name', $stranger->name)); + + // And the dropdown never offers the name in the first place. + $body = $this->actingAs($this->manager)->get('/files')->assertOk()->getContent(); + expect($body)->not->toContain('Offroster Client'); +}); + test('the edit page does not name a stranger uploader or recipient', function () { $file = fileWithStrangerCoRecipient(); $file->update(['uploaded_by' => $this->offRoster->id]); diff --git a/tests/Feature/ListFilteringTest.php b/tests/Feature/ListFilteringTest.php index c01e9c16..d5369b61 100644 --- a/tests/Feature/ListFilteringTest.php +++ b/tests/Feature/ListFilteringTest.php @@ -3,10 +3,14 @@ declare(strict_types=1); use App\Models\User; +use App\Modules\Audit\Action; +use App\Modules\Audit\ActivityLog; use App\Modules\Files\Folders\FolderService; +use App\Modules\Files\Models\File; use App\Modules\Groups\Models\Group; use App\Modules\Groups\Models\MembershipRequest; use App\Modules\Identity\Models\Role; +use App\Modules\Identity\Permissions\SystemRole; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Inertia\Testing\AssertableInertia; @@ -109,6 +113,108 @@ test('the files list searches globally and flat across folders, paginated', func ->has('pagination')); }); +test('the files list filters by uploader, and by the uploader\'s role', function () { + $this->actingAs($this->admin); + + // Through the factory state rather than a name lookup: the built-in + // roles are materialized on demand, so querying for one by name in a + // fresh database returns null -- which made the role filter fall + // through as "no filter" and quietly pass on the wrong rows. + $editor = User::factory()->role(SystemRole::Uploader)->create(); + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'ByAdmin']); + File::factory()->create(['uploaded_by' => $editor->id, 'name' => 'ByEditor']); + + $this->get("/files?uploader={$editor->id}")->assertInertia(fn (AssertableInertia $page) => $page + ->where('searching', true) + ->has('files', 1) + ->where('files.0.name', 'ByEditor')); + + // The role filter reaches the same file through who uploaded it rather + // than through the file itself -- a different join, so it gets its own + // assertion instead of being assumed from the one above. + $this->get("/files?role={$editor->role_id}")->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 1) + ->where('files.0.name', 'ByEditor')); + + // Both dropdowns are built from files this viewer can see, so both + // uploaders are offered and each role appears once. + $this->get('/files')->assertInertia(fn (AssertableInertia $page) => $page + ->has('uploader_options', 2) + ->has('role_options', 2)); +}); + +test('the files list filters by public and private, counting a public folder as public', function () { + $this->actingAs($this->admin); + + $publicFolder = app(FolderService::class)->create('Brochures', null); + $publicFolder->update(['public' => true]); + + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'FlaggedPublic', 'public' => true]); + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'InPublicFolder', 'public' => false, 'folder_id' => $publicFolder->id]); + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'PlainPrivate', 'public' => false]); + + // The file in the public folder counts as public even though its own + // flag is false -- the same rule the row's own badge uses. Filtering on + // the column alone would have hidden a file this screen labels Public. + $this->get('/files?visibility=public')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 2) + ->where('files.0.name', 'FlaggedPublic') + ->where('files.1.name', 'InPublicFolder')); + + // And the private half must not lose the file sitting at the library + // root: `folder_id NOT IN (...)` is never true for a NULL folder_id. + $this->get('/files?visibility=private')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 1) + ->where('files.0.name', 'PlainPrivate')); +}); + +test('the files list separates files that were never downloaded from those that were', function () { + $this->actingAs($this->admin); + + $downloaded = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Grabbed']); + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Untouched']); + + ActivityLog::query()->create([ + 'actor_id' => $this->admin->id, + 'action' => Action::FileDownloaded, + 'subject_type' => $downloaded->getMorphClass(), + 'subject_id' => $downloaded->id, + 'created_at' => now(), + ]); + + $this->get('/files?downloads=none')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 1) + ->where('files.0.name', 'Untouched')); + + $this->get('/files?downloads=any')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 1) + ->where('files.0.name', 'Grabbed')); +}); + +test('the files list separates current versions from outdated ones', function () { + $this->actingAs($this->admin); + + $original = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'DraftOne']); + File::factory()->create([ + 'uploaded_by' => $this->admin->id, + 'name' => 'DraftTwo', + 'previous_file_id' => $original->id, + 'version_root_id' => $original->id, + ]); + File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'NeverVersioned']); + + // A file nothing replaced is current, and that includes one that was + // never versioned at all -- it is the current version of itself. + $this->get('/files?version=current')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 2) + ->where('files.0.name', 'DraftTwo') + ->where('files.1.name', 'NeverVersioned')); + + $this->get('/files?version=outdated')->assertInertia(fn (AssertableInertia $page) => $page + ->has('files', 1) + ->where('files.0.name', 'DraftOne')); +}); + test('the account and membership request queues are searchable', function () { $this->actingAs($this->admin); From 5f414c7a4a6d0b0b99664df6ac2efc56c81b9db2 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Wed, 16 Sep 2026 13:48:09 -0300 Subject: [PATCH 6/6] Give the API the same file filters the library screen has The staff library grew filters for uploader, role, public/private, download count and version. Two of those already existed on /api/v1/files (`uploaded_by`, `public`); the other four did not, so an integration could not ask what the screen asks. Adds `role_id`, `downloads=none|any`, `version=current|outdated` and `visibility=public|private`. `visibility` rather than changing `public`, and that is the decision worth explaining. `public` has always tested the file's own column, and callers depend on that answer; changing what an existing filter means is breaking for everyone already sending it, however much better the new meaning is. So `public` is untouched and `visibility` is added beside it with the application's own definition -- File::isEffectivelyPublic(), the flag or a public folder anywhere above the file -- which is what the badge on a staff row means. The guide says in a sentence which to reach for. Point the visibility filter at the column instead and the test that separates them fails, which is the whole point of having both. That predicate now lives once, as File::scopeEffectivelyPublic(), beside the isEffectivelyPublic() it has to agree with. It was a private helper on FoldersController until a second surface wanted it. `role_id` deliberately carries no identity guard, unlike `uploaded_by` beside it. A role names nobody: the files in the result are ones the caller may already read, and learning one came from somebody holding the Client role narrows to a set they could have guessed. `uploaded_by` is different in kind -- a non-empty answer confirms exactly the identity the response is redacting -- which is why only it is guarded. The reasoning is in the code, because an absent guard sitting next to a present one is the kind of thing a reader should not have to re-derive. Tests cover each filter, the public/visibility split, and the client-scoped negative: every new filter still returns nothing outside the token's own library, because a filter narrows a library and never widens one. --- .../Http/Controllers/Api/FilesController.php | 52 ++++++++++++++ .../Http/Controllers/FoldersController.php | 40 +---------- app/Modules/Files/Models/File.php | 35 +++++++++ docs/api-guide.md | 30 ++++++++ docs/api/openapi.json | 55 ++++++++++++++ tests/Feature/Api/FilesReadTest.php | 72 +++++++++++++++++++ 6 files changed, 245 insertions(+), 39 deletions(-) diff --git a/app/Modules/Files/Http/Controllers/Api/FilesController.php b/app/Modules/Files/Http/Controllers/Api/FilesController.php index a6e2de82..2a78b89d 100644 --- a/app/Modules/Files/Http/Controllers/Api/FilesController.php +++ b/app/Modules/Files/Http/Controllers/Api/FilesController.php @@ -102,6 +102,10 @@ class FilesController extends Controller 'uploaded_by' => ['nullable', 'integer'], 'search' => ['nullable', 'string', 'max:255'], 'public' => ['nullable', 'boolean'], + 'visibility' => ['nullable', 'in:public,private'], + 'role_id' => ['nullable', 'integer'], + 'downloads' => ['nullable', 'in:none,any'], + 'version' => ['nullable', 'in:current,outdated'], 'expired' => ['nullable', 'boolean'], ]); @@ -139,10 +143,58 @@ class FilesController extends Controller ->orWhere('files.original_name', 'like', "%{$search}%")); } + // Two overlapping questions, kept apart on purpose. + // + // `public` has always tested the column, and callers depend on that, + // so its meaning is left exactly as it was -- changing what an + // existing filter answers is a breaking change for everybody already + // asking it, whatever the new answer is. + // + // `visibility` is the question the staff library's own filter asks: + // File::isEffectivelyPublic(), the flag *or* a public folder anywhere + // above the file. That is what the badge on a row means, so it is + // what an integration comparing itself to the screen will expect. + // Prefer it; `public` remains for compatibility. if ($request->has('public') && ($filters['public'] ?? null) !== null) { $query->where('files.public', $request->boolean('public')); } + if (($filters['visibility'] ?? null) !== null) { + $query->effectivelyPublic($filters['visibility'] === 'public'); + } + + // No identity guard here, unlike `uploaded_by` directly above, and + // the difference is what the answer discloses. `uploaded_by` names a + // person: a non-empty result confirms *which* client uploaded a file + // whose uploader the response is redacting, which is the redaction + // undone. A role names nobody. The files in the result are ones this + // caller may already read, and learning that one of them came from + // somebody holding the Client role narrows to a set the caller could + // have guessed. Same reasoning, and same absence of a guard, as the + // staff library's own role filter -- the two surfaces must not + // disagree about what a role reveals. + if (($filters['role_id'] ?? null) !== null) { + $query->whereHas('uploader', fn (Builder $uploader) => $uploader->where('role_id', (int) $filters['role_id'])); + } + + // has/doesn't-have rather than a comparison on a count: an aggregate + // cannot be filtered in a WHERE, and a HAVING would be applied after + // the page has already been sliced. + if (($filters['downloads'] ?? null) !== null) { + $filters['downloads'] === 'none' + ? $query->whereDoesntHave('downloads') + : $query->whereHas('downloads'); + } + + // "current" includes a file that was never versioned at all -- it is + // the current version of itself. "outdated" is the word the version + // badge uses, so the filter and the row agree. + if (($filters['version'] ?? null) !== null) { + $filters['version'] === 'current' + ? $query->whereDoesntHave('nextVersion') + : $query->whereHas('nextVersion'); + } + // Expiry is a filter, not a default: staff see expired files in the // UI too (that is how they notice and act on them). Dropping them // is the client branch's rule, applied inside the visibility scopes diff --git a/app/Modules/Files/Http/Controllers/FoldersController.php b/app/Modules/Files/Http/Controllers/FoldersController.php index 02237d65..4314c32e 100644 --- a/app/Modules/Files/Http/Controllers/FoldersController.php +++ b/app/Modules/Files/Http/Controllers/FoldersController.php @@ -165,7 +165,7 @@ class FoldersController extends Controller ->when($downloads === 'any', fn (Builder $q) => $q->whereHas('downloads')) ->when($version === 'current', fn (Builder $q) => $q->whereDoesntHave('nextVersion')) ->when($version === 'outdated', fn (Builder $q) => $q->whereHas('nextVersion')) - ->when($visibility !== null, fn (Builder $q) => $this->constrainVisibility($q, $visibility === 'public')) + ->when($visibility !== null, fn (Builder $q) => $q->effectivelyPublic($visibility === 'public')) ->orderBy('name'); } else { $current = $request->integer('folder') > 0 @@ -589,42 +589,4 @@ class FoldersController extends Controller return $this->scope->folders($user)->findOrFail($parentId); } - - /** - * Narrow to files that are, or are not, publicly reachable. - * - * "Public" here means what the row's own badge means -- - * File::isEffectivelyPublic(), the file's own flag *or* its folder - * sitting anywhere in a public folder's live subtree. Filtering on the - * `public` column alone would have hidden files the same screen visibly - * labels Public, which is a filter that argues with the list it filters. - * - * The folder half is resolved once into a list of ids rather than as a - * correlated subquery, because Folder::scopePubliclyVisible() already - * expresses the subtree rule (a LIKE per public folder) and is the only - * place that rule should live. - * - * @param Builder $query - */ - private function constrainVisibility(Builder $query, bool $public): void - { - $publicFolderIds = Folder::query()->publiclyVisible()->pluck('id')->all(); - - if ($public) { - $query->where(fn (Builder $w) => $w - ->where('public', true) - ->orWhereIn('folder_id', $publicFolderIds)); - - return; - } - - // The null branch is not tidiness: `folder_id NOT IN (...)` is never - // true for a NULL folder_id, so a file at the library root would - // otherwise be neither public nor private and vanish from both - // halves of the filter. Proved by removing it -- the private half - // then returned nothing at all. - $query->where('public', false)->where(fn (Builder $w) => $w - ->whereNull('folder_id') - ->orWhereNotIn('folder_id', $publicFolderIds)); - } } diff --git a/app/Modules/Files/Models/File.php b/app/Modules/Files/Models/File.php index f080461e..7ab63ac6 100644 --- a/app/Modules/Files/Models/File.php +++ b/app/Modules/Files/Models/File.php @@ -316,6 +316,41 @@ class File extends Model return $this->public || ($this->folder?->isEffectivelyPublic() ?? false); } + /** + * The query-side twin of isEffectivelyPublic(): narrow to files that + * are, or are not, publicly reachable. + * + * Here rather than in a controller because two surfaces now ask it -- + * the staff library's visibility filter and /api/v1/files -- and a + * predicate that has to agree with isEffectivelyPublic() should not + * exist twice. The folder half resolves once into a list of ids rather + * than as a correlated subquery, because Folder::scopePubliclyVisible() + * already expresses the subtree rule and is the only place it lives. + * + * The null branch in the private half is not tidiness: `folder_id NOT + * IN (...)` is never true for a NULL folder_id, so a file at the + * library root would otherwise be neither public nor private and + * vanish from both halves of the filter. + * + * @param Builder $query + */ + public function scopeEffectivelyPublic(Builder $query, bool $public): void + { + $publicFolderIds = Folder::query()->publiclyVisible()->pluck('id')->all(); + + if ($public) { + $query->where(fn (Builder $inner) => $inner + ->where('files.public', true) + ->orWhereIn('files.folder_id', $publicFolderIds)); + + return; + } + + $query->where('files.public', false)->where(fn (Builder $inner) => $inner + ->whereNull('files.folder_id') + ->orWhereNotIn('files.folder_id', $publicFolderIds)); + } + /** * A client can access a file that is assigned to them directly or * via a group, that sits in a folder shared with them (self or diff --git a/docs/api-guide.md b/docs/api-guide.md index 89927cb8..f4b475dc 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -190,6 +190,36 @@ shares a timestamp with another. **Polling cannot see deletions.** A deleted row simply stops appearing. If you need to react to deletions, that is what webhooks will be for; they are not built yet. +### Narrowing the file listing + +`GET /api/v1/files` takes these, in any combination: + +| Parameter | Narrows to | +|---|---| +| `search` | name, description or original filename containing the term | +| `folder_id` | files directly in that folder | +| `category_id` | files carrying that category | +| `uploaded_by` | files uploaded by that user | +| `role_id` | files whose uploader holds that role | +| `downloads` | `none` — never downloaded · `any` — downloaded at least once | +| `version` | `current` — nothing has replaced it · `outdated` — a newer upload has | +| `visibility` | `public` — reachable by anyone with the link · `private` — not | +| `public` | the file's own public flag, `true` or `false` | +| `expired` | past its expiry date, or not | + +Two of those overlap and it is worth being deliberate about which you send. **`visibility`** asks +whether a file is *actually* reachable by a stranger holding the link — its own flag, or a public +folder anywhere above it. **`public`** tests only the file's own flag, so it will not return a file +that is public purely because of the folder it sits in. `visibility` is the one that matches what an +administrator sees on screen; `public` is kept as it always behaved for callers already using it. + +`version=current` includes files that were never versioned at all — a file with no history is the +current version of itself. + +`uploaded_by` is answered narrowly for a client-scoped token: an id belonging to a client that token +may not identify matches nothing, which is deliberately indistinguishable from a client who has +uploaded nothing. Filters never widen a library, only narrow one. + --- ## Reacting to things that happen diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 5906f69c..cf46692e 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1796,6 +1796,61 @@ ] } }, + { + "name": "visibility", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "public", + "private", + null + ] + } + }, + { + "name": "role_id", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ] + } + }, + { + "name": "downloads", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "none", + "any", + null + ] + } + }, + { + "name": "version", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "enum": [ + "current", + "outdated", + null + ] + } + }, { "name": "expired", "in": "query", diff --git a/tests/Feature/Api/FilesReadTest.php b/tests/Feature/Api/FilesReadTest.php index 0094be88..d39c71c9 100644 --- a/tests/Feature/Api/FilesReadTest.php +++ b/tests/Feature/Api/FilesReadTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); use App\Models\User; use App\Modules\Audit\Action; use App\Modules\Audit\ActivityLog; +use App\Modules\Files\Folders\FolderService; use App\Modules\Files\Models\Category; use App\Modules\Files\Models\File; use App\Modules\Identity\Permissions\Permission; @@ -108,6 +109,77 @@ test('filters narrow the listing', function () { ->and($ids('expired=0'))->not->toContain($expired->id); }); +test('the new filters narrow the listing the same way the staff library does', function () { + $uploader = User::factory()->role(SystemRole::Uploader)->create(); + + $grabbed = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'grabbed']); + $untouched = File::factory()->create(['uploaded_by' => $uploader->id, 'name' => 'untouched']); + $superseded = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'draft one']); + $current = File::factory()->create([ + 'uploaded_by' => $this->admin->id, + 'name' => 'draft two', + 'previous_file_id' => $superseded->id, + 'version_root_id' => $superseded->id, + ]); + + ActivityLog::query()->create([ + 'actor_id' => $this->admin->id, + 'action' => Action::FileDownloaded, + 'subject_type' => $grabbed->getMorphClass(), + 'subject_id' => $grabbed->id, + 'created_at' => now(), + ]); + + $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id'); + + expect($ids('downloads=any'))->toBe([$grabbed->id]) + ->and($ids('downloads=none'))->not->toContain($grabbed->id) + ->and($ids('downloads=none'))->toContain($untouched->id) + ->and($ids('version=outdated'))->toBe([$superseded->id]) + ->and($ids('version=current'))->toContain($current->id) + ->and($ids('version=current'))->not->toContain($superseded->id) + ->and($ids("role_id={$uploader->role_id}"))->toBe([$untouched->id]); +}); + +test('visibility asks the effective question and public still asks the column', function () { + $folder = app(FolderService::class)->create('Brochures', null); + $folder->update(['public' => true]); + + $flagged = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'flagged', 'public' => true]); + $inherited = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'inherited', 'public' => false, 'folder_id' => $folder->id]); + $private = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'plain', 'public' => false]); + + $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id'); + + // The whole reason both exist. `public` reads the column, so the file + // that is public only by inheritance is absent -- and callers already + // depend on that answer, which is why its meaning was left alone. + expect($ids('public=1'))->toBe([$flagged->id]) + ->and($ids('public=1'))->not->toContain($inherited->id); + + // `visibility` reads File::isEffectivelyPublic(), which is what the + // badge on the staff row means, so the inherited one counts. + expect($ids('visibility=public'))->toContain($flagged->id) + ->and($ids('visibility=public'))->toContain($inherited->id) + ->and($ids('visibility=private'))->toBe([$private->id]); +}); + +test('a client-scoped token cannot use the new filters to reach past its own library', function () { + $manager = User::factory()->role(SystemRole::ClientManager)->create(); + $token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken; + + // Nothing here is the manager's: a file they did not upload, for a + // client they do not hold. Every filter must still come back empty -- + // a filter narrows a library, it never widens one. + $stranger = User::factory()->create(); + File::factory()->create(['uploaded_by' => $stranger->id, 'name' => 'not theirs', 'public' => true]); + + foreach (['downloads=none', 'version=current', 'visibility=public', 'visibility=private', "role_id={$stranger->role_id}"] as $query) { + expect($this->withToken($token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data')) + ->toBe([], "filter '{$query}' leaked past the client-scoped boundary"); + } +}); + test('a malformed updated_since is rejected rather than ignored', function () { // Silently ignoring it would make a polling client re-read the whole // library every tick and never find out why.