Compare commits

..

No commits in common. "19c2892569e2e87902ed2d625b8a4bfaf7f26be1" and "2682a234d4012c8c64c725ce552cb3f3c4613334" have entirely different histories.

48 changed files with 579 additions and 1640 deletions

View File

@ -1,2 +0,0 @@
[xdebug]
xdebug.client_port = 9009

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use Spatie\LaravelData\Data;
final class ClientCredentialsData extends Data
{
public function __construct(
public string $clientId,
public string $grantType,
public ?string $clientSecret = null,
public ?array $redirectUris = null,
public ?array $scopes = null,
) {}
}

View File

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use App\Data\ConnectedApp\ConnectedAppData;
use Spatie\LaravelData\Data;
class CreatedApplicationData extends Data
{
public function __construct(
public ConnectedAppData $application,
public ClientCredentialsData $clientCredentials,
) {}
}

View File

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Application;
use App\Enums\{ApplicationTypeEnum, UserAccessTypeEnum};
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Spatie\LaravelData\Data;
class StoreOIDCAppData extends Data
{
public function __construct(
public ApplicationTypeEnum $type,
public string $name,
public UserAccessTypeEnum $userAccessType,
/** @var array<string> */
public array $signInUris = [],
/** @var array<string> */
public array $signOutUris = [],
public ?TemporaryUploadedFile $logo = null,
/** @var array<int> */
public array $roleIds = [],
) {}
public static function fromArray(ApplicationTypeEnum $applicationType, array $value): StoreOIDCAppData
{
return new self(
type: $applicationType,
name: $value['name'],
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
signInUris: $value['signInUris'] ?? [],
signOutUris: $value['signOutUris'] ?? [],
logo: $value['logo'] ?? null,
roleIds: $value['roleIds'] ?? [],
);
}
}

View File

@ -13,11 +13,10 @@ class ConnectAppRequest extends Data
{ {
public function __construct( public function __construct(
public string $name, public string $name,
public int $connectionProviderId,
public int $connectionProtocolId, public int $connectionProtocolId,
public ?int $connectionProviderId = null,
public ?string $slug = null, public ?string $slug = null,
public ?int $connectionStatusId = null, public ?int $connectionStatusId = null,
public ?array $settings = null, public ?array $settings = null,
public ?string $logo = null,
) {} ) {}
} }

View File

@ -20,6 +20,5 @@ public function __construct(
public int $statusId, public int $statusId,
public ?string $slug = null, public ?string $slug = null,
public ?array $settings = null, public ?array $settings = null,
public ?string $logo = null
) {} ) {}
} }

View File

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Contracts\EnumMorphsToOptionsContract;
enum ApplicationTypeEnum: string implements EnumMorphsToOptionsContract
{
case Web = 'web';
case SPA = 'spa';
case Machine = 'machine';
public static function asOptions(): array
{
return array_map(fn (self $enum) => [
'name' => $enum->toLabel(),
'value' => $enum->value,
'hint' => $enum->toHint(),
], self::cases());
}
public function toLabel(): string
{
return match ($this) {
self::Web => 'Web Applications',
self::SPA => 'Single Page Applications',
self::Machine => 'Microservices',
};
}
public function toHint(): string
{
return match ($this) {
self::Web => 'Server-side rendered web application, where authentication is handled by the server.',
self::SPA => 'Single Page Application where client side framework e.g. React, Vue, Angular is used, and gets the authentication token from the server.',
self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.',
};
}
}

View File

@ -5,40 +5,13 @@
namespace App\Enums; namespace App\Enums;
use App\Concerns\EnumValuesAsArray; use App\Concerns\EnumValuesAsArray;
use App\Contracts\EnumMorphsToOptionsContract;
enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract enum ConnectionProtocolEnum: string
{ {
use EnumValuesAsArray; use EnumValuesAsArray;
case OIDC = 'oidc'; case OIDC = 'oidc';
case SAML = 'saml'; case SAML = 'saml';
case OAUTH2 = 'oauth2';
case URL = 'url'; case URL = 'url';
public static function asOptions(): array
{
return array_map(fn (self $enum) => [
'name' => $enum->toLabel(),
'value' => mb_strtolower($enum->name),
'hint' => $enum->toHint(),
], self::cases());
}
public function toLabel(): string
{
return match ($this) {
self::OIDC => 'OIDC - OpenID Connect',
self::SAML => 'SAML 2.0',
self::URL => 'Redirect URL',
};
}
public function toHint(): string
{
return match ($this) {
self::OIDC => 'Modern authentication protocol built on OAuth 2.0 for secure user sign-in across web, mobile, and API-based applications.',
self::SAML => 'XML-based Single Sign-On (SSO) protocol commonly used by enterprise applications and identity providers',
self::URL => 'Simple URL that will redirect the user to the authentication page.',
};
}
} }

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
use App\Contracts\EnumMorphsToOptionsContract;
enum UserAccessTypeEnum: string implements EnumMorphsToOptionsContract
{
use EnumValuesAsArray;
case Everyone = 'everyone';
case Role = 'role';
/**
* @return array<array{name: string, value:string, hint:string}>
*/
public static function asOptions(): array
{
return array_map(fn (self $enum) => [
'name' => $enum->toLabel(),
'value' => $enum->value,
'hint' => $enum->toHint(),
], self::cases());
}
public function toLabel(): string
{
return match ($this) {
self::Everyone => 'Everyone',
self::Role => 'Role',
};
}
public function toHint(): string
{
return match ($this) {
self::Everyone => 'Everyone can access the application.',
self::Role => 'Only users with the specified role can access the application.',
};
}
}

View File

@ -5,28 +5,32 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\OauthToken; use App\Models\OauthToken;
use App\Services\UserAppAccessService;
use Illuminate\Http\{RedirectResponse, Request}; use Illuminate\Http\{RedirectResponse, Request};
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class EntraController extends Controller class EntraController extends Controller
{ {
// ── OAuth config from .env ────────────────────────────────────────────────
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
private const PROVIDER = 'microsoft'; private const PROVIDER = 'microsoft';
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send'; private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
private string $tenantId;
private string $clientId;
private string $clientSecret;
private string $redirectUri;
public function __construct() public function __construct()
{ {
$this->tenantId = (string) config('services.microsoft.tenant_id'); $this->tenantId = config('services.microsoft.tenant_id');
$this->clientId = (string) config('services.microsoft.client_id'); $this->clientId = config('services.microsoft.client_id');
$this->clientSecret = (string) config('services.microsoft.client_secret'); $this->clientSecret = config('services.microsoft.client_secret');
$this->redirectUri = route('entra.callback'); $this->redirectUri = route('entra.callback');
} }
// ── Step 1: Redirect user to Microsoft login ────────────────────────────── // ── Step 1: Redirect user to Microsoft login ──────────────────────────────
@ -34,43 +38,28 @@ public function __construct()
public function connect(): RedirectResponse public function connect(): RedirectResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (! $user) { if (! $user) {
abort(403, 'Unauthorized'); abort(403, 'Unauthorized');
} }
if (! $this->hasAzureAccess($user)) { $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.'); abort(403, 'You do not have permission to access the required application.');
} }
// Already connected and token still valid → open Outlook directly
$token = $user->oauthToken(self::PROVIDER);
if ($token && $token->expires_at->gt(now()->addMinutes(5))) {
return redirect('https://outlook.office.com/mail/');
}
// Token exists but expired → try silent refresh first
if ($token && $token->refresh_token) {
if ($this->refreshToken($token)) {
return redirect('https://outlook.office.com/mail/');
}
// Refresh failed → delete and fall through to full login
$token->delete();
}
// No token or refresh failed → full Microsoft login
$state = Str::random(40); $state = Str::random(40);
session(['oauth_state' => $state]); session(['oauth_state' => $state]);
$query = http_build_query([ $query = http_build_query([
'client_id' => $this->clientId, 'client_id' => $this->clientId,
'response_type' => 'code', 'response_type' => 'code',
'redirect_uri' => $this->redirectUri, 'redirect_uri' => $this->redirectUri,
'response_mode' => 'query', 'response_mode' => 'query',
'scope' => self::SCOPES, 'scope' => self::SCOPES,
'state' => $state, 'state' => $state,
'prompt' => 'select_account', 'prompt' => 'select_account',
]); ]);
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}"); return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
@ -81,83 +70,75 @@ public function connect(): RedirectResponse
public function callback(Request $request): RedirectResponse public function callback(Request $request): RedirectResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (! $user) { if (! $user) {
abort(403, 'Unauthorized'); abort(403, 'Unauthorized');
} }
if (! $this->hasAzureAccess($user)) { $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.'); abort(403, 'You do not have permission to access the required application.');
} }
// CSRF state check // CSRF state check
if (! hash_equals((string) session('oauth_state'), (string) $request->state)) { if ($request->state !== session('oauth_state')) {
session()->forget('oauth_state'); return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.');
return redirect()->route('dashboard.user')
->with('entra_error', 'Invalid OAuth state. Please try again.');
} }
session()->forget('oauth_state');
if ($request->has('error')) { if ($request->has('error')) {
return redirect()->route('dashboard.user') return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
}
if (! $request->filled('code')) {
return redirect()->route('dashboard.user')
->with('entra_error', 'No authorization code received from Microsoft.');
} }
// Exchange authorization code for tokens // Exchange authorization code for tokens
$response = Http::asForm()->post( $response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[ [
'client_id' => $this->clientId, 'client_id' => $this->clientId,
'client_secret' => $this->clientSecret, 'client_secret' => $this->clientSecret,
'code' => $request->code, 'code' => $request->code,
'redirect_uri' => $this->redirectUri, 'redirect_uri' => $this->redirectUri,
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
'scope' => self::SCOPES,
] ]
); );
if ($response->failed()) { if ($response->failed()) {
return redirect()->route('dashboard.user') return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.');
->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.'));
} }
$data = $response->json(); $data = $response->json();
// Persist token for this user (upsert so re-connecting just updates)
OauthToken::updateOrCreate( OauthToken::updateOrCreate(
[ [
'user_id' => $user->id, 'user_id' => auth()->id(),
'provider' => self::PROVIDER, 'provider' => self::PROVIDER,
], ],
[ [
'access_token' => $data['access_token'], 'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null, 'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer', 'token_type' => $data['token_type'] ?? 'Bearer',
'scopes' => self::SCOPES, 'scopes' => self::SCOPES,
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)), 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
] ]
); );
return redirect()->route('dashboard.user') return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!');
->with('entra_success', 'Microsoft connected successfully!');
} }
// ── Open Outlook (silent refresh if expired) ────────────────────────────── // ── Open Entra (refresh token silently if expired) ───────────────────────
public function openEntra(): RedirectResponse public function openEntra(): RedirectResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (! $user) { if (! $user) {
abort(403, 'Unauthorized'); abort(403, 'Unauthorized');
} }
if (! $this->hasAzureAccess($user)) { $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.'); abort(403, 'You do not have permission to access the required application.');
} }
@ -167,20 +148,20 @@ public function openEntra(): RedirectResponse
return redirect()->route('entra.connect'); return redirect()->route('entra.connect');
} }
// Token still valid (5-min buffer) // If token is expired, try to refresh silently
if ($token->expires_at->gt(now()->addMinutes(5))) { if ($token->isExpired()) {
return redirect('https://outlook.office.com/mail/'); $refreshed = $this->refreshToken($token);
if (! $refreshed) {
// Refresh failed — force re-login
$token->delete();
return redirect()->route('entra.connect');
}
} }
// Token expired — try silent refresh // Redirect to Outlook Web — SSO session handles silent sign-in
if ($this->refreshToken($token)) { return redirect('https://outlook.office.com/mail/');
return redirect('https://outlook.office.com/mail/');
}
// Refresh failed — force full re-login
$token->delete();
return redirect()->route('entra.connect');
} }
// ── Disconnect ──────────────────────────────────────────────────────────── // ── Disconnect ────────────────────────────────────────────────────────────
@ -188,22 +169,23 @@ public function openEntra(): RedirectResponse
public function disconnect(): RedirectResponse public function disconnect(): RedirectResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (! $user) { if (! $user) {
abort(403, 'Unauthorized'); abort(403, 'Unauthorized');
} }
if (! $this->hasAzureAccess($user)) { $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
if (! $hasAzureFdAccess) {
abort(403, 'You do not have permission to access the required application.'); abort(403, 'You do not have permission to access the required application.');
} }
$user->oauthToken(self::PROVIDER)?->delete(); $user->oauthToken(self::PROVIDER)?->delete();
return redirect()->route('dashboard.user') return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
->with('entra_success', 'Microsoft disconnected.');
} }
// ── Internal: silent refresh ────────────────────────────────────────────── // ── Internal: Refresh access token using refresh_token ───────────────────
private function refreshToken(OauthToken $token): bool private function refreshToken(OauthToken $token): bool
{ {
@ -214,11 +196,11 @@ private function refreshToken(OauthToken $token): bool
$response = Http::asForm()->post( $response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[ [
'client_id' => $this->clientId, 'client_id' => $this->clientId,
'client_secret' => $this->clientSecret, 'client_secret' => $this->clientSecret,
'refresh_token' => $token->refresh_token, 'refresh_token' => $token->refresh_token,
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
'scope' => self::SCOPES, 'scope' => self::SCOPES,
] ]
); );
@ -229,20 +211,11 @@ private function refreshToken(OauthToken $token): bool
$data = $response->json(); $data = $response->json();
$token->update([ $token->update([
'access_token' => $data['access_token'], 'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token, 'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)), 'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
]); ]);
return true; return true;
} }
// ── Internal: permission check (DRY) ─────────────────────────────────────
private function hasAzureAccess(mixed $user): bool
{
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
}
} }

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CaptureOidcNonce
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('GET') && $request->has('nonce')) {
$request->session()->put('oidc_nonce', $request->input('nonce'));
}
return $next($request);
}
}

View File

@ -23,6 +23,6 @@ public function toResponse($request)
]) ])
->log('User logged in}'); ->log('User logged in}');
return redirect()->intended(route('dashboard')); return to_route('dashboard');
} }
} }

View File

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns\Choices;
use Closure;
final readonly class ChoiceField
{
public function __construct(
public Closure $search, // fn(string $value): array
public Closure $allIds, // fn(): array
public string $formPath, // e.g. 'form.roleIds'
public ?Closure $after = null, // optional side effect, e.g. re-hydrate permissions
) {}
}

View File

@ -1,203 +0,0 @@
# WithSearchableChoices
A small Livewire trait that removes the repeated `search{X}` / `selectAll{X}` / `clear{X}`
boilerplate that shows up every time you wire a `<x-shared.choices>` multi-select to a
service-backed list (roles, permissions, users, tags, etc.).
Instead of writing three full methods per entity, you register one config entry per
field and keep three one-line delegates. All the real logic lives once, in the trait.
## Requirements
- PHP 8.2+ (uses a `readonly` class with constructor promotion; first-class callable
syntax `(...)` is PHP 8.1+, so this also runs fine on 8.1 if you swap those for
closures)
- Laravel (uses the `data_set()` helper)
- Livewire 3 or 4 — nothing here is version-specific. Traits operate at the PHP class
level, so this works whether your component is a classic two-file component or a
Livewire 4 single-file `⚡component.blade.php`.
## Files
### `app/Livewire/Concerns/ChoiceField.php`
Describes a single searchable field: how to search it, how to select all of it, where
its selected IDs live on the form object, and an optional side effect to run after a
change.
```php
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns;
final readonly class ChoiceField
{
public function __construct(
public \Closure $search, // fn(string $value): array
public \Closure $allIds, // fn(): array
public string $formPath, // e.g. 'form.roleIds'
public ?\Closure $after = null, // optional side effect, e.g. re-hydrate permissions
) {}
}
```
### `app/Livewire/Concerns/WithSearchableChoices.php`
The trait. Holds one searchable-options property (keyed by field name) instead of one
property per entity, plus the three generic operations.
```php
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns;
use InvalidArgumentException;
trait WithSearchableChoices
{
/** @var array<string, array<int, mixed>> */
public array $choicesSearchable = [];
/** @return array<string, ChoiceField> */
abstract protected function choiceFields(): array;
public function searchChoice(string $key, string $value = ''): void
{
$field = $this->resolveChoiceField($key);
$this->choicesSearchable[$key] = ($field->search)($value);
}
public function selectAllChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, ($field->allIds)());
$this->searchChoice($key);
$field->after?->__invoke();
}
public function clearChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, []);
$field->after?->__invoke();
}
protected function resolveChoiceField(string $key): ChoiceField
{
return $this->choiceFields()[$key]
?? throw new InvalidArgumentException("Unknown choice field [{$key}].");
}
}
```
Note that `choiceFields()` is a **method**, not a property. Closures can't be stored on
a public Livewire property (they're not JSON-serializable for the wire snapshot), so
keeping the config behind a method means it's rebuilt fresh each request and never
touches Livewire's hydration/dehydration cycle.
## Usage
### 1. Use the trait and register your fields
```php
use App\Livewire\Concerns\{WithSearchableChoices, ChoiceField};
class RoleAssignmentForm extends Component
{
use WithSearchableChoices;
protected function choiceFields(): array
{
return [
'roles' => new ChoiceField(
search: fn (string $value): array => $this->roleService->searchRolesForSelect($value)->toArray(),
allIds: $this->roleService->getAllRoleIds(...), // <-- '...' is important here, as this make the closure callable
formPath: 'form.roleIds', // <-- this should be the livewire form object's property path in dot notation
after: $this->hydratePermissionInForm(...), // <-- optional side effect, that will be run after a change
),
];
}
public function searchRoles(string $value = ''): void { $this->searchChoice('roles', $value); }
public function selectAllRoles(): void { $this->selectAllChoice('roles'); }
public function clearRoles(): void { $this->clearChoice('roles'); }
}
```
The three delegate methods exist because Livewire requires `wire:click`/action targets
to be real, declared public methods — it won't fall through to `__call()`. Keeping them
means your Blade markup and method names don't change at all; they just forward into
the trait.
### 2. Update the Blade options binding
Everything stays the same except where `:options` reads from, since there's now one
shared `$choicesSearchable` array keyed by field name instead of a separate property
per entity:
```blade
<x-shared.choices
wire:model.live="form.roleIds"
:options="$choicesSearchable['roles'] ?? []"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles"
/>
```
### 3. Add another field (e.g. permissions)
```php
protected function choiceFields(): array
{
return [
'roles' => new ChoiceField(/* ... */),
'permissions' => new ChoiceField(
search: fn (string $value): array => $this->permissionService->searchPermissionsForSelect($value)->toArray(),
allIds: $this->permissionService->getAllPermissionIds(...),
formPath: 'form.permissionIds',
),
];
}
public function searchPermissions(string $value = ''): void { $this->searchChoice('permissions', $value); }
public function selectAllPermissions(): void { $this->selectAllChoice('permissions'); }
public function clearPermissions(): void { $this->clearChoice('permissions'); }
```
```blade
<x-shared.choices
wire:model.live="form.permissionIds"
:options="$choicesSearchable['permissions'] ?? []"
search-function="searchPermissions"
select-all-action="selectAllPermissions"
clear-action="clearPermissions"
label="Permissions"
/>
```
## Notes & caveats
- `formPath` is resolved with Laravel's `data_set()`, so it works against any nested
object/array path on the component (`form.roleIds`, `filters.tags`, etc.), not just a
single level.
- The `after` callback runs on **select all** and **clear**, matching the original
behavior where selecting/clearing roles re-hydrates dependent permission state. Leave
it `null` for fields with no side effects.
- This assumes `<x-shared.choices>` interpolates `search-function` / `select-all-action`
/ `clear-action` directly into `wire:click` / `wire:keyup`-style attributes as plain
method names. If that component does anything more elaborate internally (e.g. wraps
the search call with extra arguments), double-check the generated action string
against this trait's method signatures.

View File

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns\Choices;
use InvalidArgumentException;
trait WithSearchableChoices
{
/** @var array<string, array<int, mixed>> */
public array $choicesSearchable = [];
public function selectAllChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, ($field->allIds)());
$this->searchChoice($key);
$field->after?->__invoke();
}
protected function resolveChoiceField(string $key): ChoiceField
{
return $this->choiceFields()[$key]
?? throw new InvalidArgumentException("Unknown choice field [{$key}].");
}
/** @return array<string, ChoiceField> */
abstract protected function choiceFields(): array;
/**
* @param string $key this should be one of the top level array key, defined in choiceFields()
* @param string $value this is the value to search for
*/
public function searchChoice(string $key, string $value = ''): void
{
$field = $this->resolveChoiceField($key);
$this->choicesSearchable[$key] = ($field->search)($value);
}
public function clearChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, []);
$field->after?->__invoke();
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns;
trait WithRepeaterFields
{
public function addRepeaterItem(string $property, mixed $default = ''): void
{
$items = data_get($this, $property, []);
$items[] = $default;
data_set($this, $property, $items);
}
public function removeRepeaterItem(string $property, int $index): void
{
$items = data_get($this, $property, []);
unset($items[$index]);
data_set($this, $property, array_values($items));
}
}

View File

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum};
use Illuminate\Validation\Rule;
use Livewire\Form;
class CreateAppSelectionForm extends Form
{
public string $connectionProtocol = ConnectionProtocolEnum::OIDC->value;
public string $applicationType = ApplicationTypeEnum::Web->value;
protected function rules(): array
{
return [
'connectionProtocol' => [
'required',
Rule::enum(ConnectionProtocolEnum::class),
],
'applicationType' => [
'required',
Rule::enum(ApplicationTypeEnum::class),
],
];
}
}

View File

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use App\Enums\UserAccessTypeEnum;
use Livewire\Attributes\Validate;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\Form;
class StoreOIDCAppForm extends Form
{
#[Validate('required|string|max:255|min:3')]
public string $name = '';
#[Validate('nullable|image|max:1024')]
public ?TemporaryUploadedFile $logo = null;
/** @var string[] */
#[Validate([
'signInUris' => 'nullable',
'signInUris.*' => ['nullable', 'url'],
], message: 'The sign in URI must be a valid URL.')]
public array $signInUris = [''];
/** @var string[] */
#[Validate([
'signOutUris' => 'nullable',
'signOutUris.*' => ['nullable', 'url'],
])]
public array $signOutUris = [''];
#[Validate([
'userAccessOption' => 'required|in:'.UserAccessTypeEnum::Everyone->value.','.UserAccessTypeEnum::Role->value,
])]
public string $userAccessOption = UserAccessTypeEnum::Everyone->value;
/** @var int[] */
#[Validate([
'roleIds' => 'required_if:userAccessOption,'.UserAccessTypeEnum::Role->value,
'roleIds.*' => ['integer', 'exists:roles,id'],
])]
public array $roleIds = [];
public function validationAttributes(): array
{
return [
'signInUris.*' => 'Sign-in URI',
'signOutUris.*' => 'Sign-out URI',
'roleIds.*' => 'Role',
];
}
}

View File

@ -46,15 +46,9 @@ public function user(): BelongsTo
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function isExpired(): bool public function isExpired(): bool
{ {
return $this->expires_at->lte(now()->addMinutes(5)); // 5-min buffer return $this->expires_at->isPast();
}
public function isValid(): bool
{
return ! $this->isExpired();
} }
/** /**

View File

@ -18,8 +18,6 @@
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
@ -28,10 +26,10 @@
*/ */
#[Fillable(['name', 'email', 'password', 'immutable_id'])] #[Fillable(['name', 'email', 'password', 'immutable_id'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
final class User extends Authenticatable implements OAuthenticatable, OidcUserInterface final class User extends Authenticatable implements OidcUserInterface
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, HasOidcClaims, SoftDeletes; use HasFactory, HasOidcClaims, SoftDeletes;
use HasRoles; use HasRoles;
use Notifiable; use Notifiable;

View File

@ -17,18 +17,12 @@ public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): voi
{ {
parent::persistNewAuthCode($authCodeEntity); parent::persistNewAuthCode($authCodeEntity);
$nonce = request()->input('nonce') ?? (request()->hasSession() ? request()->session()->get('oidc_nonce') : null); if (request()->has('nonce')) {
if ($nonce) {
Cache::put( Cache::put(
'oidc_nonce_'.$authCodeEntity->getIdentifier(), 'oidc_nonce_'.$authCodeEntity->getIdentifier(),
$nonce, request()->input('nonce'),
now()->addMinutes(10) now()->addMinutes(10)
); );
if (request()->hasSession()) {
request()->session()->forget('oidc_nonce');
}
} }
} }
} }

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Carbon\CarbonInterval;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Passport;
class PassportServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
Passport::tokensExpireIn(CarbonInterval::minute(30));
Passport::refreshTokensExpireIn(CarbonInterval::days(15));
Passport::personalAccessTokensExpireIn(CarbonInterval::months(6));
}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
final readonly class ApplicationLogoUploader
{
public function store(?UploadedFile $logo): ?string
{
return $logo?->store('application-logos', 'public');
}
public function delete(?string $path): bool
{
if (null !== $path) {
return Storage::disk('public')->delete($path);
}
return false;
}
}

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
use App\Enums\ApplicationTypeEnum;
use Exception;
use Laravel\Passport\ClientRepository;
readonly class AuthorizationCodeClientFactory implements ClientCreationContract
{
public function __construct(
private ClientRepository $client,
) {}
/**
* @throws Exception
*/
public function create(
StoreOIDCAppData $data
): ClientCredentialsData {
$client = $this->client->createAuthorizationCodeGrantClient(
name: $data->name,
redirectUris: $data->signInUris,
confidential: ApplicationTypeEnum::Web === $data->type,
);
return new ClientCredentialsData(
clientId: $client->id,
grantType: 'authorization_code',
clientSecret: $client->plainSecret,
redirectUris: $client->getAttribute('redirect_uris'),
);
}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
interface ClientCreationContract
{
public function create(StoreOIDCAppData $data): ClientCredentialsData;
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Enums\ApplicationTypeEnum;
class ClientCreationFactory
{
public function __construct(
private readonly ClientCredentialsClientFactory $clientCredentialsClient,
private readonly AuthorizationCodeClientFactory $authorizationCodeClient,
) {}
public function for(ApplicationTypeEnum $applicationType): ClientCreationContract
{
return match ($applicationType) {
ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient,
ApplicationTypeEnum::Machine => $this->clientCredentialsClient,
};
}
}

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC\Factory;
use App\Data\Application\{ClientCredentialsData, StoreOIDCAppData};
use Laravel\Passport\ClientRepository;
class ClientCredentialsClientFactory implements ClientCreationContract
{
public function __construct(
private readonly ClientRepository $client,
) {}
public function create(StoreOIDCAppData $data): ClientCredentialsData
{
$client = $this->client->createClientCredentialsGrantClient(
name: $data->name,
);
return new ClientCredentialsData(
clientId: $client->id,
grantType: 'client_credentials',
clientSecret: $client->plainSecret,
redirectUris: null,
);
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC;
use App\Enums\ApplicationTypeEnum;
class OidcConfigResolver
{
private ?ApplicationTypeEnum $applicationType = null;
public function for(ApplicationTypeEnum $applicationType): OidcConfigResolver
{
$this->applicationType = $applicationType;
return $this;
}
public function hasClientSecret(): bool
{
return ApplicationTypeEnum::SPA !== $this->applicationType;
}
}

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Applications\OIDC;
use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData};
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
use App\Models\{ConnectionProtocol, ConnectionStatus};
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
use App\Services\Applications\OIDC\Factory\ClientCreationFactory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
readonly class OidcService
{
public function __construct(
private ClientCreationFactory $clientCreationFactory,
private ApplicationLogoUploader $logoUploader,
private ConnectedAppService $connectedAppService,
) {}
/**
* @throws Throwable
*/
public function createApp(StoreOIDCAppData $data): CreatedApplicationData
{
Log::info('Creating OIDC app', [
'name' => $data->name,
'type' => $data->type,
]);
try {
DB::beginTransaction();
$logo = $this->logoUploader->store($data->logo);
$appData = $this->connectedAppService->create(
new ConnectAppRequest(
name: $data->name,
connectionProtocolId: ConnectionProtocol::query()->where('name', ConnectionProtocolEnum::OIDC->value)->first()->id,
connectionProviderId: 0,
slug: Str::slug($data->name),
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
logo: $logo,
)
);
$clientData = $this->clientCreationFactory->for($data->type)->create($data);
DB::commit();
// remove sensitive data
$appData->settings = null;
Log::info('OIDC app created', [
'name' => $data->name,
'type' => $data->type,
'clientId' => isset($clientData->clientId),
'clientSecret' => isset($clientData->clientSecret),
]);
return new CreatedApplicationData(
application: $appData,
clientCredentials: $clientData,
);
} catch (Throwable $e) {
DB::rollBack();
Log::error('Failed to create OIDC app', [
'name' => $data->name,
'type' => $data->type,
]);
throw $e;
}
}
}

View File

@ -26,18 +26,15 @@ public function __construct(
* *
* @throws Throwable when an error occurs during the creation. * @throws Throwable when an error occurs during the creation.
*/ */
public function create(ConnectAppRequest $data): ConnectedAppData public function create(ConnectAppRequest $data): void
{ {
return DB::transaction( DB::transaction(
function () use ($data): ConnectedAppData { function () use ($data): void {
$connectedApp = ConnectedApp::query()->create( $connectedApp = ConnectedApp::query()->create(
$data->toArray() $data->toArray()
); );
// Create a permission for this app
$this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use); $this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use);
return ConnectedAppData::from($connectedApp);
} }
); );
} }
@ -98,7 +95,7 @@ public function getConnectedApp(int $id): ConnectedAppData
if ($id <= 0) { if ($id <= 0) {
throw new InvalidArgumentException('Invalid id'); throw new InvalidArgumentException('Invalid id');
} }
$data = ConnectedApp::with(['provider:id', 'protocol:id', 'status:id']) $data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id')
->findOrFail($id); ->findOrFail($id);
return ConnectedAppData::from($data); return ConnectedAppData::from($data);

View File

@ -4,66 +4,90 @@
namespace App\Services; namespace App\Services;
use App\Models\OauthToken;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
/**
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
*
* Flow:
* - connect() -> build Microsoft login URL
* - handleCallback() -> exchange code for tokens, store on user
* - getValidToken() -> return a valid access token, refreshing silently if expired
* - disconnect() -> wipe stored tokens for the user
*/
final class KekaOutlookService final class KekaOutlookService
{ {
private const PROVIDER = 'keka';
private string $clientId; private string $clientId;
private string $clientSecret; private string $clientSecret;
private string $tenantId; private string $tenantId;
private string $redirectUri; private string $redirectUri;
private array $scopes; private array $scopes;
public function __construct() public function __construct()
{ {
$this->clientId = (string) config('microsoft.client_id'); $this->clientId = (string) config('microsoft.client_id');
$this->clientSecret = (string) config('microsoft.client_secret'); $this->clientSecret = (string) config('microsoft.client_secret');
$this->tenantId = (string) config('microsoft.tenant_id'); $this->tenantId = (string) config('microsoft.tenant_id');
$this->redirectUri = (string) config('microsoft.keka_redirect_uri'); $this->redirectUri = (string) config('microsoft.keka_redirect_uri');
$this->scopes = (array) config('microsoft.keka_scopes', [ $this->scopes = config('microsoft.keka_scopes', [
'openid', 'profile', 'email', 'offline_access', 'User.Read', 'openid',
'profile',
'email',
'offline_access',
'User.Read',
'Mail.Read',
'Mail.ReadWrite',
'Mail.Send',
]); ]);
} }
/**
* Build the Microsoft login URL the user is redirected to.
* A random "state" value is generated and stored in the session to prevent CSRF.
*/
public function getAuthUrl(): string public function getAuthUrl(): string
{ {
$state = Str::random(40); $state = Str::random(40);
session(['keka_ms_oauth_state' => $state]); session(['keka_ms_oauth_state' => $state]);
return 'https://login.microsoftonline.com/' . $this->tenantId . '/oauth2/v2.0/authorize?' . http_build_query([ $query = http_build_query([
'client_id' => $this->clientId, 'client_id' => $this->clientId,
'response_type' => 'code', 'response_type' => 'code',
'redirect_uri' => $this->redirectUri, 'redirect_uri' => $this->redirectUri,
'response_mode' => 'query', 'response_mode' => 'query',
'scope' => implode(' ', $this->scopes), 'scope' => implode(' ', $this->scopes),
'state' => $state, 'state' => $state,
'prompt' => 'select_account', // prompt=select_account forces account chooser on first connect.
'prompt' => 'select_account',
]); ]);
}
public function isValidState(?string $state): bool return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
{
$expected = session('keka_ms_oauth_state');
if (! $expected || ! $state) {
return false;
}
return hash_equals($expected, $state);
}
public function isConnected(User $user): bool
{
return OauthToken::where('user_id', $user->id)
->where('provider', self::PROVIDER)
->exists();
} }
/** /**
* Validate the "state" returned by Microsoft against the one stored in session.
*/
public function isValidState(?string $state): bool
{
$expected = session('keka_ms_oauth_state');
if (! $expected || ! $state) {
return false;
}
return hash_equals($expected, $state);
}
/**
* Exchange the authorization code for access + refresh tokens and store them on the user.
*
* @return array{success: bool, message?: string} * @return array{success: bool, message?: string}
*/ */
public function handleCallback(User $user, string $code): array public function handleCallback(User $user, string $code): array
@ -88,84 +112,114 @@ public function handleCallback(User $user, string $code): array
} }
$data = $response->json(); $data = $response->json();
$this->storeTokens($user, $data); $this->storeTokens($user, $data);
// Fetch the connected mailbox's email address for display purposes.
$profile = Http::withToken($data['access_token'])
->get('https://graph.microsoft.com/v1.0/me');
if ($profile->successful()) {
$user->keka_ms_email = $profile->json('mail') ?? $profile->json('userPrincipalName');
$user->save();
}
session()->forget('keka_ms_oauth_state'); session()->forget('keka_ms_oauth_state');
return ['success' => true]; return ['success' => true];
} }
/**
* Persist tokens + expiry on the user model.
*/
private function storeTokens(User $user, array $tokenData): void
{
$user->keka_ms_access_token = $tokenData['access_token'];
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
// If a new one isn't returned on refresh, keep the existing one.
if (! empty($tokenData['refresh_token'])) {
$user->keka_ms_refresh_token = $tokenData['refresh_token'];
}
$user->save();
}
/** /**
* Returns a valid access token, silently refreshing if expired. * Get a valid access token for the user.
* Silently refreshes via refresh_token if the current access token has expired.
* *
* @throws \RuntimeException when not connected or refresh fails (caller must redirect to connect) * @throws RuntimeException if the user has not connected or refresh fails.
*/ */
public function getValidToken(User $user): string public function getValidToken(User $user): string
{ {
$record = OauthToken::where('user_id', $user->id) if (! $this->isConnected($user)) {
->where('provider', self::PROVIDER) throw new RuntimeException('User has not connected Keka/Outlook.');
->first();
if (! $record) {
throw new \RuntimeException('Not connected.');
} }
// Valid with 5-minute buffer // Still valid? (1 minute buffer)
if ($record->expires_at->gt(now()->addMinutes(5))) { if (
return $record->access_token; $user->keka_ms_access_token
&& $user->keka_ms_token_expires_at
&& now()->lt($user->keka_ms_token_expires_at->subMinute())
) {
return $user->keka_ms_access_token;
} }
// Expired — silent refresh return $this->refreshToken($user);
if (! $record->refresh_token) { }
$record->delete();
throw new \RuntimeException('No refresh token. Please reconnect.');
}
/**
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
*/
public function isConnected(User $user): bool
{
return ! empty($user->keka_ms_refresh_token);
}
/**
* Use the stored refresh_token to obtain a new access_token silently.
*
* @throws RuntimeException if Microsoft rejects the refresh token (user must reconnect).
*/
private function refreshToken(User $user): string
{
$response = Http::asForm()->post( $response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[ [
'client_id' => $this->clientId, 'client_id' => $this->clientId,
'client_secret' => $this->clientSecret, 'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
'refresh_token' => $record->refresh_token, 'refresh_token' => $user->keka_ms_refresh_token,
'scope' => implode(' ', $this->scopes), 'scope' => implode(' ', $this->scopes),
] ]
); );
if (! $response->successful()) { if (! $response->successful()) {
$record->delete(); // Refresh token expired/revoked -> force user to reconnect.
throw new \RuntimeException('Session expired. Please reconnect Keka.'); $this->disconnect($user);
throw new RuntimeException(
$response->json('error_description', 'Session expired. Please reconnect Keka.')
);
} }
$data = $response->json(); $data = $response->json();
$this->storeTokens($user, $data);
$record->update([
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? $record->refresh_token,
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
]);
return $data['access_token']; return $data['access_token'];
} }
/**
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
*/
public function disconnect(User $user): void public function disconnect(User $user): void
{ {
OauthToken::where('user_id', $user->id) $user->keka_ms_access_token = null;
->where('provider', self::PROVIDER) $user->keka_ms_refresh_token = null;
->delete(); $user->keka_ms_token_expires_at = null;
} $user->keka_ms_email = null;
$user->save();
private function storeTokens(User $user, array $data): void
{
OauthToken::updateOrCreate(
['user_id' => $user->id, 'provider' => self::PROVIDER],
[
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? null,
'token_type' => $data['token_type'] ?? 'Bearer',
'scopes' => $data['scope'] ?? implode(' ', $this->scopes),
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
]
);
} }
} }

View File

@ -4,7 +4,6 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\{Exceptions, Middleware}; use Illuminate\Foundation\Configuration\{Exceptions, Middleware};
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@ -14,12 +13,8 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [ $middleware->validateCsrfTokens(except: [
App\Http\Middleware\CaptureOidcNonce::class, 'keka/callback',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {})->create();
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();

View File

@ -6,5 +6,4 @@
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
App\Providers\DirectiveServiceProvider::class, App\Providers\DirectiveServiceProvider::class,
App\Providers\PassportServiceProvider::class,
]; ];

232
composer.lock generated
View File

@ -277,22 +277,23 @@
}, },
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.17.2", "version": "0.14.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/brick/math.git", "url": "https://github.com/brick/math.git",
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.2" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpstan/phpstan": "2.1.22", "phpstan/phpstan": "2.1.22",
"phpunit/phpunit": "^11.5" "phpunit/phpunit": "^11.5"
}, },
@ -324,7 +325,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/brick/math/issues", "issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.17.2" "source": "https://github.com/brick/math/tree/0.14.8"
}, },
"funding": [ "funding": [
{ {
@ -332,7 +333,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-05-25T20:34:43+00:00" "time": "2026-02-10T14:33:43+00:00"
}, },
{ {
"name": "carbonphp/carbon-doctrine-types", "name": "carbonphp/carbon-doctrine-types",
@ -1283,22 +1284,22 @@
}, },
{ {
"name": "guzzlehttp/guzzle", "name": "guzzlehttp/guzzle",
"version": "7.12.0", "version": "7.11.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/guzzle.git", "url": "https://github.com/guzzle/guzzle.git",
"reference": "eaa81598031cf57a9e36258c8546defffc994cba" "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/eaa81598031cf57a9e36258c8546defffc994cba", "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
"reference": "eaa81598031cf57a9e36258c8546defffc994cba", "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"guzzlehttp/promises": "^2.5", "guzzlehttp/promises": "^2.5",
"guzzlehttp/psr7": "^2.12", "guzzlehttp/psr7": "^2.11",
"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.5 || ^3.0",
@ -1391,7 +1392,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.0" "source": "https://github.com/guzzle/guzzle/tree/7.11.1"
}, },
"funding": [ "funding": [
{ {
@ -1407,7 +1408,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-16T22:11:48+00:00" "time": "2026-06-07T22:54:06+00:00"
}, },
{ {
"name": "guzzlehttp/promises", "name": "guzzlehttp/promises",
@ -1495,16 +1496,16 @@
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.12.0", "version": "2.11.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43" "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43", "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f",
"reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43", "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1594,7 +1595,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.0" "source": "https://github.com/guzzle/psr7/tree/2.11.0"
}, },
"funding": [ "funding": [
{ {
@ -1610,20 +1611,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-16T21:50:11+00:00" "time": "2026-06-02T12:30:48+00:00"
}, },
{ {
"name": "guzzlehttp/uri-template", "name": "guzzlehttp/uri-template",
"version": "v1.0.7", "version": "v1.0.6",
"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": "eef7f87bab6f204eba3c39224d8075c70c637946"
}, },
"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/eef7f87bab6f204eba3c39224d8075c70c637946",
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529", "reference": "eef7f87bab6f204eba3c39224d8075c70c637946",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1680,7 +1681,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.6"
}, },
"funding": [ "funding": [
{ {
@ -1696,7 +1697,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-12T21:33:43+00:00" "time": "2026-05-23T22:00:21+00:00"
}, },
{ {
"name": "jfcherng/php-color-output", "name": "jfcherng/php-color-output",
@ -2001,16 +2002,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.16.1", "version": "v13.15.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "6135650d69bd9442e470bb1b343422081b076f1e" "reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/6135650d69bd9442e470bb1b343422081b076f1e", "url": "https://api.github.com/repos/laravel/framework/zipball/7e23b2aa4e1133a43835c93a810b4bedc40e425b",
"reference": "6135650d69bd9442e470bb1b343422081b076f1e", "reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2221,7 +2222,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-16T16:07:50+00:00" "time": "2026-06-09T13:45:51+00:00"
}, },
{ {
"name": "laravel/passkeys", "name": "laravel/passkeys",
@ -3551,16 +3552,16 @@
}, },
{ {
"name": "mallardduck/blade-lucide-icons", "name": "mallardduck/blade-lucide-icons",
"version": "1.26.24", "version": "1.26.21",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/mallardduck/blade-lucide-icons.git", "url": "https://github.com/mallardduck/blade-lucide-icons.git",
"reference": "25ed71dd92f43e25f524deb06bd482261629293b" "reference": "495df35fc2678415a2b22b44cc6e1eea75dd99fb"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/mallardduck/blade-lucide-icons/zipball/25ed71dd92f43e25f524deb06bd482261629293b", "url": "https://api.github.com/repos/mallardduck/blade-lucide-icons/zipball/495df35fc2678415a2b22b44cc6e1eea75dd99fb",
"reference": "25ed71dd92f43e25f524deb06bd482261629293b", "reference": "495df35fc2678415a2b22b44cc6e1eea75dd99fb",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3605,9 +3606,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/mallardduck/blade-lucide-icons/issues", "issues": "https://github.com/mallardduck/blade-lucide-icons/issues",
"source": "https://github.com/mallardduck/blade-lucide-icons/tree/1.26.24" "source": "https://github.com/mallardduck/blade-lucide-icons/tree/1.26.21"
}, },
"time": "2026-06-17T00:50:15+00:00" "time": "2026-05-29T00:45:59+00:00"
}, },
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
@ -3714,16 +3715,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.12.3", "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": "6e7853a668c3107294aff38d42bf760ec02126b6" "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6e7853a668c3107294aff38d42bf760ec02126b6", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
"reference": "6e7853a668c3107294aff38d42bf760ec02126b6", "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3815,7 +3816,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-14T20:41:42+00:00" "time": "2026-04-07T09:57:54+00:00"
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
@ -5504,20 +5505,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"
}, },
@ -5576,9 +5577,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": "robrichards/xmlseclibs", "name": "robrichards/xmlseclibs",
@ -6035,16 +6036,16 @@
}, },
{ {
"name": "spatie/php-structure-discoverer", "name": "spatie/php-structure-discoverer",
"version": "2.4.4", "version": "2.4.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/spatie/php-structure-discoverer.git", "url": "https://github.com/spatie/php-structure-discoverer.git",
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b" "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/fa2b7dae8e8a22c0306154c4b052420e054f7e2b", "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b", "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6102,7 +6103,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/spatie/php-structure-discoverer/issues", "issues": "https://github.com/spatie/php-structure-discoverer/issues",
"source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.4" "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2"
}, },
"funding": [ "funding": [
{ {
@ -6110,7 +6111,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-15T07:14:32+00:00" "time": "2026-04-28T06:26:02+00:00"
}, },
{ {
"name": "spomky-labs/cbor-php", "name": "spomky-labs/cbor-php",
@ -9579,16 +9580,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": {
@ -9639,9 +9640,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"
} }
], ],
"packages-dev": [ "packages-dev": [
@ -9880,29 +9881,28 @@
}, },
{ {
"name": "composer/pcre", "name": "composer/pcre",
"version": "3.4.0", "version": "3.3.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/composer/pcre.git", "url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed" "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.4 || ^8.0" "php": "^7.4 || ^8.0"
}, },
"conflict": { "conflict": {
"phpstan/phpstan": "<2.2.2" "phpstan/phpstan": "<1.11.10"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^2", "phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-deprecation-rules": "^2", "phpstan/phpstan-strict-rules": "^1 || ^2",
"phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^8 || ^9"
"phpunit/phpunit": "^9"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@ -9940,7 +9940,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/composer/pcre/issues", "issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0" "source": "https://github.com/composer/pcre/tree/3.3.2"
}, },
"funding": [ "funding": [
{ {
@ -9950,9 +9950,13 @@
{ {
"url": "https://github.com/composer", "url": "https://github.com/composer",
"type": "github" "type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
} }
], ],
"time": "2026-06-07T11:47:49+00:00" "time": "2024-11-12T16:29:46+00:00"
}, },
{ {
"name": "composer/xdebug-handler", "name": "composer/xdebug-handler",
@ -10439,16 +10443,16 @@
}, },
{ {
"name": "laradumps/laradumps-core", "name": "laradumps/laradumps-core",
"version": "v4.1.0", "version": "v4.0.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laradumps/laradumps-core.git", "url": "https://github.com/laradumps/laradumps-core.git",
"reference": "ffd0811bee6c2dc6e230705b6a0751fbcbf3921f" "reference": "41acc4a0dba81232287bed4d98de112b1c466244"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/ffd0811bee6c2dc6e230705b6a0751fbcbf3921f", "url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/41acc4a0dba81232287bed4d98de112b1c466244",
"reference": "ffd0811bee6c2dc6e230705b6a0751fbcbf3921f", "reference": "41acc4a0dba81232287bed4d98de112b1c466244",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10498,7 +10502,7 @@
"homepage": "https://github.com/laradumps/laradumps-core", "homepage": "https://github.com/laradumps/laradumps-core",
"support": { "support": {
"issues": "https://github.com/laradumps/laradumps-core/issues", "issues": "https://github.com/laradumps/laradumps-core/issues",
"source": "https://github.com/laradumps/laradumps-core/tree/v4.1.0" "source": "https://github.com/laradumps/laradumps-core/tree/v4.0.6"
}, },
"funding": [ "funding": [
{ {
@ -10506,7 +10510,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-17T00:41:51+00:00" "time": "2026-03-19T15:05:22+00:00"
}, },
{ {
"name": "larastan/larastan", "name": "larastan/larastan",
@ -10728,16 +10732,16 @@
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
"version": "v0.8.1", "version": "v0.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/mcp.git", "url": "https://github.com/laravel/mcp.git",
"reference": "fdc39c9e21048801508765cb4d72bd9e8501b51f" "reference": "18221a07093d84153883bc956e5e213999549a4b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/fdc39c9e21048801508765cb4d72bd9e8501b51f", "url": "https://api.github.com/repos/laravel/mcp/zipball/18221a07093d84153883bc956e5e213999549a4b",
"reference": "fdc39c9e21048801508765cb4d72bd9e8501b51f", "reference": "18221a07093d84153883bc956e5e213999549a4b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10798,7 +10802,7 @@
"issues": "https://github.com/laravel/mcp/issues", "issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp" "source": "https://github.com/laravel/mcp"
}, },
"time": "2026-06-11T13:59:49+00:00" "time": "2026-06-08T13:48:51+00:00"
}, },
{ {
"name": "laravel/pail", "name": "laravel/pail",
@ -10882,16 +10886,16 @@
}, },
{ {
"name": "laravel/pao", "name": "laravel/pao",
"version": "v1.1.1", "version": "v1.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/pao.git", "url": "https://github.com/laravel/pao.git",
"reference": "8f6c8094d701a03041c85ba583ca233398c792ec" "reference": "519eecdefb9d5da362876376b52207c8f11b477c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/pao/zipball/8f6c8094d701a03041c85ba583ca233398c792ec", "url": "https://api.github.com/repos/laravel/pao/zipball/519eecdefb9d5da362876376b52207c8f11b477c",
"reference": "8f6c8094d701a03041c85ba583ca233398c792ec", "reference": "519eecdefb9d5da362876376b52207c8f11b477c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10910,7 +10914,7 @@
"orchestra/testbench": "^10.11.0 || ^11.1.0", "orchestra/testbench": "^10.11.0 || ^11.1.0",
"pestphp/pest": "^4.7.2 || ^5.0.0", "pestphp/pest": "^4.7.2 || ^5.0.0",
"pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0",
"phpstan/phpstan": "^2.2.2", "phpstan/phpstan": "^2.2.1",
"rector/rector": "^2.4.5", "rector/rector": "^2.4.5",
"symfony/process": "^7.4.8 || ^8.1.0", "symfony/process": "^7.4.8 || ^8.1.0",
"symfony/var-dumper": "^7.4.8 || ^8.1.0" "symfony/var-dumper": "^7.4.8 || ^8.1.0"
@ -10963,20 +10967,20 @@
"issues": "https://github.com/laravel/pao/issues", "issues": "https://github.com/laravel/pao/issues",
"source": "https://github.com/laravel/pao" "source": "https://github.com/laravel/pao"
}, },
"time": "2026-06-12T08:00:22+00:00" "time": "2026-06-01T07:26:51+00:00"
}, },
{ {
"name": "laravel/pint", "name": "laravel/pint",
"version": "v1.29.3", "version": "v1.29.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/pint.git", "url": "https://github.com/laravel/pint.git",
"reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
"reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10987,14 +10991,14 @@
"php": "^8.2.0" "php": "^8.2.0"
}, },
"require-dev": { "require-dev": {
"friendsofphp/php-cs-fixer": "^3.95.8", "friendsofphp/php-cs-fixer": "^3.95.1",
"illuminate/view": "^12.62.0", "illuminate/view": "^12.56.0",
"larastan/larastan": "^3.10.0", "larastan/larastan": "^3.9.6",
"laravel-zero/framework": "^12.1.0", "laravel-zero/framework": "^12.1.0",
"laravel/agent-detector": "^2.0.2",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6" "pestphp/pest": "^3.8.6",
"shipfastlabs/agent-detector": "^1.1.3"
}, },
"bin": [ "bin": [
"builds/pint" "builds/pint"
@ -11031,7 +11035,7 @@
"issues": "https://github.com/laravel/pint/issues", "issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint" "source": "https://github.com/laravel/pint"
}, },
"time": "2026-06-16T15:34:04+00:00" "time": "2026-04-20T15:26:14+00:00"
}, },
{ {
"name": "laravel/roster", "name": "laravel/roster",
@ -11398,16 +11402,16 @@
}, },
{ {
"name": "pestphp/pest", "name": "pestphp/pest",
"version": "v4.7.3", "version": "v4.7.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/pestphp/pest.git", "url": "https://github.com/pestphp/pest.git",
"reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f" "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/pestphp/pest/zipball/87882a8561bf3ddf230b9a6b764f367f687d5b2f", "url": "https://api.github.com/repos/pestphp/pest/zipball/40b88b62ef8a7c6fcae5fc28f1fa747f601c131b",
"reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f", "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -11420,12 +11424,12 @@
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1", "pestphp/pest-plugin-profanity": "^4.2.1",
"php": "^8.3.0", "php": "^8.3.0",
"phpunit/phpunit": "^12.5.29", "phpunit/phpunit": "^12.5.28",
"symfony/process": "^7.4.13|^8.1.0" "symfony/process": "^7.4.13|^8.1.0"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.29", "phpunit/phpunit": ">12.5.28",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@ -11501,7 +11505,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/pestphp/pest/issues", "issues": "https://github.com/pestphp/pest/issues",
"source": "https://github.com/pestphp/pest/tree/v4.7.3" "source": "https://github.com/pestphp/pest/tree/v4.7.2"
}, },
"funding": [ "funding": [
{ {
@ -11513,7 +11517,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-12T05:57:27+00:00" "time": "2026-06-01T06:08:59+00:00"
}, },
{ {
"name": "pestphp/pest-plugin", "name": "pestphp/pest-plugin",
@ -12390,16 +12394,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "12.5.29", "version": "12.5.28",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4",
"reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -12413,7 +12417,7 @@
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.3", "php": ">=8.3",
"phpunit/php-code-coverage": "^12.5.7", "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1", "phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0", "phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0", "phpunit/php-text-template": "^5.0.0",
@ -12423,7 +12427,7 @@
"sebastian/diff": "^7.0.0", "sebastian/diff": "^7.0.0",
"sebastian/environment": "^8.1.2", "sebastian/environment": "^8.1.2",
"sebastian/exporter": "^7.0.3", "sebastian/exporter": "^7.0.3",
"sebastian/global-state": "^8.0.3", "sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0", "sebastian/object-enumerator": "^7.0.0",
"sebastian/recursion-context": "^7.0.1", "sebastian/recursion-context": "^7.0.1",
"sebastian/type": "^6.0.4", "sebastian/type": "^6.0.4",
@ -12468,7 +12472,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28"
}, },
"funding": [ "funding": [
{ {
@ -12476,7 +12480,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-06-04T06:14:42+00:00" "time": "2026-05-27T14:01:10+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",

View File

@ -7,13 +7,22 @@
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'), 'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'), // Your app's redirect URI for Keka OAuth flow
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
// After first-time login: Keka's own Microsoft SSO URL
// After already connected: Keka dashboard
'keka_login_url' => env('KEKA_LOGIN_URL', 'https://sentientgeeks.keka.com/'),
'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'), 'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
'keka_scopes' => [ 'keka_scopes' => [
'openid', 'profile', 'email', 'openid',
'offline_access', // ← required for refresh_token 'profile',
'email',
'offline_access',
'User.Read', 'User.Read',
'Mail.Read', 'Mail.ReadWrite', 'Mail.Send', 'Mail.Read',
'Mail.ReadWrite',
'Mail.Send',
], ],
]; ];

View File

@ -1,24 +0,0 @@
<?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('connected_apps', function (Blueprint $table): void {
$table->string('logo')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('connected_apps', function (Blueprint $table): void {
$table->dropColumn('logo');
});
}
};

View File

@ -22,5 +22,6 @@ public function run(): void
$this->call(DefaultRoleWithPermissionSeeder::class); $this->call(DefaultRoleWithPermissionSeeder::class);
$this->call(ExampleUserWithRoleSeeder::class); $this->call(ExampleUserWithRoleSeeder::class);
$this->call(TicketStatusSeeder::class); $this->call(TicketStatusSeeder::class);
$this->call(MockDataSeeder::class);
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

View File

@ -10,7 +10,7 @@
<div> <div>
<x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0"> <x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0">
<x-slot:actions> <x-slot:actions>
<x-shared.button> <x-shared.button :link="route('apps.create')">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<x-lucide-plus class="w-5 h-5"/> <x-lucide-plus class="w-5 h-5"/>
{{ __('Connect App') }} {{ __('Connect App') }}

View File

@ -1,7 +1,3 @@
{{--
Read /App/Livewire/Concerns/Choices/README.md first.
This will give you a resuable idea of using this component
--}}
@props([ @props([
'label' => 'Select Items', 'label' => 'Select Items',
'options', // The data collection passed from the parent 'options', // The data collection passed from the parent

View File

@ -1,48 +0,0 @@
@props([
'model', // property path on the host component, e.g. "uris"
'items' => [], // current array value
'label' => null,
'addLabel' => 'Add',
'placeholder' => null,
'icon' => null,
'min' => 1,
])
<div {{ $attributes->class(['w-full']) }}>
@if ($label)
<label class="label pb-1">
<span class="label-text font-semibold">{{ $label }}</span>
</label>
@endif
@foreach ($items as $index => $value)
<x-mary-input
wire:key="{{ $model }}-row-{{ $index }}"
wire:model="{{ $model }}.{{ $index }}"
placeholder="{{ $placeholder }}"
icon="{{ $icon }}"
@class([
'border-r-red-400' => count($items) > $min
])
>
@if (count($items) > $min)
<x-slot:append>
<x-mary-button
icon="o-x-mark"
wire:click="removeRepeaterItem('{{ $model }}', {{ $index }})"
class="join-item btn-outline btn-error"
spinner
/>
</x-slot:append>
@endif
</x-mary-input>
<div class="mb-4"></div>
@endforeach
<x-mary-button
:label="$addLabel"
icon="o-plus"
wire:click="addRepeaterItem('{{ $model }}')"
class="btn-primary btn-outline"
/>
</div>

View File

@ -1,264 +0,0 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\Application\StoreOIDCAppData;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, UserAccessTypeEnum};
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
use App\Livewire\Concerns\WithRepeaterFields;
use App\Livewire\Forms\StoreOIDCAppForm;
use App\Services\{Applications\OIDC\OidcConfigResolver, Applications\OIDC\OidcService, RoleService};
use Livewire\Attributes\{Computed, Url};
use Livewire\Component;
use Livewire\WithFileUploads;
new
class extends Component {
use HandlesOperations, WithRepeaterFields, WithSearchableChoices, WithFileUploads;
#[Url]
public string $protocol = '';
#[Url]
public ?string $type = null;
// For creation states
public bool $created = false;
public ?string $clientId = null;
public ?string $clientSecret = null;
public ?ConnectedAppData $app = null;
public StoreOIDCAppForm $form;
public ConnectionProtocolEnum $connectionProtocol = ConnectionProtocolEnum::OIDC;
public ApplicationTypeEnum $applicationType;
private RoleService $roleService;
private OidcService $oidcService;
private OidcConfigResolver $configResolver;
public function boot(RoleService $roleService, OidcService $oidcService, OidcConfigResolver $configResolver): void
{
$this->roleService = $roleService;
$this->oidcService = $oidcService;
$this->configResolver = $configResolver;
}
public function mount(): void
{
$this->applicationType = ApplicationTypeEnum::from($this->type);
}
public function render()
{
/** @phpstan-ignore-next-line */
return $this->view()->title($this->title());
}
public function save(bool $goBack = true): void
{
$this->form->validate();
$this->attempt(
function () {
$data = StoreOIDCAppData::fromArray($this->applicationType, $this->form->pull());
$appdata = $this->oidcService->createApp($data);
// Show the credentials
$this->created = true;
$this->clientId = $appdata->clientCredentials->clientId;
$this->clientSecret = $appdata->clientCredentials->clientSecret;
$this->app = $appdata->application;
}
);
}
public function goBack(): void
{
$this->redirectRoute("apps.index", navigate: true);
}
public function title(): string
{
$title = "New ";
$title .= match ($this->applicationType) {
ApplicationTypeEnum::Web => "Web Application ",
ApplicationTypeEnum::SPA => "Single Page Application ",
ApplicationTypeEnum::Machine => "Microservice Application ",
};
$title .= "Integration";
return $title;
}
public function searchRoles(string $q = ''): void
{
$this->searchChoice('roles', $q);
}
public function selectAllRoles(): void
{
$this->selectAllChoice('roles');
}
public function clearRoles(): void
{
$this->clearChoice('roles');
}
protected function choiceFields(): array
{
return [
'roles' => new ChoiceField(
search: fn(string $q): array => $this->roleService->searchRolesForSelect($q)->toArray(),
allIds: $this->roleService->getAllRoleIds(...),
formPath: 'form.roleIds',
),
];
}
#[Computed]
public function hasClientSecret(): bool
{
return $this->configResolver->for($this->applicationType)->hasClientSecret();
}
};
?>
<div>
@if($created)
<x-shared.card
title="Application Created"
icon="lucide-check-circle"
>
<div class="flex w-full justify-center items-center pt-6">
<article class="mx-auto flex items-center gap-4">
<x-mary-avatar :image="$app->logo ? asset($app->logo) : asset('images/app-icon-placeholder.png')"
alt="App logo"
class="w-14! rounded-lg! p-2"/>
<h1 class="text-2xl font-medium">{{$app->name}}</h1>
</article>
</div>
<div class="space-y-6 p-6">
<x-mary-input
label="Client ID"
:value="$clientId"
readonly
/>
@if($this->hasClientSecret)
<x-mary-alert
title="Save these credentials"
description="The client secret will only be displayed once."
class="alert-warning alert-soft text-yellow-700"
/>
<x-mary-password label="Client Secret" :value="$clientSecret" readonly right/>
@endif
<div class="flex justify-end gap-4">
<x-mary-button
wire:click="goBack"
class="btn-primary"
>
Done
</x-mary-button>
</div>
</div>
</x-shared.card>
@else
<x-shared.card :title="$this->title()" icon="lucide-package-plus">
<x-slot:actions>
<x-mary-button wire:click="goBack" icon="lucide.corner-up-left" class="btn-ghost">Go Back
</x-mary-button>
</x-slot:actions>
<x-mary-form wire:submit="save" class="p-4 gap-y-8">
<!-- General settings start -->
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">General Settings</h2>
</article>
<div class="w-full md:w-3/5">
<x-mary-input required label="App Name" wire:model="form.name"
placeholder="Keka, MS 365 etc."
hint="A basic name that matches the actual application"/>
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
accept="application/jpg, application/png, image/jpeg, image/png"/>
</div>
</div>
<!-- General settings end -->
<!-- Sign-in uris start -->
<hr>
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">Sign In Redirect URIs</h2>
<p class="text-sm text-gray-500 max-w-70 mt-4">
{{config('app.name')}} sends response and ID Token back to these URIs.
</p>
</article>
<div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
</div>
</div>
<!-- Sign-in uris end -->
<!-- Sign-out uris start -->
<hr>
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">Sign out Redirect URIs</h2>
<p class="text-sm text-gray-500 max-w-70 mt-4">
{{config('app.name')}} sends back the user to these URIs after logout.
</p>
</article>
<div class="w-full md:w-3/5">
<x-shared.repeater-input model="form.signOutUris" :items="$form->signOutUris"/>
</div>
</div>
<!-- Sign-out uris end -->
<!-- Access start -->
<hr>
<div class="flex gap-4 flex-col w-full md:flex-row">
<article class="w-full md:w-2/5">
<h2 class="font-bold">User Access</h2>
<p class="text-sm text-gray-500 max-w-70 mt-4">
Choose whether to allow all users be able to connect this app or selected ones.
</p>
</article>
<div class="w-full md:w-3/5">
<x-mary-radio
:options="UserAccessTypeEnum::asOptions()"
option-value="value"
wire:model.change.live="form.userAccessOption"
/>
<div class=""
x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"
x-cloak
>
<x-shared.choices
wire:model.live="form.roleIds"
:options="$choicesSearchable['roles'] ?? []"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles"
/>
</div>
</div>
</div>
<!-- Access end -->
<div class="flex gap-x-4 font-medium justify-end">
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
<x-mary-button type="submit"
class="btn-primary">
Save
</x-mary-button>
</div>
</x-mary-form>
</x-shared.card>
@endif
</div>

View File

@ -0,0 +1,209 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Data\ConnectedApp\ConnectionStatusData;
use App\Enums\ConnectionStatusEnum;
use App\Livewire\Forms\StoreConnectedAppFrom;
use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus};
use App\Services\{
ConnectedAppService,
ConnectionProtocolService,
ConnectionProviderService,
ConnectionStatusService,
};
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed, Title};
use Livewire\Component;
use Mary\Traits\Toast;
use Spatie\LaravelData\DataCollection;
new #[Title("Create a service")]
class extends Component {
use HandlesOperations;
public StoreConnectedAppFrom $form;
protected ConnectedAppService $appService;
public function boot(ConnectedAppService $appService): void
{
$this->appService = $appService;
}
#[Computed]
public function providers()
{
return ConnectionProvider::query()->get();
}
#[Computed]
public function protocols(): Collection
{
$protocolService = app(ConnectionProtocolService::class);
return $protocolService
->getAll()
->map(function (ConnectionProtocol $protocol) {
$protocol->name = strtoupper($protocol->name);
return $protocol;
});
}
/**
* @returns DataCollection<int, ConnectionStatus>
*/
#[Computed]
public function connectionStatuses(): DataCollection
{
$statusService = app(ConnectionStatusService::class);
return $statusService
->getAll()
->map(function (ConnectionStatusData $status) {
if ($status->name === ConnectionStatusEnum::Disconnected) {
$this->form->statusId = $status->id;
}
return $status;
});
}
#[Computed]
public function isSaml(): bool
{
$protocol = $this->protocols()->firstWhere(
"id",
(int) $this->form->protocolId,
);
return $protocol && strtolower($protocol->name) === "saml";
}
#[Computed]
public function isKekaProvider(): bool
{
$provider = $this->providers()->firstWhere(
"id",
(int) $this->form->providerId,
);
return $provider && $provider->slug === "keka-hr";
}
public function save(bool $goBack = true): void
{
$this->form->validate();
$settings = null;
if ($this->isSaml()) {
$attributes = [];
foreach ($this->form->samlAttributes as $attr) {
if (!empty($attr['saml_name']) && !empty($attr['user_field'])) {
$attributes[$attr['saml_name']] = $attr['user_field'];
}
}
$settings = [
"saml" => [
"entity_id" => $this->form->samlEntityId,
"acs_url" => $this->form->samlAcsUrl,
"nameid_format" => $this->form->samlNameIdFormat,
"nameid_source" => $this->form->samlNameIdSource,
"attributes" => $attributes,
],
];
} elseif ($this->isKekaProvider()) {
$settings = [
"keka_url" => $this->form->kekaUrl,
"keka" => [
"keka_url" => $this->form->kekaUrl,
"callback_path" => "signin-oidc",
"provider" => "Office365",
],
];
}
$data = new ConnectAppRequest(
name: $this->form->name,
connectionProviderId: $this->form->providerId,
connectionProtocolId: $this->form->protocolId,
slug: str($this->form->name)->slug()->toString(),
connectionStatusId: $this->form->statusId,
settings: $settings,
);
$this->attempt(
action: function () use ($data, $goBack) {
$this->appService->create($data);
$this->form->reset();
if ($goBack) {
$this->goBack();
}
},
);
}
public function addSamlAttribute(): void
{
$this->form->addSamlAttribute();
}
public function removeSamlAttribute(int $index): void
{
$this->form->removeSamlAttribute($index);
}
public function goBack(): void
{
$this->redirectRoute("apps.index", navigate: true);
}
};
?>
<div>
<x-shared.card :title="__('Create a service')" class="">
<x-mary-form wire:submit="save" class="p-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<x-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
<x-mary-choices
required
wire:model.live="form.protocolId"
:label="__('Protocol')"
:single="true"
:options="$this->protocols"
placeholder="Select"
/>
<x-mary-choices
required
wire:model.live="form.providerId"
:label="__('Provider')"
placeholder="Select"
:single="true"
:options="$this->providers"
/>
<x-mary-choices
required
wire:model="form.statusId"
:label="__('Status')"
:single="true"
:options="$this->connectionStatuses->toArray()"
placeholder="Select"
/>
@if($this->isSaml)
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
@endif
@if($this->isKekaProvider)
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
</div>
@endif
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
<x-mary-button type="submit"
class="btn-primary">
Save
</x-mary-button>
</div>
</x-mary-form>
</x-shared.card>
</div>

View File

@ -4,9 +4,6 @@
use App\Concerns\HandlesOperations; use App\Concerns\HandlesOperations;
use App\Data\Ui\ConnectedApps\ConnectedAppData; use App\Data\Ui\ConnectedApps\ConnectedAppData;
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Enums\ApplicationTypeEnum;
use App\Enums\ConnectionProtocolEnum;
use App\Livewire\Forms\CreateAppSelectionForm;
use App\Services\ConnectedAppService; use App\Services\ConnectedAppService;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
@ -22,8 +19,6 @@ class extends Component {
use WithPagination, HandlesOperations, Confirmation; use WithPagination, HandlesOperations, Confirmation;
protected ConnectedAppService $service; protected ConnectedAppService $service;
public bool $showAppCreationModal = false;
public CreateAppSelectionForm $appSelectionForm;
#[DataCollectionOf(TableHeader::class)] #[DataCollectionOf(TableHeader::class)]
public DataCollection $headers; public DataCollection $headers;
@ -69,22 +64,13 @@ public function delete(int $appId): void
successMessage: 'The app has been deleted !' successMessage: 'The app has been deleted !'
); );
} }
public function createApp(): void
{
$this->appSelectionForm->validate();
$route = "apps.{$this->appSelectionForm->connectionProtocol}.create";
$this->redirectRoute($route, [
'type' => $this->appSelectionForm->applicationType
], navigate: true);
}
}; };
?> ?>
<div> <div>
<x-shared.card title="Connected Apps" class="pb-2"> <x-shared.card title="Connected Apps" class="pb-2">
<x-slot:actions> <x-slot:actions>
<x-mary-button @click="$wire.showAppCreationModal = true" icon="lucide.plus">Add App <x-mary-button :link="route('apps.create')" wire:navigate icon="lucide.plus">Add App
</x-mary-button> </x-mary-button>
</x-slot:actions> </x-slot:actions>
<x-mary-table <x-mary-table
@ -99,7 +85,6 @@ public function createApp(): void
@scope('cell_status', $row) @scope('cell_status', $row)
<x-shared.connection-status :status="$row->status"/> <x-shared.connection-status :status="$row->status"/>
@endscope @endscope
@scope('actions', $row) @scope('actions', $row)
<div class="flex"> <div class="flex">
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})" <x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})"
@ -112,44 +97,4 @@ class="btn-sm btn-circle btn-error btn-soft"/>
@endscope @endscope
</x-mary-table> </x-mary-table>
</x-shared.card> </x-shared.card>
<x-mary-modal title='Create a new app' wire:model="showAppCreationModal" box-class="w-11/12 max-w-4xl">
<div class="flex flex-col md:flex-row gap-6 mb-6">
<article class="md:w-1/3 shrink-0">
<h2 class="font-bold">Protocol</h2>
</article>
<div class="flex-1">
<x-mary-radio
wire:model.change.live="appSelectionForm.connectionProtocol"
:options="ConnectionProtocolEnum::asOptions()"
option-value="value"
/>
</div>
</div>
<div class="flex flex-col md:flex-row gap-6 mt-6 border-t border-gray-200 pt-6"
x-show="$wire.appSelectionForm.connectionProtocol === '{{ ConnectionProtocolEnum::OIDC->value }}'"
x-cloak>
<article class="md:w-1/3 shrink-0">
<h2 class="font-bold">Application Type</h2>
<p class="text-sm mt-1 text-gray-500">
Choose what type of application you are trying to connect. This helps to properly generate
responses.
</p>
</article>
<div class="flex-1">
<x-mary-radio wire:model="appSelectionForm.applicationType" :options="ApplicationTypeEnum::asOptions()"
option-value="value"/>
</div>
</div>
<x-slot:actions>
<x-mary-button @click="$wire.showAppCreationModal = false">Cancel</x-mary-button>
<x-mary-button class="btn-primary" wire:click="createApp" spinner="createApp">Create</x-mary-button>
</x-slot:actions>
</x-mary-modal>
</div> </div>

View File

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\ConnectionProtocolEnum;
use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum};
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\PermissionMiddleware;
Route::prefix('apps')->name('apps.')->group(function (): void {
Route::livewire('microsoft-federation', 'pages::connecton-providers.microsoft-federation')
->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access))
->name('microsoft-federation');
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void {
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Create))->group(function (): void {
Route::prefix(ConnectionProtocolEnum::OIDC->value)
->name(ConnectionProtocolEnum::OIDC->value.'.')
->group(function (): void {
Route::livewire('create', 'pages::apps.oidc.create')->name('create');
});
});
Route::livewire(
'{id}/edit',
'pages::apps.edit'
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit');
Route::livewire('/', 'pages::apps.index')->name('index');
});
Route::prefix('connection-providers')->name('connectionProviders.')
->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access))
->group(function (): void {
Route::livewire('create', 'pages::connecton-providers.create')->name('create');
Route::livewire('{slug}/edit', 'pages::connecton-providers.edit')->name('edit');
Route::livewire('index', 'pages::connecton-providers.index')->name('index');
});
});

View File

@ -3,7 +3,11 @@
declare(strict_types=1); declare(strict_types=1);
use App\Enums\DefaultUserRole; use App\Enums\DefaultUserRole;
use App\Enums\Permissions\{PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; use App\Enums\Permissions\{AppPermissionEnum,
ConnectionProviderPermissionEnum,
PermissionPermissionEnum,
RoleAndAppsPermissionEnum,
UserPermissionEnum};
use App\Http\Controllers\{EntraController, KekaController}; use App\Http\Controllers\{EntraController, KekaController};
use App\Http\Controllers\KekaOutlookController; use App\Http\Controllers\KekaOutlookController;
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController}; use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
@ -58,7 +62,25 @@
}); });
}); });
require __DIR__.'/apps.php'; Route::prefix('apps')->name('apps.')->group(function (): void {
Route::livewire('microsoft-federation', 'pages::connecton-providers.microsoft-federation')
->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access))
->name('microsoft-federation');
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void {
Route::livewire('create', 'pages::connected-apps.create')->name('create');
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');
Route::livewire('/', 'pages::connected-apps.index')->name('index');
});
Route::prefix('connection-providers')->name('connectionProviders.')
->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access))
->group(function (): void {
Route::livewire('create', 'pages::connecton-providers.create')->name('create');
Route::livewire('{slug}/edit', 'pages::connecton-providers.edit')->name('edit');
Route::livewire('index', 'pages::connecton-providers.index')->name('index');
});
});
}); });
Route::middleware(['auth', 'verified'])->prefix('entra')->name('entra.')->group(function (): void { Route::middleware(['auth', 'verified'])->prefix('entra')->name('entra.')->group(function (): void {

View File

@ -2,7 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Middleware\CaptureOidcNonce;
use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType}; use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType};
use Defuse\Crypto\Crypto; use Defuse\Crypto\Crypto;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -64,43 +63,3 @@
// 4. Assert the resolved nonce is correct // 4. Assert the resolved nonce is correct
expect($resolvedNonce)->toBe('secure-nonce-value-xyz'); expect($resolvedNonce)->toBe('secure-nonce-value-xyz');
}); });
it('captures nonce via middleware and resolves it from session during POST approval request', function (): void {
// 1. Setup session
$session = app('session.store');
// 2. GET Request (Middleware captures nonce into session)
$getRequest = Request::create('/oauth/authorize', 'GET', ['nonce' => 'session-nonce-value-999']);
$getRequest->setLaravelSession($session);
$middleware = new CaptureOidcNonce();
$middleware->handle($getRequest, fn () => new Symfony\Component\HttpFoundation\Response());
expect($session->get('oidc_nonce'))->toBe('session-nonce-value-999');
// 3. POST Request (No nonce in parameters, but resolved from session)
$postRequest = Request::create('/oauth/authorize', 'POST');
$postRequest->setLaravelSession($session);
app()->instance('request', $postRequest);
// Mock AuthCodeEntity
$client = Mockery::mock(ClientEntityInterface::class);
$client->shouldReceive('getIdentifier')->andReturn('client-123');
$authCodeEntity = Mockery::mock(AuthCodeEntityInterface::class);
$authCodeEntity->shouldReceive('getIdentifier')->andReturn('code-id-session');
$authCodeEntity->shouldReceive('getUserIdentifier')->andReturn('user-1');
$authCodeEntity->shouldReceive('getClient')->andReturn($client);
$authCodeEntity->shouldReceive('getScopes')->andReturn([]);
$authCodeEntity->shouldReceive('getExpiryDateTime')->andReturn(new DateTimeImmutable('+10 minutes'));
// Persist
$repository = new CustomAuthCodeRepository();
$repository->persistNewAuthCode($authCodeEntity);
// Verify cache has it
expect(Cache::get('oidc_nonce_code-id-session'))->toBe('session-nonce-value-999');
// Verify session was cleared
expect($session->has('oidc_nonce'))->toBeFalse();
});