Compare commits
10 Commits
2682a234d4
...
19c2892569
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c2892569 | |||
| 17b916c293 | |||
|
|
81e9773f5b | ||
|
|
28f6da1a9a | ||
|
|
0b77f69818 | ||
|
|
fb7b1e3721 | ||
|
|
e791c08ce1 | ||
|
|
05c4e5eab7 | ||
|
|
a04bb5d7d1 | ||
|
|
0921535ae2 |
2
.ddev/php/xdebug.ini
Normal file
2
.ddev/php/xdebug.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[xdebug]
|
||||
xdebug.client_port = 9009
|
||||
18
app/Data/Application/ClientCredentialsData.php
Normal file
18
app/Data/Application/ClientCredentialsData.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
16
app/Data/Application/CreatedApplicationData.php
Normal file
16
app/Data/Application/CreatedApplicationData.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
38
app/Data/Application/StoreOIDCAppData.php
Normal file
38
app/Data/Application/StoreOIDCAppData.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?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'] ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,10 +13,11 @@ class ConnectAppRequest extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public int $connectionProviderId,
|
||||
public int $connectionProtocolId,
|
||||
public ?int $connectionProviderId = null,
|
||||
public ?string $slug = null,
|
||||
public ?int $connectionStatusId = null,
|
||||
public ?array $settings = null,
|
||||
public ?string $logo = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -20,5 +20,6 @@ public function __construct(
|
||||
public int $statusId,
|
||||
public ?string $slug = null,
|
||||
public ?array $settings = null,
|
||||
public ?string $logo = null
|
||||
) {}
|
||||
}
|
||||
|
||||
41
app/Enums/ApplicationTypeEnum.php
Normal file
41
app/Enums/ApplicationTypeEnum.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,40 @@
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Concerns\EnumValuesAsArray;
|
||||
use App\Contracts\EnumMorphsToOptionsContract;
|
||||
|
||||
enum ConnectionProtocolEnum: string
|
||||
enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract
|
||||
{
|
||||
use EnumValuesAsArray;
|
||||
|
||||
case OIDC = 'oidc';
|
||||
case SAML = 'saml';
|
||||
case OAUTH2 = 'oauth2';
|
||||
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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
43
app/Enums/UserAccessTypeEnum.php
Normal file
43
app/Enums/UserAccessTypeEnum.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,32 +5,28 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\OauthToken;
|
||||
use App\Services\UserAppAccessService;
|
||||
use Illuminate\Http\{RedirectResponse, Request};
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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 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()
|
||||
{
|
||||
$this->tenantId = config('services.microsoft.tenant_id');
|
||||
$this->clientId = config('services.microsoft.client_id');
|
||||
$this->clientSecret = config('services.microsoft.client_secret');
|
||||
$this->redirectUri = route('entra.callback');
|
||||
$this->tenantId = (string) config('services.microsoft.tenant_id');
|
||||
$this->clientId = (string) config('services.microsoft.client_id');
|
||||
$this->clientSecret = (string) config('services.microsoft.client_secret');
|
||||
$this->redirectUri = route('entra.callback');
|
||||
}
|
||||
|
||||
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
||||
@ -38,28 +34,43 @@ public function __construct()
|
||||
public function connect(): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
||||
|
||||
if (! $hasAzureFdAccess) {
|
||||
if (! $this->hasAzureAccess($user)) {
|
||||
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);
|
||||
session(['oauth_state' => $state]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'client_id' => $this->clientId,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => self::SCOPES,
|
||||
'state' => $state,
|
||||
'prompt' => 'select_account',
|
||||
'scope' => self::SCOPES,
|
||||
'state' => $state,
|
||||
'prompt' => 'select_account',
|
||||
]);
|
||||
|
||||
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
|
||||
@ -70,75 +81,83 @@ public function connect(): RedirectResponse
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
||||
|
||||
if (! $hasAzureFdAccess) {
|
||||
if (! $this->hasAzureAccess($user)) {
|
||||
abort(403, 'You do not have permission to access the required application.');
|
||||
}
|
||||
|
||||
// CSRF state check
|
||||
if ($request->state !== session('oauth_state')) {
|
||||
return redirect()->route('dashboard.user')->with('entra_error', 'Invalid OAuth state. Please try again.');
|
||||
if (! hash_equals((string) session('oauth_state'), (string) $request->state)) {
|
||||
session()->forget('oauth_state');
|
||||
return redirect()->route('dashboard.user')
|
||||
->with('entra_error', 'Invalid OAuth state. Please try again.');
|
||||
}
|
||||
|
||||
session()->forget('oauth_state');
|
||||
|
||||
if ($request->has('error')) {
|
||||
return redirect()->route('dashboard.user')->with('entra_error', $request->error_description ?? 'Microsoft login failed.');
|
||||
return redirect()->route('dashboard.user')
|
||||
->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
|
||||
$response = Http::asForm()->post(
|
||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||
[
|
||||
'client_id' => $this->clientId,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'code' => $request->code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $request->code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'grant_type' => 'authorization_code',
|
||||
'scope' => self::SCOPES,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
return redirect()->route('dashboard.user')->with('entra_error', 'Failed to retrieve tokens from Microsoft.');
|
||||
return redirect()->route('dashboard.user')
|
||||
->with('entra_error', $response->json('error_description', 'Failed to retrieve tokens from Microsoft.'));
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// Persist token for this user (upsert so re-connecting just updates)
|
||||
OauthToken::updateOrCreate(
|
||||
[
|
||||
'user_id' => auth()->id(),
|
||||
'user_id' => $user->id,
|
||||
'provider' => self::PROVIDER,
|
||||
],
|
||||
[
|
||||
'access_token' => $data['access_token'],
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? null,
|
||||
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||
'scopes' => self::SCOPES,
|
||||
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
||||
'token_type' => $data['token_type'] ?? 'Bearer',
|
||||
'scopes' => self::SCOPES,
|
||||
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||
]
|
||||
);
|
||||
|
||||
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft connected successfully!');
|
||||
return redirect()->route('dashboard.user')
|
||||
->with('entra_success', 'Microsoft connected successfully!');
|
||||
}
|
||||
|
||||
// ── Open Entra (refresh token silently if expired) ───────────────────────
|
||||
// ── Open Outlook (silent refresh if expired) ──────────────────────────────
|
||||
|
||||
public function openEntra(): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
||||
|
||||
if (! $hasAzureFdAccess) {
|
||||
if (! $this->hasAzureAccess($user)) {
|
||||
abort(403, 'You do not have permission to access the required application.');
|
||||
}
|
||||
|
||||
@ -148,20 +167,20 @@ public function openEntra(): RedirectResponse
|
||||
return redirect()->route('entra.connect');
|
||||
}
|
||||
|
||||
// If token is expired, try to refresh silently
|
||||
if ($token->isExpired()) {
|
||||
$refreshed = $this->refreshToken($token);
|
||||
|
||||
if (! $refreshed) {
|
||||
// Refresh failed — force re-login
|
||||
$token->delete();
|
||||
|
||||
return redirect()->route('entra.connect');
|
||||
}
|
||||
// Token still valid (5-min buffer)
|
||||
if ($token->expires_at->gt(now()->addMinutes(5))) {
|
||||
return redirect('https://outlook.office.com/mail/');
|
||||
}
|
||||
|
||||
// Redirect to Outlook Web — SSO session handles silent sign-in
|
||||
return redirect('https://outlook.office.com/mail/');
|
||||
// Token expired — try silent refresh
|
||||
if ($this->refreshToken($token)) {
|
||||
return redirect('https://outlook.office.com/mail/');
|
||||
}
|
||||
|
||||
// Refresh failed — force full re-login
|
||||
$token->delete();
|
||||
|
||||
return redirect()->route('entra.connect');
|
||||
}
|
||||
|
||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||
@ -169,23 +188,22 @@ public function openEntra(): RedirectResponse
|
||||
public function disconnect(): RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
||||
|
||||
if (! $hasAzureFdAccess) {
|
||||
if (! $this->hasAzureAccess($user)) {
|
||||
abort(403, 'You do not have permission to access the required application.');
|
||||
}
|
||||
|
||||
$user->oauthToken(self::PROVIDER)?->delete();
|
||||
|
||||
return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.');
|
||||
return redirect()->route('dashboard.user')
|
||||
->with('entra_success', 'Microsoft disconnected.');
|
||||
}
|
||||
|
||||
// ── Internal: Refresh access token using refresh_token ───────────────────
|
||||
// ── Internal: silent refresh ──────────────────────────────────────────────
|
||||
|
||||
private function refreshToken(OauthToken $token): bool
|
||||
{
|
||||
@ -196,11 +214,11 @@ private function refreshToken(OauthToken $token): bool
|
||||
$response = Http::asForm()->post(
|
||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||
[
|
||||
'client_id' => $this->clientId,
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'refresh_token' => $token->refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
'scope' => self::SCOPES,
|
||||
'grant_type' => 'refresh_token',
|
||||
'scope' => self::SCOPES,
|
||||
]
|
||||
);
|
||||
|
||||
@ -211,11 +229,20 @@ private function refreshToken(OauthToken $token): bool
|
||||
$data = $response->json();
|
||||
|
||||
$token->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'] ?? $token->refresh_token,
|
||||
'expires_at' => now()->addSeconds($data['expires_in'] ?? 3600),
|
||||
'expires_at' => now()->addSeconds((int) ($data['expires_in'] ?? 3600)),
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
24
app/Http/Middleware/CaptureOidcNonce.php
Normal file
24
app/Http/Middleware/CaptureOidcNonce.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,6 @@ public function toResponse($request)
|
||||
])
|
||||
->log('User logged in}');
|
||||
|
||||
return to_route('dashboard');
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
}
|
||||
|
||||
17
app/Livewire/Concerns/Choices/ChoiceField.php
Normal file
17
app/Livewire/Concerns/Choices/ChoiceField.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?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
|
||||
) {}
|
||||
}
|
||||
203
app/Livewire/Concerns/Choices/README.md
Normal file
203
app/Livewire/Concerns/Choices/README.md
Normal file
@ -0,0 +1,203 @@
|
||||
# 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.
|
||||
53
app/Livewire/Concerns/Choices/WithSearchableChoices.php
Normal file
53
app/Livewire/Concerns/Choices/WithSearchableChoices.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
22
app/Livewire/Concerns/WithRepeaterFields.php
Normal file
22
app/Livewire/Concerns/WithRepeaterFields.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
30
app/Livewire/Forms/CreateAppSelectionForm.php
Normal file
30
app/Livewire/Forms/CreateAppSelectionForm.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Livewire/Forms/StoreOIDCAppForm.php
Normal file
54
app/Livewire/Forms/StoreOIDCAppForm.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -46,9 +46,15 @@ public function user(): BelongsTo
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
return $this->expires_at->lte(now()->addMinutes(5)); // 5-min buffer
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return ! $this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Passport\Contracts\OAuthenticatable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
@ -26,10 +28,10 @@
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password', 'immutable_id'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
final class User extends Authenticatable implements OidcUserInterface
|
||||
final class User extends Authenticatable implements OAuthenticatable, OidcUserInterface
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasOidcClaims, SoftDeletes;
|
||||
use HasApiTokens, HasFactory, HasOidcClaims, SoftDeletes;
|
||||
|
||||
use HasRoles;
|
||||
use Notifiable;
|
||||
|
||||
@ -17,12 +17,18 @@ public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): voi
|
||||
{
|
||||
parent::persistNewAuthCode($authCodeEntity);
|
||||
|
||||
if (request()->has('nonce')) {
|
||||
$nonce = request()->input('nonce') ?? (request()->hasSession() ? request()->session()->get('oidc_nonce') : null);
|
||||
|
||||
if ($nonce) {
|
||||
Cache::put(
|
||||
'oidc_nonce_'.$authCodeEntity->getIdentifier(),
|
||||
request()->input('nonce'),
|
||||
$nonce,
|
||||
now()->addMinutes(10)
|
||||
);
|
||||
|
||||
if (request()->hasSession()) {
|
||||
request()->session()->forget('oidc_nonce');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
app/Providers/PassportServiceProvider.php
Normal file
21
app/Providers/PassportServiceProvider.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
25
app/Services/ApplicationLogoUploader.php
Normal file
25
app/Services/ApplicationLogoUploader.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
<?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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
<?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;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
24
app/Services/Applications/OIDC/OidcConfigResolver.php
Normal file
24
app/Services/Applications/OIDC/OidcConfigResolver.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
75
app/Services/Applications/OIDC/OidcService.php
Normal file
75
app/Services/Applications/OIDC/OidcService.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,15 +26,18 @@ public function __construct(
|
||||
*
|
||||
* @throws Throwable when an error occurs during the creation.
|
||||
*/
|
||||
public function create(ConnectAppRequest $data): void
|
||||
public function create(ConnectAppRequest $data): ConnectedAppData
|
||||
{
|
||||
DB::transaction(
|
||||
function () use ($data): void {
|
||||
return DB::transaction(
|
||||
function () use ($data): ConnectedAppData {
|
||||
$connectedApp = ConnectedApp::query()->create(
|
||||
$data->toArray()
|
||||
);
|
||||
|
||||
// Create a permission for this app
|
||||
$this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use);
|
||||
|
||||
return ConnectedAppData::from($connectedApp);
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -95,7 +98,7 @@ public function getConnectedApp(int $id): ConnectedAppData
|
||||
if ($id <= 0) {
|
||||
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);
|
||||
|
||||
return ConnectedAppData::from($data);
|
||||
|
||||
@ -4,90 +4,66 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OauthToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
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
|
||||
{
|
||||
private const PROVIDER = 'keka';
|
||||
|
||||
private string $clientId;
|
||||
|
||||
private string $clientSecret;
|
||||
|
||||
private string $tenantId;
|
||||
|
||||
private string $redirectUri;
|
||||
|
||||
private array $scopes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clientId = (string) config('microsoft.client_id');
|
||||
$this->clientSecret = (string) config('microsoft.client_secret');
|
||||
$this->tenantId = (string) config('microsoft.tenant_id');
|
||||
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
||||
$this->scopes = config('microsoft.keka_scopes', [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'offline_access',
|
||||
'User.Read',
|
||||
'Mail.Read',
|
||||
'Mail.ReadWrite',
|
||||
'Mail.Send',
|
||||
$this->tenantId = (string) config('microsoft.tenant_id');
|
||||
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
||||
$this->scopes = (array) config('microsoft.keka_scopes', [
|
||||
'openid', 'profile', 'email', 'offline_access', 'User.Read',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$state = Str::random(40);
|
||||
session(['keka_ms_oauth_state' => $state]);
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
return 'https://login.microsoftonline.com/' . $this->tenantId . '/oauth2/v2.0/authorize?' . http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'response_mode' => 'query',
|
||||
'scope' => implode(' ', $this->scopes),
|
||||
'state' => $state,
|
||||
// prompt=select_account forces account chooser on first connect.
|
||||
'prompt' => 'select_account',
|
||||
'scope' => implode(' ', $this->scopes),
|
||||
'state' => $state,
|
||||
'prompt' => 'select_account',
|
||||
]);
|
||||
|
||||
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
public function isConnected(User $user): bool
|
||||
{
|
||||
return OauthToken::where('user_id', $user->id)
|
||||
->where('provider', self::PROVIDER)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the authorization code for access + refresh tokens and store them on the user.
|
||||
*
|
||||
* @return array{success: bool, message?: string}
|
||||
*/
|
||||
public function handleCallback(User $user, string $code): array
|
||||
@ -112,114 +88,84 @@ public function handleCallback(User $user, string $code): array
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
$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');
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token for the user.
|
||||
* Silently refreshes via refresh_token if the current access token has expired.
|
||||
* Returns a valid access token, silently refreshing if expired.
|
||||
*
|
||||
* @throws RuntimeException if the user has not connected or refresh fails.
|
||||
* @throws \RuntimeException when not connected or refresh fails (caller must redirect to connect)
|
||||
*/
|
||||
public function getValidToken(User $user): string
|
||||
{
|
||||
if (! $this->isConnected($user)) {
|
||||
throw new RuntimeException('User has not connected Keka/Outlook.');
|
||||
$record = OauthToken::where('user_id', $user->id)
|
||||
->where('provider', self::PROVIDER)
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
throw new \RuntimeException('Not connected.');
|
||||
}
|
||||
|
||||
// Still valid? (1 minute buffer)
|
||||
if (
|
||||
$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;
|
||||
// Valid with 5-minute buffer
|
||||
if ($record->expires_at->gt(now()->addMinutes(5))) {
|
||||
return $record->access_token;
|
||||
}
|
||||
|
||||
return $this->refreshToken($user);
|
||||
}
|
||||
// Expired — silent refresh
|
||||
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(
|
||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||
[
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $this->clientSecret,
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $user->keka_ms_refresh_token,
|
||||
'scope' => implode(' ', $this->scopes),
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $record->refresh_token,
|
||||
'scope' => implode(' ', $this->scopes),
|
||||
]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
// Refresh token expired/revoked -> force user to reconnect.
|
||||
$this->disconnect($user);
|
||||
|
||||
throw new RuntimeException(
|
||||
$response->json('error_description', 'Session expired. Please reconnect Keka.')
|
||||
);
|
||||
$record->delete();
|
||||
throw new \RuntimeException('Session expired. Please reconnect Keka.');
|
||||
}
|
||||
|
||||
$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'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
|
||||
*/
|
||||
public function disconnect(User $user): void
|
||||
{
|
||||
$user->keka_ms_access_token = null;
|
||||
$user->keka_ms_refresh_token = null;
|
||||
$user->keka_ms_token_expires_at = null;
|
||||
$user->keka_ms_email = null;
|
||||
$user->save();
|
||||
OauthToken::where('user_id', $user->id)
|
||||
->where('provider', self::PROVIDER)
|
||||
->delete();
|
||||
}
|
||||
|
||||
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)),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\{Exceptions, Middleware};
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@ -13,8 +14,12 @@
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'keka/callback',
|
||||
$middleware->web(append: [
|
||||
App\Http\Middleware\CaptureOidcNonce::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {})->create();
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
})->create();
|
||||
|
||||
@ -6,4 +6,5 @@
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\DirectiveServiceProvider::class,
|
||||
App\Providers\PassportServiceProvider::class,
|
||||
];
|
||||
|
||||
232
composer.lock
generated
232
composer.lock
generated
@ -277,23 +277,22 @@
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.8",
|
||||
"version": "0.17.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/brick/math.git",
|
||||
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
|
||||
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
|
||||
"reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818",
|
||||
"reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^2.2",
|
||||
"phpstan/phpstan": "2.1.22",
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
@ -325,7 +324,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/brick/math/issues",
|
||||
"source": "https://github.com/brick/math/tree/0.14.8"
|
||||
"source": "https://github.com/brick/math/tree/0.17.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -333,7 +332,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-10T14:33:43+00:00"
|
||||
"time": "2026-05-25T20:34:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "carbonphp/carbon-doctrine-types",
|
||||
@ -1284,22 +1283,22 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.11.1",
|
||||
"version": "7.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
|
||||
"reference": "eaa81598031cf57a9e36258c8546defffc994cba"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||
"reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/eaa81598031cf57a9e36258c8546defffc994cba",
|
||||
"reference": "eaa81598031cf57a9e36258c8546defffc994cba",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/promises": "^2.5",
|
||||
"guzzlehttp/psr7": "^2.11",
|
||||
"guzzlehttp/psr7": "^2.12",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
@ -1392,7 +1391,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.11.1"
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.12.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -1408,7 +1407,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-07T22:54:06+00:00"
|
||||
"time": "2026-06-16T22:11:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
@ -1496,16 +1495,16 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.11.0",
|
||||
"version": "2.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f"
|
||||
"reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f",
|
||||
"reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43",
|
||||
"reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -1595,7 +1594,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.11.0"
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -1611,20 +1610,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-02T12:30:48+00:00"
|
||||
"time": "2026-06-16T21:50:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/uri-template",
|
||||
"version": "v1.0.6",
|
||||
"version": "v1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/uri-template.git",
|
||||
"reference": "eef7f87bab6f204eba3c39224d8075c70c637946"
|
||||
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946",
|
||||
"reference": "eef7f87bab6f204eba3c39224d8075c70c637946",
|
||||
"url": "https://api.github.com/repos/guzzle/uri-template/zipball/7fe811c23a9e3cd712b4389eaeb50b5456d8c529",
|
||||
"reference": "7fe811c23a9e3cd712b4389eaeb50b5456d8c529",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -1681,7 +1680,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/uri-template/issues",
|
||||
"source": "https://github.com/guzzle/uri-template/tree/v1.0.6"
|
||||
"source": "https://github.com/guzzle/uri-template/tree/v1.0.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -1697,7 +1696,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-23T22:00:21+00:00"
|
||||
"time": "2026-06-12T21:33:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jfcherng/php-color-output",
|
||||
@ -2002,16 +2001,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.15.0",
|
||||
"version": "v13.16.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b"
|
||||
"reference": "6135650d69bd9442e470bb1b343422081b076f1e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/7e23b2aa4e1133a43835c93a810b4bedc40e425b",
|
||||
"reference": "7e23b2aa4e1133a43835c93a810b4bedc40e425b",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/6135650d69bd9442e470bb1b343422081b076f1e",
|
||||
"reference": "6135650d69bd9442e470bb1b343422081b076f1e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -2222,7 +2221,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-06-09T13:45:51+00:00"
|
||||
"time": "2026-06-16T16:07:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/passkeys",
|
||||
@ -3552,16 +3551,16 @@
|
||||
},
|
||||
{
|
||||
"name": "mallardduck/blade-lucide-icons",
|
||||
"version": "1.26.21",
|
||||
"version": "1.26.24",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mallardduck/blade-lucide-icons.git",
|
||||
"reference": "495df35fc2678415a2b22b44cc6e1eea75dd99fb"
|
||||
"reference": "25ed71dd92f43e25f524deb06bd482261629293b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/mallardduck/blade-lucide-icons/zipball/495df35fc2678415a2b22b44cc6e1eea75dd99fb",
|
||||
"reference": "495df35fc2678415a2b22b44cc6e1eea75dd99fb",
|
||||
"url": "https://api.github.com/repos/mallardduck/blade-lucide-icons/zipball/25ed71dd92f43e25f524deb06bd482261629293b",
|
||||
"reference": "25ed71dd92f43e25f524deb06bd482261629293b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -3606,9 +3605,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/mallardduck/blade-lucide-icons/issues",
|
||||
"source": "https://github.com/mallardduck/blade-lucide-icons/tree/1.26.21"
|
||||
"source": "https://github.com/mallardduck/blade-lucide-icons/tree/1.26.24"
|
||||
},
|
||||
"time": "2026-05-29T00:45:59+00:00"
|
||||
"time": "2026-06-17T00:50:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
@ -3715,16 +3714,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.11.4",
|
||||
"version": "3.12.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
|
||||
"reference": "6e7853a668c3107294aff38d42bf760ec02126b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
|
||||
"reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6e7853a668c3107294aff38d42bf760ec02126b6",
|
||||
"reference": "6e7853a668c3107294aff38d42bf760ec02126b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -3816,7 +3815,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-07T09:57:54+00:00"
|
||||
"time": "2026-06-14T20:41:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
@ -5505,20 +5504,20 @@
|
||||
},
|
||||
{
|
||||
"name": "ramsey/uuid",
|
||||
"version": "4.9.2",
|
||||
"version": "4.9.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ramsey/uuid.git",
|
||||
"reference": "8429c78ca35a09f27565311b98101e2826affde0"
|
||||
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
|
||||
"reference": "8429c78ca35a09f27565311b98101e2826affde0",
|
||||
"url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
|
||||
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
|
||||
"brick/math": ">=0.8.16 <=0.18",
|
||||
"php": "^8.0",
|
||||
"ramsey/collection": "^1.2 || ^2.0"
|
||||
},
|
||||
@ -5577,9 +5576,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/ramsey/uuid/issues",
|
||||
"source": "https://github.com/ramsey/uuid/tree/4.9.2"
|
||||
"source": "https://github.com/ramsey/uuid/tree/4.9.3"
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "robrichards/xmlseclibs",
|
||||
@ -6036,16 +6035,16 @@
|
||||
},
|
||||
{
|
||||
"name": "spatie/php-structure-discoverer",
|
||||
"version": "2.4.2",
|
||||
"version": "2.4.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/php-structure-discoverer.git",
|
||||
"reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc"
|
||||
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
|
||||
"reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
|
||||
"url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
|
||||
"reference": "fa2b7dae8e8a22c0306154c4b052420e054f7e2b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -6103,7 +6102,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/php-structure-discoverer/issues",
|
||||
"source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2"
|
||||
"source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -6111,7 +6110,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-28T06:26:02+00:00"
|
||||
"time": "2026-06-15T07:14:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/cbor-php",
|
||||
@ -9580,16 +9579,16 @@
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -9640,9 +9639,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/webmozarts/assert/issues",
|
||||
"source": "https://github.com/webmozarts/assert/tree/2.4.0"
|
||||
"source": "https://github.com/webmozarts/assert/tree/2.4.1"
|
||||
},
|
||||
"time": "2026-05-20T13:07:01+00:00"
|
||||
"time": "2026-06-15T15:31:57+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
@ -9881,28 +9880,29 @@
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"version": "3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
"phpstan/phpstan": "<2.2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@ -9940,7 +9940,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -9950,13 +9950,9 @@
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
"time": "2026-06-07T11:47:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/xdebug-handler",
|
||||
@ -10443,16 +10439,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laradumps/laradumps-core",
|
||||
"version": "v4.0.6",
|
||||
"version": "v4.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laradumps/laradumps-core.git",
|
||||
"reference": "41acc4a0dba81232287bed4d98de112b1c466244"
|
||||
"reference": "ffd0811bee6c2dc6e230705b6a0751fbcbf3921f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/41acc4a0dba81232287bed4d98de112b1c466244",
|
||||
"reference": "41acc4a0dba81232287bed4d98de112b1c466244",
|
||||
"url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/ffd0811bee6c2dc6e230705b6a0751fbcbf3921f",
|
||||
"reference": "ffd0811bee6c2dc6e230705b6a0751fbcbf3921f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -10502,7 +10498,7 @@
|
||||
"homepage": "https://github.com/laradumps/laradumps-core",
|
||||
"support": {
|
||||
"issues": "https://github.com/laradumps/laradumps-core/issues",
|
||||
"source": "https://github.com/laradumps/laradumps-core/tree/v4.0.6"
|
||||
"source": "https://github.com/laradumps/laradumps-core/tree/v4.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -10510,7 +10506,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-19T15:05:22+00:00"
|
||||
"time": "2026-06-17T00:41:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "larastan/larastan",
|
||||
@ -10732,16 +10728,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
"version": "v0.8.0",
|
||||
"version": "v0.8.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/mcp.git",
|
||||
"reference": "18221a07093d84153883bc956e5e213999549a4b"
|
||||
"reference": "fdc39c9e21048801508765cb4d72bd9e8501b51f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/18221a07093d84153883bc956e5e213999549a4b",
|
||||
"reference": "18221a07093d84153883bc956e5e213999549a4b",
|
||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/fdc39c9e21048801508765cb4d72bd9e8501b51f",
|
||||
"reference": "fdc39c9e21048801508765cb4d72bd9e8501b51f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -10802,7 +10798,7 @@
|
||||
"issues": "https://github.com/laravel/mcp/issues",
|
||||
"source": "https://github.com/laravel/mcp"
|
||||
},
|
||||
"time": "2026-06-08T13:48:51+00:00"
|
||||
"time": "2026-06-11T13:59:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pail",
|
||||
@ -10886,16 +10882,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/pao",
|
||||
"version": "v1.1.0",
|
||||
"version": "v1.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/pao.git",
|
||||
"reference": "519eecdefb9d5da362876376b52207c8f11b477c"
|
||||
"reference": "8f6c8094d701a03041c85ba583ca233398c792ec"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/pao/zipball/519eecdefb9d5da362876376b52207c8f11b477c",
|
||||
"reference": "519eecdefb9d5da362876376b52207c8f11b477c",
|
||||
"url": "https://api.github.com/repos/laravel/pao/zipball/8f6c8094d701a03041c85ba583ca233398c792ec",
|
||||
"reference": "8f6c8094d701a03041c85ba583ca233398c792ec",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -10914,7 +10910,7 @@
|
||||
"orchestra/testbench": "^10.11.0 || ^11.1.0",
|
||||
"pestphp/pest": "^4.7.2 || ^5.0.0",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0",
|
||||
"phpstan/phpstan": "^2.2.1",
|
||||
"phpstan/phpstan": "^2.2.2",
|
||||
"rector/rector": "^2.4.5",
|
||||
"symfony/process": "^7.4.8 || ^8.1.0",
|
||||
"symfony/var-dumper": "^7.4.8 || ^8.1.0"
|
||||
@ -10967,20 +10963,20 @@
|
||||
"issues": "https://github.com/laravel/pao/issues",
|
||||
"source": "https://github.com/laravel/pao"
|
||||
},
|
||||
"time": "2026-06-01T07:26:51+00:00"
|
||||
"time": "2026-06-12T08:00:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pint",
|
||||
"version": "v1.29.1",
|
||||
"version": "v1.29.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/pint.git",
|
||||
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
|
||||
"reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
|
||||
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
|
||||
"url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14",
|
||||
"reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -10991,14 +10987,14 @@
|
||||
"php": "^8.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.95.1",
|
||||
"illuminate/view": "^12.56.0",
|
||||
"larastan/larastan": "^3.9.6",
|
||||
"friendsofphp/php-cs-fixer": "^3.95.8",
|
||||
"illuminate/view": "^12.62.0",
|
||||
"larastan/larastan": "^3.10.0",
|
||||
"laravel-zero/framework": "^12.1.0",
|
||||
"laravel/agent-detector": "^2.0.2",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest": "^3.8.6",
|
||||
"shipfastlabs/agent-detector": "^1.1.3"
|
||||
"pestphp/pest": "^3.8.6"
|
||||
},
|
||||
"bin": [
|
||||
"builds/pint"
|
||||
@ -11035,7 +11031,7 @@
|
||||
"issues": "https://github.com/laravel/pint/issues",
|
||||
"source": "https://github.com/laravel/pint"
|
||||
},
|
||||
"time": "2026-04-20T15:26:14+00:00"
|
||||
"time": "2026-06-16T15:34:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/roster",
|
||||
@ -11402,16 +11398,16 @@
|
||||
},
|
||||
{
|
||||
"name": "pestphp/pest",
|
||||
"version": "v4.7.2",
|
||||
"version": "v4.7.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pestphp/pest.git",
|
||||
"reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b"
|
||||
"reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/pestphp/pest/zipball/40b88b62ef8a7c6fcae5fc28f1fa747f601c131b",
|
||||
"reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b",
|
||||
"url": "https://api.github.com/repos/pestphp/pest/zipball/87882a8561bf3ddf230b9a6b764f367f687d5b2f",
|
||||
"reference": "87882a8561bf3ddf230b9a6b764f367f687d5b2f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -11424,12 +11420,12 @@
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"php": "^8.3.0",
|
||||
"phpunit/phpunit": "^12.5.28",
|
||||
"phpunit/phpunit": "^12.5.29",
|
||||
"symfony/process": "^7.4.13|^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.28",
|
||||
"phpunit/phpunit": ">12.5.29",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@ -11505,7 +11501,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/pestphp/pest/issues",
|
||||
"source": "https://github.com/pestphp/pest/tree/v4.7.2"
|
||||
"source": "https://github.com/pestphp/pest/tree/v4.7.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -11517,7 +11513,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-01T06:08:59+00:00"
|
||||
"time": "2026-06-12T05:57:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pestphp/pest-plugin",
|
||||
@ -12394,16 +12390,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.5.28",
|
||||
"version": "12.5.29",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4"
|
||||
"reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4",
|
||||
"reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a",
|
||||
"reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@ -12417,7 +12413,7 @@
|
||||
"phar-io/manifest": "^2.0.4",
|
||||
"phar-io/version": "^3.2.1",
|
||||
"php": ">=8.3",
|
||||
"phpunit/php-code-coverage": "^12.5.6",
|
||||
"phpunit/php-code-coverage": "^12.5.7",
|
||||
"phpunit/php-file-iterator": "^6.0.1",
|
||||
"phpunit/php-invoker": "^6.0.0",
|
||||
"phpunit/php-text-template": "^5.0.0",
|
||||
@ -12427,7 +12423,7 @@
|
||||
"sebastian/diff": "^7.0.0",
|
||||
"sebastian/environment": "^8.1.2",
|
||||
"sebastian/exporter": "^7.0.3",
|
||||
"sebastian/global-state": "^8.0.2",
|
||||
"sebastian/global-state": "^8.0.3",
|
||||
"sebastian/object-enumerator": "^7.0.0",
|
||||
"sebastian/recursion-context": "^7.0.1",
|
||||
"sebastian/type": "^6.0.4",
|
||||
@ -12472,7 +12468,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@ -12480,7 +12476,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-27T14:01:10+00:00"
|
||||
"time": "2026-06-04T06:14:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
@ -7,22 +7,13 @@
|
||||
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
|
||||
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
|
||||
|
||||
// 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_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
|
||||
'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
|
||||
|
||||
'keka_scopes' => [
|
||||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'offline_access',
|
||||
'openid', 'profile', 'email',
|
||||
'offline_access', // ← required for refresh_token
|
||||
'User.Read',
|
||||
'Mail.Read',
|
||||
'Mail.ReadWrite',
|
||||
'Mail.Send',
|
||||
'Mail.Read', 'Mail.ReadWrite', 'Mail.Send',
|
||||
],
|
||||
];
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -22,6 +22,5 @@ public function run(): void
|
||||
$this->call(DefaultRoleWithPermissionSeeder::class);
|
||||
$this->call(ExampleUserWithRoleSeeder::class);
|
||||
$this->call(TicketStatusSeeder::class);
|
||||
$this->call(MockDataSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/images/app-icon-placeholder.png
Normal file
BIN
public/images/app-icon-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@ -10,7 +10,7 @@
|
||||
<div>
|
||||
<x-shared.card title="Connected Apps" icon="lucide-grid-2x2-plus" class="p-0">
|
||||
<x-slot:actions>
|
||||
<x-shared.button :link="route('apps.create')">
|
||||
<x-shared.button>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<x-lucide-plus class="w-5 h-5"/>
|
||||
{{ __('Connect App') }}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
{{--
|
||||
Read /App/Livewire/Concerns/Choices/README.md first.
|
||||
This will give you a resuable idea of using this component
|
||||
--}}
|
||||
@props([
|
||||
'label' => 'Select Items',
|
||||
'options', // The data collection passed from the parent
|
||||
|
||||
48
resources/views/components/shared/repeater-input.blade.php
Normal file
48
resources/views/components/shared/repeater-input.blade.php
Normal file
@ -0,0 +1,48 @@
|
||||
@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>
|
||||
264
resources/views/pages/apps/oidc/⚡create.blade.php
Normal file
264
resources/views/pages/apps/oidc/⚡create.blade.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?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>
|
||||
@ -4,6 +4,9 @@
|
||||
use App\Concerns\HandlesOperations;
|
||||
use App\Data\Ui\ConnectedApps\ConnectedAppData;
|
||||
use App\Data\Ui\TableHeader;
|
||||
use App\Enums\ApplicationTypeEnum;
|
||||
use App\Enums\ConnectionProtocolEnum;
|
||||
use App\Livewire\Forms\CreateAppSelectionForm;
|
||||
use App\Services\ConnectedAppService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
@ -19,6 +22,8 @@ class extends Component {
|
||||
use WithPagination, HandlesOperations, Confirmation;
|
||||
|
||||
protected ConnectedAppService $service;
|
||||
public bool $showAppCreationModal = false;
|
||||
public CreateAppSelectionForm $appSelectionForm;
|
||||
|
||||
#[DataCollectionOf(TableHeader::class)]
|
||||
public DataCollection $headers;
|
||||
@ -64,13 +69,22 @@ public function delete(int $appId): void
|
||||
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>
|
||||
<x-shared.card title="Connected Apps" class="pb-2">
|
||||
<x-slot:actions>
|
||||
<x-mary-button :link="route('apps.create')" wire:navigate icon="lucide.plus">Add App
|
||||
<x-mary-button @click="$wire.showAppCreationModal = true" icon="lucide.plus">Add App
|
||||
</x-mary-button>
|
||||
</x-slot:actions>
|
||||
<x-mary-table
|
||||
@ -85,6 +99,7 @@ public function delete(int $appId): void
|
||||
@scope('cell_status', $row)
|
||||
<x-shared.connection-status :status="$row->status"/>
|
||||
@endscope
|
||||
|
||||
@scope('actions', $row)
|
||||
<div class="flex">
|
||||
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})"
|
||||
@ -97,4 +112,44 @@ class="btn-sm btn-circle btn-error btn-soft"/>
|
||||
@endscope
|
||||
</x-mary-table>
|
||||
</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>
|
||||
@ -1,209 +0,0 @@
|
||||
<?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>
|
||||
38
routes/apps.php
Normal file
38
routes/apps.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?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');
|
||||
});
|
||||
});
|
||||
@ -3,11 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\DefaultUserRole;
|
||||
use App\Enums\Permissions\{AppPermissionEnum,
|
||||
ConnectionProviderPermissionEnum,
|
||||
PermissionPermissionEnum,
|
||||
RoleAndAppsPermissionEnum,
|
||||
UserPermissionEnum};
|
||||
use App\Enums\Permissions\{PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum};
|
||||
use App\Http\Controllers\{EntraController, KekaController};
|
||||
use App\Http\Controllers\KekaOutlookController;
|
||||
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
||||
@ -62,25 +58,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
require __DIR__.'/apps.php';
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('entra')->name('entra.')->group(function (): void {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Middleware\CaptureOidcNonce;
|
||||
use App\OAuth\{CustomAuthCodeRepository, CustomTokenResponseType};
|
||||
use Defuse\Crypto\Crypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -63,3 +64,43 @@
|
||||
// 4. Assert the resolved nonce is correct
|
||||
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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user