Merge remote-tracking branch 'origin/main' into virus-scanning

# Conflicts:
#	tests/Feature/Platform/SchedulerMonitoringTest.php
This commit is contained in:
ignacionelson
2026-09-16 23:57:19 -03:00
65 changed files with 2413 additions and 91 deletions
@@ -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));
}
/**
@@ -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) : [],
]);
@@ -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
+35
View File
@@ -29,9 +29,11 @@ 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
* @property \Carbon\Carbon|null $expires_at
* @property-read Role|null $role
*/
class User extends Authenticatable implements HasLocalePreference
@@ -54,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',
];
@@ -96,6 +101,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 +214,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',
@@ -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);
}
+6
View File
@@ -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';
@@ -188,6 +192,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"',
@@ -302,6 +307,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',
+29 -1
View File
@@ -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.'),
]);
}
}
}
@@ -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,
]);
}
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients\Console;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\UserType;
use Illuminate\Console\Command;
/**
* Switches off client accounts whose expiry date has passed.
*
* Not what stops an expired client getting in User::maySignIn() does
* that the moment the date passes, with or without this. What this does is
* make `active` tell the truth, so everything that reads the flag rather
* than asking the account agrees: the client list and its status filter,
* the API's `active` field, and a managed plan's seat count. Hourly for the
* same reason purge-stale-uploads is: a seat held by an account that can
* no longer use it is a seat somebody else cannot have.
*/
class ExpireClientAccountsCommand extends Command
{
protected $signature = 'projectsend:expire-client-accounts';
protected $description = 'Deactivate client accounts whose expiry date has passed (runs hourly)';
public function handle(ActivityLogger $activity): int
{
$now = now();
$expired = 0;
$due = User::query()
->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;
}
}
@@ -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.
@@ -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
@@ -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(),
];
+10 -31
View File
@@ -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);
}
}
@@ -103,6 +103,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'],
// One of pending, clean, infected, released, not_scanned or
// unscannable_blocked — so an integration can wait for a file
@@ -144,10 +148,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
@@ -24,6 +24,7 @@ use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use App\Support\PublicUrl;
@@ -87,6 +88,15 @@ class FoldersController extends Controller
'search' => ['nullable', 'string', 'max:255'],
'folder' => ['nullable', 'integer'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'uploader' => ['nullable', 'integer', 'exists:users,id'],
'visibility' => ['nullable', 'in:public,private'],
'downloads' => ['nullable', 'in:none,any'],
'role' => ['nullable', 'integer', 'exists:roles,id'],
// "current" is every file nothing has replaced, which includes
// a file that was never versioned at all -- it is the current
// version of itself. "outdated" is the same word the version
// badge uses, so the filter and the row agree.
'version' => ['nullable', 'in:current,outdated'],
// Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1',
// rejecting the literal "true"/"" the frontend checkbox sends.
// $request->boolean() below coerces any of those safely, so
@@ -95,12 +105,25 @@ class FoldersController extends Controller
$search = trim($validated['search'] ?? '');
$searching = $search !== '';
$categoryId = $validated['category'] ?? null;
// Cast, because `integer` validates a numeric string without
// converting it -- so these arrive as "5" from the query string.
// permitsClientId() below takes a strict ?int and 500s on a string,
// and the props these become are typed `number | null` on the page.
$uploaderId = isset($validated['uploader']) ? (int) $validated['uploader'] : null;
$visibility = $validated['visibility'] ?? null;
$downloads = $validated['downloads'] ?? null;
$roleId = isset($validated['role']) ? (int) $validated['role'] : null;
$version = $validated['version'] ?? null;
$expired = $request->boolean('expired');
// A search term, a category filter, or the expired-only filter all
// switch to a flat view across the whole visible library;
// otherwise it's folder browsing.
$flat = $searching || $categoryId !== null || $expired;
// A search term or any filter switches to a flat view across the
// whole visible library; otherwise it's folder browsing. Every
// filter here is a property of a *file*, so in flat mode the folder
// sequence stays empty unless there is a search term to match names
// against -- which is what the existing branch below already does.
$flat = $searching || $categoryId !== null || $expired
|| $uploaderId !== null || $visibility !== null
|| $downloads !== null || $roleId !== null || $version !== null;
$folderQuery = $this->scope->folders($user)->withCount(['children', 'files']);
// `downloads` unconditionally — the library has always shown a
@@ -134,6 +157,29 @@ class FoldersController extends Controller
->when($categoryId !== null, fn (Builder $q) => $q
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
->when($expired, fn (Builder $q) => $q->expired())
// The same guard /api/v1/files puts on `uploaded_by`, and it
// is needed for the same reason. A filter is a question, and
// this one asks "did user N put anything into my library".
// fileRow() already withholds an uploader's name from a
// viewer who may not identify them -- so answering this
// plainly would hand back, as a row count, precisely the
// identity the row itself is redacting. An id this caller
// may not identify matches nothing, which is
// indistinguishable from someone who has uploaded nothing.
->when($uploaderId !== null && ! $this->identity->permitsClientId($user, $uploaderId),
fn (Builder $q) => $q->whereRaw('1 = 0'))
->when($uploaderId !== null, fn (Builder $q) => $q->where('uploaded_by', $uploaderId))
->when($roleId !== null, fn (Builder $q) => $q
->whereHas('uploader', fn (Builder $u) => $u->where('role_id', $roleId)))
// has/doesn't-have rather than a comparison on the
// withCount alias: an aggregate cannot be filtered in a
// WHERE, and `downloads_count = 0` in a HAVING would be
// applied after the pagination slice above.
->when($downloads === 'none', fn (Builder $q) => $q->whereDoesntHave('downloads'))
->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) => $q->effectivelyPublic($visibility === 'public'))
->orderBy('name');
} else {
$current = $request->integer('folder') > 0
@@ -176,6 +222,11 @@ class FoldersController extends Controller
'search' => $search !== '' ? $search : null,
'folder' => $current?->id,
'category' => $categoryId,
'uploader' => $uploaderId,
'visibility' => $visibility,
'downloads' => $downloads,
'role' => $roleId,
'version' => $version,
'expired' => $expired ? 'true' : null,
'page' => Pagination::redirectPage($sliced['paginator']),
]));
@@ -190,6 +241,15 @@ class FoldersController extends Controller
// as the comment counts above.
$versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false));
// Two queries for the whole page, not one per row. `distinct` on an
// indexed foreign key rather than a join, because all this needs is
// the set of ids -- the names come back with the roles in one go.
$uploaders = User::query()
->whereIn('id', $this->scope->files($user)->whereNotNull('uploaded_by')->distinct()->pluck('uploaded_by'))
->with('role')
->orderBy('name')
->get(['id', 'name', 'role_id']);
return Inertia::render('files/index', [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current),
@@ -199,6 +259,11 @@ class FoldersController extends Controller
'search' => $search,
'searching' => $flat,
'category' => $categoryId,
'uploader' => $uploaderId,
'visibility' => $visibility,
'downloads' => $downloads,
'role' => $roleId,
'version' => $version,
'expired' => $expired,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
@@ -208,6 +273,18 @@ class FoldersController extends Controller
// every folder name and id on the installation.
'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get()
->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(),
// Only people who actually uploaded something *this viewer can
// see*, and their roles taken from the same set. Narrowed for
// the reason folder_options directly above is: an unscoped list
// would hand a client-scoped staffer the name and id of every
// account on the installation, through a filter dropdown.
// Through filterClientPairs, so the dropdown never offers a name
// this viewer may not be told -- the same rule fileRow() applies
// to the uploader on each row, asked once for the whole list.
'uploader_options' => $this->identity->filterClientPairs($user, array_values($uploaders
->map(fn (User $uploader): array => ['id' => $uploader->id, 'name' => $uploader->name])->all())),
'role_options' => $uploaders->pluck('role')->filter()->unique('id')->sortBy('name')->values()
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
'can_create_folders' => $user->can('create_own_folders'),
'can_upload' => $user->can('upload'),
'can_manage_public' => $user->can('upload_public'),
+35
View File
@@ -378,6 +378,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<File> $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
@@ -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) {
@@ -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<mixed>
*/
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<string> $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<string> $permissions
*/
@@ -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
@@ -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
@@ -90,6 +92,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;
}
}
@@ -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.'),
]));
}
+1
View File
@@ -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
*/
+10 -3
View File
@@ -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.');
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Identity\Permissions\Permission;
/**
* A page somebody can be sent to after signing in.
*
* One case per idea rather than per route: "Files" is the library for
* staff and the portal's own list for a client, so a single vocabulary
* serves both kinds of account and a role never has to know which
* routes its members can reach. Two of them only mean something to staff.
*
* requiredPermission() must name what the route's own middleware asks
* for. It is what keeps somebody from being sent to a page that answers
* them with a 403, and StartPageTest checks it against the real routes
* rather than trusting this file to stay in step.
*/
enum StartPage: string
{
case Dashboard = 'dashboard';
case Files = 'files';
case Upload = 'upload';
case Groups = 'groups';
case Clients = 'clients';
case Activity = 'activity';
/**
* The choices offered to one kind of account, in menu order.
*
* @return list<self>
*/
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);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Updates\UpdateWelcome;
/**
* Where an account lands after signing in, and what it may choose from.
*
* The person's own choice wins, then their role's, then the dashboard.
* Each is used only if the account can actually open that page *now*:
* permissions change after a choice is saved, and a start page that
* answers 403 is worse than no start page. So an unreachable choice is
* skipped rather than obeyed, and the next one down is tried.
*
* A waiting greeting beats all of them. The getting-started list and the
* what's-new page are reached through the dashboard (RedirectToGreeting
* sits on that route alone), so an administrator who starts somewhere
* else would otherwise never see either.
*/
class StartPages
{
public function __construct(
private readonly InstallationWelcome $installation,
private readonly UpdateWelcome $update,
) {}
/**
* The path to send this account to. Relative, for redirect()->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<array{value: string, label: string}>
*/
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<array{value: string, label: string, permission: string|null}>
*/
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;
}
}
@@ -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'),
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Localization;
use App\Models\User;
use Carbon\Carbon;
/**
* A stored instant, read and written as a calendar day in the zone of
* whoever is looking.
*
* Every expiry in the application is stored as an instant, and every
* person sets one with a date input. "The 12th" means the end of the 12th
* where *they* live otherwise something 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 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 changed
* something else and the date came along for the ride". Re-deriving on
* every save instead moves the instant by the difference between two
* people's zones each time somebody edits anything.
*
* Shared by a file's expiry (FileExpiry) and a client account's.
*/
class DateInput
{
public function __construct(
private readonly TimezoneRegistry $timezones,
) {}
/**
* The stored instant as the calendar day a form should show, in the
* viewer's zone. Null when there is no instant.
*/
public function asShown(?Carbon $instant, ?User $viewer): ?string
{
return $instant?->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);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// The instant a client account stops working. Null means it
// never does. See User::hasExpired() for how it is enforced,
// and ExpireClientAccountsCommand for why `active` is also
// switched off once it passes.
$table->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');
});
}
};
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Where somebody lands after signing in: a StartPage value, or null
// for the dashboard. The role holds the default for everyone in it;
// the user column is that person's own choice, and wins. Plain
// strings rather than an enum column, and not cast to the enum
// either — a value a later version stops offering must fall back to
// the dashboard, not fail to load the account. See StartPages.
Schema::table('roles', function (Blueprint $table) {
$table->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');
});
}
};
+30
View File
@@ -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
+80
View File
@@ -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": {
@@ -1780,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",
@@ -3548,6 +3619,7 @@
"client.invitation_redeemed",
"client.invitation_revoked",
"client.invitation_resent",
"client.expired",
"file.uploaded",
"file.updated",
"file.deleted",
@@ -3748,6 +3820,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",
@@ -3828,6 +3907,7 @@
"active",
"account_requested",
"two_factor_enabled",
"expires_at",
"created_at",
"updated_at",
"storage",
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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é."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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.": "アカウントの有効期限が切れました。"
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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.": "Срок действия вашей учётной записи истёк."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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."
}
+15 -1
View File
@@ -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.": "你的账户已过期。"
}
@@ -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 (
<div className="grid gap-2">
<Label htmlFor="expires_at">{t('Account expires')}</Label>
<Input id="expires_at" type="date" className="w-48" value={value} onChange={(e) => onChange(e.target.value)} />
{expired && value !== '' ? (
<p className="text-destructive text-xs">
{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),
})}
</p>
) : (
<p className="text-muted-foreground text-xs">
{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.')}
</p>
)}
<InputError message={error} />
</div>
);
}
@@ -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 (
<div className="grid gap-2">
<Label htmlFor="start_page">{t('Start page')}</Label>
<Select value={selected} onValueChange={(v) => onChange(v === INHERIT ? null : v)}>
<SelectTrigger id="start_page" className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{inheritLabel && <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>}
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={grantedPermissions !== undefined && !!option.permission && !grantedPermissions.includes(option.permission)}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">{description}</p>
<InputError message={error} />
</div>
);
}
+5
View File
@@ -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<string, string>;
}
@@ -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
<InputError message={errors.storage_quota_mb} />
</div>
<ClientExpiryField value={data.expires_at} onChange={(value) => setData('expires_at', value)} error={errors.expires_at} />
<ClientCustomFieldsSection
fields={custom_fields}
values={data.custom_field_values}
+14
View File
@@ -4,6 +4,7 @@ import { FormEventHandler } from 'react';
import { AccountContentDeleteDialog, type ReassignCandidate } from '@/components/account-content-delete-dialog';
import { ClientCustomFieldsSection, type CustomFieldDefinition } from '@/components/client-custom-fields-section';
import { ClientExpiryField } from '@/components/client-expiry-field';
import { ConfirmDialog } from '@/components/confirm-dialog';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
@@ -26,6 +27,8 @@ interface ClientsEditProps {
account_requested: boolean;
storage_quota_mb: number;
two_factor_enabled: boolean;
expires_at: string | null;
expired: boolean;
};
default_storage_quota_mb: number;
storage_used_mb: number;
@@ -43,6 +46,7 @@ interface ClientFormData {
password: string;
password_confirmation: string;
storage_quota_mb: string;
expires_at: string;
custom_field_values: Record<string, string>;
}
@@ -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({
)}
<InputError message={errors.active} />
<ClientExpiryField
value={data.expires_at}
onChange={(value) => setData('expires_at', value)}
error={errors.expires_at}
expired={client.expired && data.expires_at === (client.expires_at ?? '')}
/>
<div className="grid gap-2">
<Label htmlFor="storage_quota_mb">{t('Storage quota (MB)')}</Label>
<Input
+18 -1
View File
@@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFormatDate } from '@/hooks/use-format-date';
import { ALL, useListQuery } from '@/hooks/use-list-query';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
@@ -22,6 +23,8 @@ interface ClientRow {
email: string;
active: boolean;
account_requested: boolean;
expires_on: string | null;
expired: boolean;
created_at: string | null;
content: { files: number; folders: number };
}
@@ -41,6 +44,7 @@ interface ClientsIndexProps {
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates, seats }: ClientsIndexProps) {
const { t } = useTranslation();
const { calendarDate } = useFormatDate();
const { auth } = usePage<SharedData>().props;
const can = (permission: string) => auth.permissions.includes(permission);
@@ -58,7 +62,20 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca
return <Badge variant="outline">{t('Pending approval')}</Badge>;
}
return <Badge variant={client.active ? 'secondary' : 'destructive'}>{client.active ? t('Active') : t('Inactive')}</Badge>;
// 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 <Badge variant="destructive">{t('Expired')}</Badge>;
}
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant={client.active ? 'secondary' : 'destructive'}>{client.active ? t('Active') : t('Inactive')}</Badge>
{client.active && client.expires_on && (
<span className="text-muted-foreground text-xs">{t('Until :date', { date: calendarDate(client.expires_on) })}</span>
)}
</div>
);
};
return (
+96 -3
View File
@@ -103,6 +103,13 @@ interface FilesIndexProps {
searching: boolean;
category: number | null;
categories: CategoryTag[];
uploader: number | null;
visibility: 'public' | 'private' | null;
downloads: 'none' | 'any' | null;
role: number | null;
version: 'current' | 'outdated' | null;
uploader_options: Crumb[];
role_options: Crumb[];
expired: boolean;
can_create_folders: boolean;
can_upload: boolean;
@@ -121,6 +128,13 @@ export default function FilesIndex({
searching,
category,
categories,
uploader,
visibility,
downloads,
role,
version,
uploader_options,
role_options,
expired,
can_create_folders,
can_upload,
@@ -148,7 +162,7 @@ export default function FilesIndex({
useEffect(() => {
setSelectedFileIds(new Set());
setSelectedFolderIds(new Set());
}, [folder?.id, search, category, expired, pagination.page]);
}, [folder?.id, search, category, uploader, visibility, downloads, role, version, expired, pagination.page]);
const toggleFile = (id: number) =>
setSelectedFileIds((current) => {
@@ -181,8 +195,17 @@ export default function FilesIndex({
// dropping the current folder context.
const { values, set, reset, hasFilters } = useListQuery(
'files.index',
{ search, category: category === null ? ALL : String(category), expired: expired ? 'true' : '' },
{ search: '', category: ALL, expired: '' },
{
search,
category: category === null ? ALL : String(category),
uploader: uploader === null ? ALL : String(uploader),
visibility: visibility ?? ALL,
downloads: downloads ?? ALL,
role: role === null ? ALL : String(role),
version: version ?? ALL,
expired: expired ? 'true' : '',
},
{ search: '', category: ALL, uploader: ALL, visibility: ALL, downloads: ALL, role: ALL, version: ALL, expired: '' },
);
// A small drag threshold so clicking action buttons never starts a drag.
@@ -407,6 +430,76 @@ export default function FilesIndex({
</Select>
</FilterField>
)}
{uploader_options.length > 0 && (
<FilterField label={t('Uploaded by')} htmlFor="files-uploader">
<Select value={values.uploader} onValueChange={(v) => set('uploader', v)}>
<SelectTrigger id="files-uploader" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('All users')}</SelectItem>
{uploader_options.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FilterField>
)}
{role_options.length > 0 && (
<FilterField label={t('Uploader role')} htmlFor="files-role">
<Select value={values.role} onValueChange={(v) => set('role', v)}>
<SelectTrigger id="files-role" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('All roles')}</SelectItem>
{role_options.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FilterField>
)}
<FilterField label={t('Visibility')} htmlFor="files-visibility">
<Select value={values.visibility} onValueChange={(v) => set('visibility', v)}>
<SelectTrigger id="files-visibility" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('All files')}</SelectItem>
<SelectItem value="public">{t('Public')}</SelectItem>
<SelectItem value="private">{t('Private')}</SelectItem>
</SelectContent>
</Select>
</FilterField>
<FilterField label={t('Downloads')} htmlFor="files-downloads">
<Select value={values.downloads} onValueChange={(v) => set('downloads', v)}>
<SelectTrigger id="files-downloads" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('Any number of downloads')}</SelectItem>
<SelectItem value="none">{t('Never downloaded')}</SelectItem>
<SelectItem value="any">{t('Downloaded at least once')}</SelectItem>
</SelectContent>
</Select>
</FilterField>
<FilterField label={t('Version')} htmlFor="files-version">
<Select value={values.version} onValueChange={(v) => set('version', v)}>
<SelectTrigger id="files-version" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t('All versions')}</SelectItem>
<SelectItem value="current">{t('Current version')}</SelectItem>
<SelectItem value="outdated">{t('Outdated')}</SelectItem>
</SelectContent>
</Select>
</FilterField>
<FilterField label={t('Expired')} htmlFor="files-expired">
<div className="flex h-9 items-center gap-2">
<Checkbox
+15 -2
View File
@@ -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}
/>
<StartPageSelect
value={data.start_page}
onChange={(value) => 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.')}
/>
<Button type="submit" disabled={processing}>
{t('Create role')}
</Button>
+51 -6
View File
@@ -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) {
<Heading title={displayName} description={t(':count accounts have this role', { count: role.users_count })} />
{role.is_administrator ? (
<Alert>
<ShieldCheck className="size-4" />
<AlertDescription>{t('The administrator role always has every permission and cannot be edited.')}</AlertDescription>
</Alert>
<div className="space-y-6">
<Alert>
<ShieldCheck className="size-4" />
<AlertDescription>{t('The administrator role always has every permission and cannot be edited.')}</AlertDescription>
</Alert>
<form
onSubmit={(e) => {
e.preventDefault();
adminForm.patch(route('roles.update', role.id));
}}
className="space-y-6"
>
<StartPageSelect
value={adminForm.data.start_page}
onChange={(value) => adminForm.setData('start_page', value)}
options={start_page_options}
error={adminForm.errors.start_page}
description={startPageDescription}
/>
<div className="flex items-center gap-4">
<Button type="submit" disabled={adminForm.processing}>
{t('Save')}
</Button>
<SavedIndicator recentlySuccessful={adminForm.recentlySuccessful} />
</div>
</form>
</div>
) : (
<form onSubmit={submit} className="space-y-6">
<RoleForm
@@ -83,6 +119,15 @@ export default function RolesEdit({ role, catalog }: RolesEditProps) {
errors={errors}
/>
<StartPageSelect
value={data.start_page}
onChange={(value) => setData('start_page', value)}
options={start_page_options}
grantedPermissions={data.permissions}
error={errors.start_page}
description={startPageDescription}
/>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>
{t('Save')}
+18
View File
@@ -6,6 +6,7 @@ import { ClientCustomFieldsSection, type CustomFieldDefinition } from '@/compone
import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import { SaveButton } from '@/components/save-button';
import { StartPageSelect, type StartPageOption } from '@/components/start-page-select';
import { TimezonePicker, type TimezoneOption } from '@/components/timezone-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -20,6 +21,9 @@ export default function Profile({
custom_field_values,
timezone,
timezones,
start_page,
start_page_options,
role_start_page,
}: {
mustVerifyEmail: boolean;
status?: string;
@@ -27,6 +31,10 @@ export default function Profile({
custom_field_values: Record<string, string>;
timezone: string;
timezones: TimezoneOption[];
start_page: string | null;
start_page_options: StartPageOption[];
/** The page this person lands on when they choose nothing, already translated. */
role_start_page: string;
}) {
const { t } = useTranslation();
const { auth } = usePage<SharedData>().props;
@@ -43,6 +51,7 @@ export default function Profile({
email: auth.user.email,
custom_field_values,
timezone,
start_page,
current_password: '',
});
@@ -134,6 +143,15 @@ export default function Profile({
<InputError className="mt-2" message={errors.timezone} />
</div>
<StartPageSelect
value={data.start_page}
onChange={(value) => 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.')}
/>
<ClientCustomFieldsSection
fields={custom_fields}
values={data.custom_field_values}
+4
View File
@@ -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();
+8 -2
View File
@@ -46,10 +46,16 @@ use App\Modules\Identity\Http\Controllers\UsersController;
use App\Modules\Notifications\Http\Controllers\NotificationsController;
use App\Modules\Platform\Http\Controllers\LocaleController;
use App\Modules\Platform\Http\Controllers\TimezoneController;
use App\Modules\Identity\StartPages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->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'])
+72
View File
@@ -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.
+13
View File
@@ -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');
});
+308
View File
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\AccountConversion;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->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');
});
@@ -11,6 +11,7 @@ use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
/**
* A client-scoped staff member may hold a file whose uploader, or whose
@@ -189,6 +190,31 @@ test('the library listing does not describe a stranger uploader', function () {
expect($body)->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]);
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Auth;
use PragmaRX\Google2FA\Google2FA;
beforeEach(function () {
// The main administrator. Also what EnsureSetupIsComplete needs.
$this->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<Permission> $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');
});
+106
View File
@@ -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);
@@ -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', 13));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 14));
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')