Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
dbc927b6a9 Add core support for extendable authentication
add support for passwordless user creation; add filament loading support
for new laravel modules namespacing;
add support for pluggable password reset and login rules
2026-06-23 18:32:54 +02:00
Constantin Graf
c94aa8038d Add base path to the vite config to be able to tunnel all vite assets through a CDN 2026-06-20 21:55:29 +02:00
10 changed files with 514 additions and 198 deletions

View File

@@ -5,8 +5,12 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords; use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords class ResetUserPassword implements ResetsUserPasswords
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
*/ */
public function reset(User $user, array $input): void public function reset(User $user, array $input): void
{ {
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
/** @var PasswordBroker $broker */
$broker = Password::broker(config('fortify.passwords'));
$broker->deleteToken($user);
throw ValidationException::withMessages([
'email' => [__('This password reset link is invalid.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'password' => $this->passwordRules(), 'password' => $this->passwordRules(),
])->validate(); ])->validate();

View File

@@ -145,11 +145,21 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF'; return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
} }
public function canAccessPanel(Panel $panel): bool public function isSuperAdmin(): bool
{ {
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail(); return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
} }
public function hasLocalPassword(): bool
{
return is_string($this->password) && $this->password !== '';
}
public function canAccessPanel(Panel $panel): bool
{
return $this->isSuperAdmin();
}
public function isMemberOfOrganization(Organization $organization): bool public function isMemberOfOrganization(Organization $organization): bool
{ {
if ($this->relationLoaded('organizations')) { if ($this->relationLoaded('organizations')) {

View File

@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module; use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
@@ -91,22 +92,77 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled(); $modules = Module::allEnabled();
foreach ($modules as $module) { foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources( $panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'), in: module_path($module->getName(), 'app/Filament/Resources'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources' for: $moduleNamespace.'\\Filament\\Resources'
); );
$panel->discoverPages( $panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'), in: module_path($module->getName(), 'app/Filament/Pages'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages' for: $moduleNamespace.'\\Filament\\Pages'
); );
$panel->discoverWidgets( $panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'), in: module_path($module->getName(), 'app/Filament/Widgets'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets' for: $moduleNamespace.'\\Filament\\Widgets'
); );
} }
return $panel; return $panel;
} }
/** @var array<string, string> Cache of module name => resolved app namespace. */
private static array $moduleAppNamespaces = [];
private function getModuleAppNamespace(LaravelModule $module): string
{
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
}
/**
* Resolve the PHP namespace mapped to a module's app/ directory so the
* Filament panel can discover its Resources/Pages/Widgets under the right
* namespace.
*
* Two module layouts currently coexist in this repo:
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
* app/ e.g. "Extensions\SSO\" => app/, so classes are
* Extensions\SSO\Filament\... (this is the current convention).
* - the older layout: an "...\App" namespace maps to app/ e.g.
* "Extensions\Billing\App\" => app/, so classes are
* Extensions\Billing\App\Filament\...
*
* The package's own namespace derivation assumes the v12 (bare) layout and
* would mis-resolve the legacy modules, so we read each module's composer
* PSR-4 map and use whichever namespace actually points at app/. The legacy
* "...\App" shape is only a fallback for when composer is missing/unreadable.
* Once every module adopts the bare layout this collapses to
* config('modules.namespace').'\\'.$module->getName().
*/
private function resolveModuleAppNamespace(LaravelModule $module): string
{
$fallback = 'Extensions\\'.$module->getName().'\\App';
$composerPath = module_path($module->getName(), 'composer.json');
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
}
foreach ((array) $psr4 as $namespace => $path) {
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
return rtrim($namespace, '\\');
}
}
return $fallback;
}
private function normalizeComposerPath(mixed $path): string
{
return trim(str_replace('\\', '/', (string) $path), '/');
}
} }

View File

@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
/**
* Dummy bcrypt hash compared against when no user matches the submitted
* email. Hash::check is run against it so login takes the same time whether
* or not the email exists otherwise an unknown email would skip the
* (deliberately slow) hash and return faster, letting an attacker enumerate
* registered accounts by timing the response. The plaintext is irrelevant:
* it is only ever checked against attacker-supplied input and never matches.
*/
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
/**
* Authorization rules applied AFTER the password is verified. Each rule
* receives the authenticated user + request and returns whether the login
* may proceed; any rule returning false denies it. This is an extension
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
* instead of replacing this credential check which would silently drift
* from the host logic the next time it changes.
*
* @var array<int, \Closure(User, Request): bool>
*/
protected static array $loginRules = [];
/**
* Authorization rules applied before a password reset is completed. Rules
* receive the user being reset + submitted input and return whether the
* local reset flow may set a new password for that account.
*
* @var array<int, \Closure(User, array<string, mixed>): bool>
*/
protected static array $passwordResetRules = [];
/**
* Register an additional rule that gates password login (see $loginRules).
*
* @param \Closure(User, Request): bool $rule
*/
public static function authenticateUsingRule(\Closure $rule): void
{
static::$loginRules[] = $rule;
}
/**
* Register an additional rule that gates password reset completion.
*
* @param \Closure(User, array<string, mixed>): bool $rule
*/
public static function resetPasswordUsingRule(\Closure $rule): void
{
static::$passwordResetRules[] = $rule;
}
/**
* Check whether the given user may complete the local password reset flow.
*
* @param array<string, mixed> $input
*/
public static function canResetPassword(User $user, array $input = []): bool
{
foreach (static::$passwordResetRules as $rule) {
if (! $rule($user, $input)) {
return false;
}
}
return true;
}
/** /**
* Register any application services. * Register any application services.
*/ */
@@ -92,7 +159,23 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->first(); ->first();
if ($user !== null && Hash::check($request->password, $user->password)) { // Always run the hash check — against the real hash, or a dummy when
// there is no user — so login timing is identical either way (see
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
// have password = null) fail here, so they cannot password-login.
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
if ($user !== null && $passwordIsValid) {
// Credentials are valid; now apply any registered authorization
// rules (e.g. SSO enforcement may still block password login).
foreach (static::$loginRules as $rule) {
if (! $rule($user, $request)) {
return null;
}
}
return $user; return $user;
} }

View File

@@ -48,6 +48,56 @@ class UserService
} }
$user->save(); $user->save();
$this->createDefaultOrganizationForUser(
$user,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
return $user;
}
/**
* Create a user without a password (e.g. provisioned via SSO). Such users
* can only authenticate through a linked identity provider.
*/
public function createPasswordlessUser(
string $name,
string $email,
string $timezone,
Weekday $weekStart,
?string $currency,
bool $verifyEmail = false
): User {
$user = new User;
$user->name = $name;
$user->email = strtolower($email);
$user->password = null;
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$this->createDefaultOrganizationForUser($user, $currency);
return $user;
}
private function createDefaultOrganizationForUser(
User $user,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): void {
$organizations = app(InvitationService::class)->processAcceptedInvitations($user); $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) { if ($organizations->isEmpty()) {
@@ -64,8 +114,6 @@ class UserService
); );
$this->switchCurrentOrganization($user, $organization); $this->switchCurrentOrganization($user, $organization);
} }
return $user;
} }
/** /**

328
composer.lock generated
View File

@@ -3020,26 +3020,25 @@
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.12.1", "version": "7.10.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/guzzle.git", "url": "https://github.com/guzzle/guzzle.git",
"reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425" "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
"reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425", "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.5", "guzzlehttp/promises": "^2.3",
"guzzlehttp/psr7": "^2.12.1", "guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/deprecation-contracts": "^2.2 || ^3.0"
"symfony/polyfill-php80": "^1.24"
}, },
"provide": { "provide": {
"psr/http-client-implementation": "1.0" "psr/http-client-implementation": "1.0"
@@ -3048,7 +3047,7 @@
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*", "ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2", "guzzle/client-integration-tests": "3.0.2",
"guzzlehttp/test-server": "^0.5.1", "guzzlehttp/test-server": "^0.3.2",
"php-http/message-factory": "^1.1", "php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34", "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0" "psr/log": "^1.1 || ^2.0 || ^3.0"
@@ -3128,7 +3127,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/guzzle/issues", "issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.12.1" "source": "https://github.com/guzzle/guzzle/tree/7.10.3"
}, },
"funding": [ "funding": [
{ {
@@ -3144,25 +3143,24 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-18T14:12:49+00:00" "time": "2026-05-20T22:59:19+00:00"
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
"version": "2.5.0", "version": "2.4.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/promises.git", "url": "https://github.com/guzzle/promises.git",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e" "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2",
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0"
"symfony/deprecation-contracts": "^2.5 || ^3.0"
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
@@ -3212,7 +3210,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/promises/issues", "issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.0" "source": "https://github.com/guzzle/promises/tree/2.4.1"
}, },
"funding": [ "funding": [
{ {
@@ -3228,29 +3226,27 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-02T12:23:43+00:00" "time": "2026-05-20T22:57:30+00:00"
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.12.1", "version": "2.10.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" "reference": "73ab136360b5dfd858006eae9795e8fe43c80361"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361",
"reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", "reference": "73ab136360b5dfd858006eae9795e8fe43c80361",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2.5 || ^8.0", "php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0", "psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0", "psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0", "ralouphie/getallheaders": "^3.0"
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.24"
}, },
"provide": { "provide": {
"psr/http-factory-implementation": "1.0", "psr/http-factory-implementation": "1.0",
@@ -3331,7 +3327,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.1" "source": "https://github.com/guzzle/psr7/tree/2.10.1"
}, },
"funding": [ "funding": [
{ {
@@ -3347,20 +3343,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-18T09:49:37+00:00" "time": "2026-05-20T09:27:36+00:00"
}, },
{ {
"name": "guzzlehttp/uri-template", "name": "guzzlehttp/uri-template",
"version": "v1.0.7", "version": "v1.0.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/uri-template.git", "url": "https://github.com/guzzle/uri-template.git",
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529" "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529", "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1",
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529", "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3369,7 +3365,7 @@
}, },
"require-dev": { "require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2", "bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34", "phpunit/phpunit": "^8.5.44 || ^9.6.25",
"uri-template/tests": "1.0.0" "uri-template/tests": "1.0.0"
}, },
"type": "library", "type": "library",
@@ -3417,7 +3413,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/uri-template/issues", "issues": "https://github.com/guzzle/uri-template/issues",
"source": "https://github.com/guzzle/uri-template/tree/v1.0.7" "source": "https://github.com/guzzle/uri-template/tree/v1.0.5"
}, },
"funding": [ "funding": [
{ {
@@ -3433,7 +3429,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-12T21:33:43+00:00" "time": "2025-08-22T14:27:06+00:00"
}, },
{ {
"name": "inertiajs/inertia-laravel", "name": "inertiajs/inertia-laravel",
@@ -4197,16 +4193,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.61.1", "version": "v12.60.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d" "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
"reference": "e8472ca9774452fe50841d9bdced060679f4d58d", "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4415,7 +4411,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-06-04T14:22:52+00:00" "time": "2026-05-20T11:48:19+00:00"
}, },
{ {
"name": "laravel/octane", "name": "laravel/octane",
@@ -6676,16 +6672,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.13.0", "version": "3.11.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/CarbonPHP/carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "40f6618f052df16b545f626fbf9a878e6497d16a" "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
"reference": "40f6618f052df16b545f626fbf9a878e6497d16a", "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6777,7 +6773,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-18T13:49:15+00:00" "time": "2026-04-07T09:57:54+00:00"
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
@@ -7796,16 +7792,16 @@
}, },
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "1.30.5", "version": "1.30.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "97bcabd32a64924688487dcd64aceaf158affb5c" "reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
"reference": "97bcabd32a64924688487dcd64aceaf158affb5c", "reference": "02970383cc12e7bf0bc0707ea6e2e8ed23a7aec9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7898,9 +7894,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.4"
}, },
"time": "2026-05-31T05:13:11+00:00" "time": "2026-04-19T06:00:39+00:00"
}, },
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
@@ -7979,16 +7975,16 @@
}, },
{ {
"name": "phpseclib/phpseclib", "name": "phpseclib/phpseclib",
"version": "3.0.55", "version": "3.0.52",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpseclib/phpseclib.git", "url": "https://github.com/phpseclib/phpseclib.git",
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af" "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af", "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8069,7 +8065,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpseclib/phpseclib/issues", "issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
}, },
"funding": [ "funding": [
{ {
@@ -8085,7 +8081,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-14T23:24:10+00:00" "time": "2026-04-27T07:02:15+00:00"
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
@@ -9023,20 +9019,20 @@
}, },
{ {
"name": "ramsey/uuid", "name": "ramsey/uuid",
"version": "4.9.3", "version": "4.9.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/ramsey/uuid.git", "url": "https://github.com/ramsey/uuid.git",
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" "reference": "8429c78ca35a09f27565311b98101e2826affde0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": ">=0.8.16 <=0.18", "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0", "php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0" "ramsey/collection": "^1.2 || ^2.0"
}, },
@@ -9095,9 +9091,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/ramsey/uuid/issues", "issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.9.3" "source": "https://github.com/ramsey/uuid/tree/4.9.2"
}, },
"time": "2026-06-18T03:57:49+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{ {
"name": "react/promise", "name": "react/promise",
@@ -10134,16 +10130,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.13", "version": "v7.4.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075",
"reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10208,7 +10204,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.13" "source": "https://github.com/symfony/console/tree/v7.4.11"
}, },
"funding": [ "funding": [
{ {
@@ -10228,7 +10224,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-24T08:56:14+00:00" "time": "2026-05-13T12:04:42+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
@@ -10757,16 +10753,16 @@
}, },
{ {
"name": "symfony/html-sanitizer", "name": "symfony/html-sanitizer",
"version": "v7.4.13", "version": "v7.4.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/html-sanitizer.git", "url": "https://github.com/symfony/html-sanitizer.git",
"reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9" "reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/761f6c49dfd103ee08b3cd09ece588b069e18ec9", "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/51cb4f68195883f7ac403abb58ecf7adc74e0f9e",
"reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9", "reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10807,7 +10803,7 @@
"sanitizer" "sanitizer"
], ],
"support": { "support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.13" "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.12"
}, },
"funding": [ "funding": [
{ {
@@ -10827,20 +10823,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-24T11:20:33+00:00" "time": "2026-05-20T07:20:23+00:00"
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v7.4.13", "version": "v7.4.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-foundation.git", "url": "https://github.com/symfony/http-foundation.git",
"reference": "bc354f47c62301e990b7874fa662326368508e2c" "reference": "9381209597ec66c25be154cbf2289076e64d1eab"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab",
"reference": "bc354f47c62301e990b7874fa662326368508e2c", "reference": "9381209597ec66c25be154cbf2289076e64d1eab",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10889,7 +10885,7 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.13" "source": "https://github.com/symfony/http-foundation/tree/v7.4.8"
}, },
"funding": [ "funding": [
{ {
@@ -10909,20 +10905,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-24T11:20:33+00:00" "time": "2026-03-24T13:12:05+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
"version": "v7.4.13", "version": "v7.4.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-kernel.git", "url": "https://github.com/symfony/http-kernel.git",
"reference": "9df847980c436451f4f51d1284491bb4356dd989" "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
"reference": "9df847980c436451f4f51d1284491bb4356dd989", "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11008,7 +11004,7 @@
"description": "Provides a structured process for converting a Request into a Response", "description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.13" "source": "https://github.com/symfony/http-kernel/tree/v7.4.12"
}, },
"funding": [ "funding": [
{ {
@@ -11028,7 +11024,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-27T08:31:43+00:00" "time": "2026-05-20T09:27:11+00:00"
}, },
{ {
"name": "symfony/mailer", "name": "symfony/mailer",
@@ -11116,16 +11112,16 @@
}, },
{ {
"name": "symfony/mime", "name": "symfony/mime",
"version": "v7.4.13", "version": "v7.4.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/mime.git", "url": "https://github.com/symfony/mime.git",
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770" "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470",
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11181,7 +11177,7 @@
"mime-type" "mime-type"
], ],
"support": { "support": {
"source": "https://github.com/symfony/mime/tree/v7.4.13" "source": "https://github.com/symfony/mime/tree/v7.4.12"
}, },
"funding": [ "funding": [
{ {
@@ -11201,7 +11197,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-23T16:22:37+00:00" "time": "2026-05-20T07:20:23+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
@@ -11288,16 +11284,16 @@
}, },
{ {
"name": "symfony/polyfill-intl-grapheme", "name": "symfony/polyfill-intl-grapheme",
"version": "v1.38.1", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "e9247d281d694a5120554d9afaf54e070e88a603" "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
"reference": "e9247d281d694a5120554d9afaf54e070e88a603", "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11346,7 +11342,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11366,20 +11362,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T05:58:03+00:00" "time": "2026-04-26T13:13:48+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.38.1", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git", "url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "dc21118016c039a66235cf93d96b435ffb282412" "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "dc21118016c039a66235cf93d96b435ffb282412", "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11433,7 +11429,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11453,20 +11449,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-25T15:22:23+00:00" "time": "2024-09-10T14:38:51+00:00"
}, },
{ {
"name": "symfony/polyfill-intl-normalizer", "name": "symfony/polyfill-intl-normalizer",
"version": "v1.38.0", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" "reference": "3833d7255cc303546435cb650316bff708a1c75c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "reference": "3833d7255cc303546435cb650316bff708a1c75c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11518,7 +11514,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11538,20 +11534,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-25T13:48:31+00:00" "time": "2024-09-09T11:45:10+00:00"
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.38.2", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11603,7 +11599,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11623,7 +11619,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-27T06:59:30+00:00" "time": "2026-04-10T17:25:58+00:00"
}, },
{ {
"name": "symfony/polyfill-php73", "name": "symfony/polyfill-php73",
@@ -11791,16 +11787,16 @@
}, },
{ {
"name": "symfony/polyfill-php81", "name": "symfony/polyfill-php81",
"version": "v1.38.1", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php81.git", "url": "https://github.com/symfony/polyfill-php81.git",
"reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11847,7 +11843,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11867,20 +11863,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T12:45:58+00:00" "time": "2024-09-09T11:45:10+00:00"
}, },
{ {
"name": "symfony/polyfill-php83", "name": "symfony/polyfill-php83",
"version": "v1.38.2", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php83.git", "url": "https://github.com/symfony/polyfill-php83.git",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11927,7 +11923,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -11947,20 +11943,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-27T06:51:48+00:00" "time": "2026-04-10T17:25:58+00:00"
}, },
{ {
"name": "symfony/polyfill-php84", "name": "symfony/polyfill-php84",
"version": "v1.38.1", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php84.git", "url": "https://github.com/symfony/polyfill-php84.git",
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06",
"reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12007,7 +12003,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -12027,20 +12023,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T12:51:13+00:00" "time": "2026-04-10T18:47:49+00:00"
}, },
{ {
"name": "symfony/polyfill-php85", "name": "symfony/polyfill-php85",
"version": "v1.38.1", "version": "v1.37.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php85.git", "url": "https://github.com/symfony/polyfill-php85.git",
"reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12087,7 +12083,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
}, },
"funding": [ "funding": [
{ {
@@ -12107,7 +12103,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-26T02:25:22+00:00" "time": "2026-04-26T13:10:57+00:00"
}, },
{ {
"name": "symfony/polyfill-uuid", "name": "symfony/polyfill-uuid",
@@ -12194,16 +12190,16 @@
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v7.4.13", "version": "v7.4.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/process.git", "url": "https://github.com/symfony/process.git",
"reference": "f5804be144caceb570f6747519999636b664f24c" "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0",
"reference": "f5804be144caceb570f6747519999636b664f24c", "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12235,7 +12231,7 @@
"description": "Executes commands in sub-processes", "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/process/tree/v7.4.13" "source": "https://github.com/symfony/process/tree/v7.4.11"
}, },
"funding": [ "funding": [
{ {
@@ -12255,7 +12251,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-23T16:05:06+00:00" "time": "2026-05-11T16:55:21+00:00"
}, },
{ {
"name": "symfony/property-access", "name": "symfony/property-access",
@@ -12518,16 +12514,16 @@
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v7.4.13", "version": "v7.4.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/routing.git", "url": "https://github.com/symfony/routing.git",
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12579,7 +12575,7 @@
"url" "url"
], ],
"support": { "support": {
"source": "https://github.com/symfony/routing/tree/v7.4.13" "source": "https://github.com/symfony/routing/tree/v7.4.12"
}, },
"funding": [ "funding": [
{ {
@@ -12599,7 +12595,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-24T11:20:33+00:00" "time": "2026-05-20T07:20:23+00:00"
}, },
{ {
"name": "symfony/serializer", "name": "symfony/serializer",
@@ -12794,16 +12790,16 @@
}, },
{ {
"name": "symfony/string", "name": "symfony/string",
"version": "v7.4.13", "version": "v7.4.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/string.git", "url": "https://github.com/symfony/string.git",
"reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15",
"reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -12861,7 +12857,7 @@
"utf8" "utf8"
], ],
"support": { "support": {
"source": "https://github.com/symfony/string/tree/v7.4.13" "source": "https://github.com/symfony/string/tree/v7.4.11"
}, },
"funding": [ "funding": [
{ {
@@ -12881,7 +12877,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-05-23T15:23:29+00:00" "time": "2026-05-13T12:04:42+00:00"
}, },
{ {
"name": "symfony/translation", "name": "symfony/translation",
@@ -13827,16 +13823,16 @@
}, },
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.4.1", "version": "2.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/webmozarts/assert.git", "url": "https://github.com/webmozarts/assert.git",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13887,9 +13883,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/webmozarts/assert/issues", "issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.4.1" "source": "https://github.com/webmozarts/assert/tree/2.4.0"
}, },
"time": "2026-06-15T15:31:57+00:00" "time": "2026-05-20T13:07:01+00:00"
}, },
{ {
"name": "wikimedia/composer-merge-plugin", "name": "wikimedia/composer-merge-plugin",

View File

@@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
defineProps({ withDefaults(
canResetPassword: Boolean, defineProps<{
status: String, canResetPassword?: boolean;
}); status?: string;
}>(),
{
canResetPassword: false,
status: '',
}
);
const form = useForm({ const form = useForm({
email: '', email: '',
@@ -28,8 +34,8 @@ const submit = () => {
}; };
const page = usePage<{ const page = usePage<{
flash: { flash?: {
message: string; message?: string;
}; };
}>(); }>();
</script> </script>
@@ -61,6 +67,9 @@ const page = usePage<{
{{ page.props.flash?.message }} {{ page.props.flash?.message }}
</div> </div>
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<Field> <Field>
<FieldLabel for="email">Email</FieldLabel> <FieldLabel for="email">Email</FieldLabel>
@@ -103,5 +112,8 @@ const page = usePage<{
</PrimaryButton> </PrimaryButton>
</div> </div>
</form> </form>
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard> </AuthenticationCard>
</template> </template>

View File

@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue'; import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch'; import { setupPrefetching } from '@/utils/prefetch';
interface ExtensionManifest {
name?: string;
alias?: string;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia(); const pinia = createPinia();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
eager: true,
import: 'default',
}) as Record<string, ExtensionManifest>;
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
const extensionPages = import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/**/*.vue',
'!**/BillingPortal.vue',
]);
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
(directories, [path, manifest]) => {
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
const extensionDirectory = match?.[1];
if (extensionDirectory === undefined) {
return directories;
}
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
if (typeof key !== 'string' || key === '') {
continue;
}
directories[key] = extensionDirectory;
directories[key.toLowerCase()] = extensionDirectory;
}
return directories;
},
{}
);
function resolveExtensionDirectory(moduleName: string): string {
return (
extensionDirectories[moduleName] ??
extensionDirectories[moduleName.toLowerCase()] ??
moduleName
);
}
createInertiaApp({ createInertiaApp({
title: (title) => `${title} - ${appName}`, title: (title) => `${title} - ${appName}`,
resolve: (name) => { resolve: (name) => {
if (name.includes('Invoicing::')) { // "Module::Page" (both halves present) resolves to that extension's page
const [module, page] = name.split('::'); // directory; everything else is a host page under resources/js/Pages.
const [module, ...pageSegments] = name.split('::');
const page = pageSegments.join('::');
const pagePath = module if (module && page) {
? `../../extensions/${module}/resources/js/Pages/${page}.vue` const extensionDirectory = resolveExtensionDirectory(module);
: `./Pages/${page}.vue`; const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
// BillingPortal is a Vue 2 Component and therefore should not be imported return resolvePageComponent(pagePath, extensionPages);
const pages = module
? import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/*.vue',
'!**/BillingPortal.vue',
])
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
return resolvePageComponent(pagePath, pages);
} else {
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
} }
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
}, },
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) }); const app = createApp({ render: () => h(App, props) });

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Tests\Unit\Service; namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
$this->assertSame(Role::Owner->value, $newMember->role); $this->assertSame(Role::Owner->value, $newMember->role);
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey()); $this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
} }
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
{
// Arrange — an accepted invitation exists for the email (e.g. the user
// followed the invite link, then signs up via SSO). Casing differs to
// prove the email is normalised before the invitation is matched.
$organization = Organization::factory()->create();
OrganizationInvitation::factory()
->forOrganization($organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'invitee@example.com',
]);
// Act
$user = $this->userService->createPasswordlessUser(
'Invitee',
'Invitee@Example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — invitation is materialised, no personal organization is created
$this->assertNull($user->password);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'invitee@example.com',
]);
$user->refresh();
$this->assertSame(1, $user->organizations()->count());
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
$this->assertSame(Role::Employee->value, $member->role);
}
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
{
// Act
$user = $this->userService->createPasswordlessUser(
'Solo User',
'solo@example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — a personal organization is created, owned by the user and set current
$user->refresh();
$this->assertNull($user->password);
$this->assertSame(1, $user->organizations()->count());
$organization = $user->organizations()->first();
$this->assertTrue($organization->personal_team);
$this->assertSame($user->getKey(), $organization->user_id);
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
}
} }

View File

@@ -14,6 +14,7 @@ async function getConfig() {
const additionalPlugins = await collectModulePlugins('extensions'); const additionalPlugins = await collectModulePlugins('extensions');
return defineConfig({ return defineConfig({
base: './',
build: { build: {
sourcemap: true, // Source map generation must be turned on sourcemap: true, // Source map generation must be turned on
}, },