Compare commits
9 Commits
19c2892569
...
ee8c320431
| Author | SHA1 | Date | |
|---|---|---|---|
| ee8c320431 | |||
|
|
335288c8b5 | ||
|
|
9c9e3ca43c | ||
|
|
6a400fc532 | ||
|
|
83e56e35e0 | ||
|
|
1ef7957a05 | ||
|
|
feaa8b6c93 | ||
|
|
7f02209631 | ||
|
|
882538130b |
@ -14,6 +14,7 @@ public function __construct(
|
|||||||
public ApplicationTypeEnum $type,
|
public ApplicationTypeEnum $type,
|
||||||
public string $name,
|
public string $name,
|
||||||
public UserAccessTypeEnum $userAccessType,
|
public UserAccessTypeEnum $userAccessType,
|
||||||
|
public string $accessUrl,
|
||||||
/** @var array<string> */
|
/** @var array<string> */
|
||||||
public array $signInUris = [],
|
public array $signInUris = [],
|
||||||
/** @var array<string> */
|
/** @var array<string> */
|
||||||
@ -29,6 +30,7 @@ public static function fromArray(ApplicationTypeEnum $applicationType, array $va
|
|||||||
type: $applicationType,
|
type: $applicationType,
|
||||||
name: $value['name'],
|
name: $value['name'],
|
||||||
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
|
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
|
||||||
|
accessUrl: $value['accessUrl'],
|
||||||
signInUris: $value['signInUris'] ?? [],
|
signInUris: $value['signInUris'] ?? [],
|
||||||
signOutUris: $value['signOutUris'] ?? [],
|
signOutUris: $value['signOutUris'] ?? [],
|
||||||
logo: $value['logo'] ?? null,
|
logo: $value['logo'] ?? null,
|
||||||
|
|||||||
34
app/Data/Application/StoreURLAppData.php
Normal file
34
app/Data/Application/StoreURLAppData.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Data\Application;
|
||||||
|
|
||||||
|
use App\Enums\UserAccessTypeEnum;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
use Spatie\LaravelData\Data;
|
||||||
|
|
||||||
|
class StoreURLAppData extends Data
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public UserAccessTypeEnum $userAccessType,
|
||||||
|
public string $accessUrl,
|
||||||
|
public bool $isConnectedToMicrosoft = false,
|
||||||
|
public ?TemporaryUploadedFile $logo = null,
|
||||||
|
/** @var array<int> */
|
||||||
|
public array $roleIds = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromArray(array $value): StoreURLAppData
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
name: $value['name'],
|
||||||
|
userAccessType: UserAccessTypeEnum::from($value['userAccessOption']),
|
||||||
|
accessUrl: $value['accessUrl'],
|
||||||
|
isConnectedToMicrosoft: (bool) ($value['isConnectedToMicrosoft'] ?? false),
|
||||||
|
logo: $value['logo'] ?? null,
|
||||||
|
roleIds: $value['roleIds'] ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,9 +13,12 @@ public function __construct(
|
|||||||
public string $name,
|
public string $name,
|
||||||
public string $slug,
|
public string $slug,
|
||||||
public string $duration,
|
public string $duration,
|
||||||
public int $days_remaining,
|
public bool $isUnlimited,
|
||||||
public bool $is_warning,
|
public int $daysRemaining,
|
||||||
public ?string $protocol_name = null,
|
public bool $isWarning,
|
||||||
public ?string $provider_slug = null,
|
public ?string $protocolName = null,
|
||||||
|
public ?string $providerSlug = null,
|
||||||
|
public ?string $accessUrl = null,
|
||||||
|
public bool $isConnectedToMicrosoft = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ enum ApplicationTypeEnum: string implements EnumMorphsToOptionsContract
|
|||||||
{
|
{
|
||||||
case Web = 'web';
|
case Web = 'web';
|
||||||
case SPA = 'spa';
|
case SPA = 'spa';
|
||||||
case Machine = 'machine';
|
// case Machine = 'machine';
|
||||||
|
|
||||||
public static function asOptions(): array
|
public static function asOptions(): array
|
||||||
{
|
{
|
||||||
@ -26,7 +26,7 @@ public function toLabel(): string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Web => 'Web Applications',
|
self::Web => 'Web Applications',
|
||||||
self::SPA => 'Single Page Applications',
|
self::SPA => 'Single Page Applications',
|
||||||
self::Machine => 'Microservices',
|
// self::Machine => 'Microservices',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ public function toHint(): string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Web => 'Server-side rendered web application, where authentication is handled by the server.',
|
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::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.',
|
// self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract
|
|||||||
use EnumValuesAsArray;
|
use EnumValuesAsArray;
|
||||||
|
|
||||||
case OIDC = 'oidc';
|
case OIDC = 'oidc';
|
||||||
case SAML = 'saml';
|
// case SAML = 'saml';
|
||||||
case URL = 'url';
|
case URL = 'url';
|
||||||
|
|
||||||
public static function asOptions(): array
|
public static function asOptions(): array
|
||||||
@ -28,7 +28,7 @@ public function toLabel(): string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::OIDC => 'OIDC - OpenID Connect',
|
self::OIDC => 'OIDC - OpenID Connect',
|
||||||
self::SAML => 'SAML 2.0',
|
// self::SAML => 'SAML 2.0',
|
||||||
self::URL => 'Redirect URL',
|
self::URL => 'Redirect URL',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -37,8 +37,8 @@ public function toHint(): string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
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::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::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.',
|
self::URL => 'Simple URL that will redirect the user to the authentication page. E.g. Keka, Outlook, etc.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,21 +4,27 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\OauthToken;
|
use App\Models\{OauthToken, User};
|
||||||
use App\Services\UserAppAccessService;
|
use App\Services\UserAppAccessService;
|
||||||
|
use Illuminate\Container\Attributes\CurrentUser;
|
||||||
use Illuminate\Http\{RedirectResponse, Request};
|
use Illuminate\Http\{RedirectResponse, Request};
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class EntraController extends Controller
|
class EntraController extends Controller
|
||||||
{
|
{
|
||||||
private const PROVIDER = 'microsoft';
|
// ── OAuth config from .env ────────────────────────────────────────────────
|
||||||
|
|
||||||
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
private const string PROVIDER = 'microsoft';
|
||||||
|
|
||||||
|
private const string SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
||||||
|
|
||||||
private string $tenantId;
|
private string $tenantId;
|
||||||
|
|
||||||
private string $clientId;
|
private string $clientId;
|
||||||
|
|
||||||
private string $clientSecret;
|
private string $clientSecret;
|
||||||
|
|
||||||
private string $redirectUri;
|
private string $redirectUri;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@ -31,7 +37,7 @@ public function __construct()
|
|||||||
|
|
||||||
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
||||||
|
|
||||||
public function connect(): RedirectResponse
|
public function connect(#[CurrentUser] User $user): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -39,27 +45,6 @@ public function connect(): RedirectResponse
|
|||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
$state = Str::random(40);
|
||||||
session(['oauth_state' => $state]);
|
session(['oauth_state' => $state]);
|
||||||
|
|
||||||
@ -73,7 +58,7 @@ public function connect(): RedirectResponse
|
|||||||
'prompt' => 'select_account',
|
'prompt' => 'select_account',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}");
|
return redirect("https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/authorize?$query");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
|
// ── Step 2: Handle callback, exchange code for tokens ────────────────────
|
||||||
@ -86,7 +71,10 @@ public function callback(Request $request): RedirectResponse
|
|||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasAzureAccess($user)) {
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
|
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
|
||||||
|
|
||||||
|
if (! $hasAzureFdAccess) {
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +99,7 @@ public function callback(Request $request): RedirectResponse
|
|||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
$response = Http::asForm()->post(
|
$response = Http::asForm()->post(
|
||||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token",
|
||||||
[
|
[
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'client_secret' => $this->clientSecret,
|
'client_secret' => $this->clientSecret,
|
||||||
@ -157,7 +145,10 @@ public function openEntra(): RedirectResponse
|
|||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasAzureAccess($user)) {
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
|
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
|
||||||
|
|
||||||
|
if (! $hasAzureFdAccess) {
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
abort(403, 'You do not have permission to access the required application.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,26 +176,6 @@ public function openEntra(): RedirectResponse
|
|||||||
|
|
||||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function disconnect(): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
abort(403, 'Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
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.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal: silent refresh ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
private function refreshToken(OauthToken $token): bool
|
private function refreshToken(OauthToken $token): bool
|
||||||
{
|
{
|
||||||
if (! $token->refresh_token) {
|
if (! $token->refresh_token) {
|
||||||
@ -212,7 +183,7 @@ private function refreshToken(OauthToken $token): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$response = Http::asForm()->post(
|
$response = Http::asForm()->post(
|
||||||
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
"https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token",
|
||||||
[
|
[
|
||||||
'client_id' => $this->clientId,
|
'client_id' => $this->clientId,
|
||||||
'client_secret' => $this->clientSecret,
|
'client_secret' => $this->clientSecret,
|
||||||
@ -237,12 +208,24 @@ private function refreshToken(OauthToken $token): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal: permission check (DRY) ─────────────────────────────────────
|
// ── Internal: Refresh access token using refresh_token ───────────────────
|
||||||
|
|
||||||
private function hasAzureAccess(mixed $user): bool
|
public function disconnect(): RedirectResponse
|
||||||
{
|
{
|
||||||
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
$user = auth()->user();
|
||||||
|
if (! $user) {
|
||||||
|
abort(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
|
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
|
||||||
|
|
||||||
|
if (! $hasAzureFdAccess) {
|
||||||
|
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5,8 +5,7 @@
|
|||||||
namespace App\Livewire\Forms;
|
namespace App\Livewire\Forms;
|
||||||
|
|
||||||
use App\Data\ConnectedApp\ConnectedAppData;
|
use App\Data\ConnectedApp\ConnectedAppData;
|
||||||
use App\Enums\ConnectionProtocolEnum;
|
use App\Models\{ConnectionProtocol, ConnectionProvider};
|
||||||
use App\Models\ConnectionProtocol;
|
|
||||||
use App\Services\ConnectedAppService;
|
use App\Services\ConnectedAppService;
|
||||||
use Livewire\Form;
|
use Livewire\Form;
|
||||||
|
|
||||||
@ -56,18 +55,18 @@ public function rules(): array
|
|||||||
$protocol = ConnectionProtocol::query()->find($this->protocolId);
|
$protocol = ConnectionProtocol::query()->find($this->protocolId);
|
||||||
if ($protocol) {
|
if ($protocol) {
|
||||||
$protocolName = mb_strtolower($protocol->name);
|
$protocolName = mb_strtolower($protocol->name);
|
||||||
if (ConnectionProtocolEnum::SAML->value === $protocolName) {
|
// if (ConnectionProtocolEnum::SAML->value === $protocolName) {
|
||||||
$rules['samlEntityId'] = 'required|string|min:3';
|
// $rules['samlEntityId'] = 'required|string|min:3';
|
||||||
$rules['samlAcsUrl'] = 'required|url';
|
// $rules['samlAcsUrl'] = 'required|url';
|
||||||
$rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
|
// $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified';
|
||||||
$rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
|
// $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id';
|
||||||
$rules['samlAttributes'] = 'array';
|
// $rules['samlAttributes'] = 'array';
|
||||||
$rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
|
// $rules['samlAttributes.*.saml_name'] = 'required|string|min:1';
|
||||||
$rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id';
|
// $rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id';
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
$provider = \App\Models\ConnectionProvider::query()->find($this->providerId);
|
$provider = ConnectionProvider::query()->find($this->providerId);
|
||||||
if ($provider && 'keka-hr' === $provider->slug) {
|
if ($provider && 'keka-hr' === $provider->slug) {
|
||||||
$rules['kekaUrl'] = 'required|url';
|
$rules['kekaUrl'] = 'required|url';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,9 @@ class StoreOIDCAppForm extends Form
|
|||||||
#[Validate('required|string|max:255|min:3')]
|
#[Validate('required|string|max:255|min:3')]
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('required|url|max:255')]
|
||||||
|
public string $accessUrl = '';
|
||||||
|
|
||||||
#[Validate('nullable|image|max:1024')]
|
#[Validate('nullable|image|max:1024')]
|
||||||
public ?TemporaryUploadedFile $logo = null;
|
public ?TemporaryUploadedFile $logo = null;
|
||||||
|
|
||||||
@ -46,6 +49,7 @@ class StoreOIDCAppForm extends Form
|
|||||||
public function validationAttributes(): array
|
public function validationAttributes(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'accessUrl' => 'Access URL',
|
||||||
'signInUris.*' => 'Sign-in URI',
|
'signInUris.*' => 'Sign-in URI',
|
||||||
'signOutUris.*' => 'Sign-out URI',
|
'signOutUris.*' => 'Sign-out URI',
|
||||||
'roleIds.*' => 'Role',
|
'roleIds.*' => 'Role',
|
||||||
|
|||||||
45
app/Livewire/Forms/StoreURLAppForm.php
Normal file
45
app/Livewire/Forms/StoreURLAppForm.php
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<?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 StoreURLAppForm extends Form
|
||||||
|
{
|
||||||
|
#[Validate('required|string|max:255|min:3')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('required|url|max:255')]
|
||||||
|
public string $accessUrl = '';
|
||||||
|
|
||||||
|
#[Validate('boolean')]
|
||||||
|
public bool $isConnectedToMicrosoft = false;
|
||||||
|
|
||||||
|
#[Validate('nullable|image|max:1024')]
|
||||||
|
public ?TemporaryUploadedFile $logo = null;
|
||||||
|
|
||||||
|
#[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 [
|
||||||
|
'accessUrl' => 'Access URL',
|
||||||
|
'roleIds.*' => 'Role',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
* @property int $app_id
|
* @property int $app_id
|
||||||
* @property int $role_id
|
* @property int $role_id
|
||||||
* @property string $duration Validity of the role
|
* @property string $duration Validity of the role
|
||||||
|
* @property bool $is_unlimited Whether validity of the role is unlimited
|
||||||
*/
|
*/
|
||||||
#[Fillable(['app_id', 'role_id', 'duration', 'is_unlimited'])]
|
#[Fillable(['app_id', 'role_id', 'duration', 'is_unlimited'])]
|
||||||
class AppRole extends Pivot
|
class AppRole extends Pivot
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property AppRole $pivot
|
* @property AppRole $pivot
|
||||||
|
* @property array $settings
|
||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'name',
|
'name',
|
||||||
@ -23,23 +24,12 @@
|
|||||||
'connection_provider_id',
|
'connection_provider_id',
|
||||||
'connection_status_id',
|
'connection_status_id',
|
||||||
'settings',
|
'settings',
|
||||||
|
'logo',
|
||||||
])]
|
])]
|
||||||
class ConnectedApp extends Model
|
class ConnectedApp extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, LogsActivity, SoftDeletes;
|
use HasFactory, LogsActivity, SoftDeletes;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'settings' => 'array',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasOne<ConnectionProtocol, $this>
|
* @return HasOne<ConnectionProtocol, $this>
|
||||||
*/
|
*/
|
||||||
@ -87,4 +77,16 @@ public function roles(): BelongsToMany
|
|||||||
->using(AppRole::class)
|
->using(AppRole::class)
|
||||||
->withPivot('duration');
|
->withPivot('duration');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,7 +32,7 @@ public function apps(): BelongsToMany
|
|||||||
'app_id'
|
'app_id'
|
||||||
)
|
)
|
||||||
->using(AppRole::class)
|
->using(AppRole::class)
|
||||||
->withPivot('id', 'duration');
|
->withPivot('id', 'duration', 'is_unlimited');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActivitylogOptions(): LogOptions
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
|||||||
@ -23,7 +23,8 @@ public function create(
|
|||||||
): ClientCredentialsData {
|
): ClientCredentialsData {
|
||||||
$client = $this->client->createAuthorizationCodeGrantClient(
|
$client = $this->client->createAuthorizationCodeGrantClient(
|
||||||
name: $data->name,
|
name: $data->name,
|
||||||
redirectUris: $data->signInUris,
|
// laravel-oidc-server expects redirect_uris to consist of sign_in and sign_out uris.
|
||||||
|
redirectUris: array_merge($data->signInUris, $data->signOutUris),
|
||||||
confidential: ApplicationTypeEnum::Web === $data->type,
|
confidential: ApplicationTypeEnum::Web === $data->type,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
class ClientCreationFactory
|
class ClientCreationFactory
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClientCredentialsClientFactory $clientCredentialsClient,
|
// private readonly ClientCredentialsClientFactory $clientCredentialsClient,
|
||||||
private readonly AuthorizationCodeClientFactory $authorizationCodeClient,
|
private readonly AuthorizationCodeClientFactory $authorizationCodeClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ public function for(ApplicationTypeEnum $applicationType): ClientCreationContrac
|
|||||||
{
|
{
|
||||||
return match ($applicationType) {
|
return match ($applicationType) {
|
||||||
ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient,
|
ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient,
|
||||||
ApplicationTypeEnum::Machine => $this->clientCredentialsClient,
|
// ApplicationTypeEnum::Machine => $this->clientCredentialsClient,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
namespace App\Services\Applications\OIDC;
|
namespace App\Services\Applications\OIDC;
|
||||||
|
|
||||||
use App\Enums\ApplicationTypeEnum;
|
use App\Enums\ApplicationTypeEnum;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
|
|
||||||
class OidcConfigResolver
|
class OidcConfigResolver
|
||||||
{
|
{
|
||||||
@ -21,4 +22,26 @@ public function hasClientSecret(): bool
|
|||||||
{
|
{
|
||||||
return ApplicationTypeEnum::SPA !== $this->applicationType;
|
return ApplicationTypeEnum::SPA !== $this->applicationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function needSignInUris(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
// return ApplicationTypeEnum::Machine !== $this->applicationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function needSignOutUris(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
// return ApplicationTypeEnum::Machine !== $this->applicationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConfidential(Client $client): bool
|
||||||
|
{
|
||||||
|
return $client->confidential();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresPkce(Client $client): bool
|
||||||
|
{
|
||||||
|
return ! $client->confidential();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,12 @@
|
|||||||
use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData};
|
use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData};
|
||||||
use App\Data\ConnectedApp\ConnectAppRequest;
|
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||||
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
|
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
|
||||||
use App\Models\{ConnectionProtocol, ConnectionStatus};
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus};
|
||||||
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
|
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
|
||||||
use App\Services\Applications\OIDC\Factory\ClientCreationFactory;
|
use App\Services\Applications\OIDC\Factory\ClientCreationFactory;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\{DB, Log};
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
readonly class OidcService
|
readonly class OidcService
|
||||||
@ -42,6 +42,10 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData
|
|||||||
connectionProviderId: 0,
|
connectionProviderId: 0,
|
||||||
slug: Str::slug($data->name),
|
slug: Str::slug($data->name),
|
||||||
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
|
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
|
||||||
|
settings: [
|
||||||
|
'access_url' => $data->accessUrl,
|
||||||
|
'sign_out_uris' => $data->signOutUris,
|
||||||
|
],
|
||||||
logo: $logo,
|
logo: $logo,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -72,4 +76,34 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPassportClient(ConnectedApp $app): ?Client
|
||||||
|
{
|
||||||
|
return Client::where('name', $app->name)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateNewSecret(Client $client): string
|
||||||
|
{
|
||||||
|
$plainSecret = Str::random(40);
|
||||||
|
$client->forceFill(['secret' => bcrypt($plainSecret)])->save();
|
||||||
|
|
||||||
|
return $plainSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSignOutUris(ConnectedApp $app): array
|
||||||
|
{
|
||||||
|
return data_get($app->settings, 'signOutUris', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRedirectUris(ConnectedApp $app, Client $client, array $signInUris, array $signOutUris): void
|
||||||
|
{
|
||||||
|
$mergedUris = array_merge($signInUris, $signOutUris);
|
||||||
|
/** @phpstan-ignore-next-line */
|
||||||
|
$client->redirect_uris = $mergedUris;
|
||||||
|
$client->save();
|
||||||
|
$settings = $app->settings ?? [];
|
||||||
|
$settings['signOutUris'] = $signOutUris;
|
||||||
|
$app->settings = $settings;
|
||||||
|
$app->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
66
app/Services/Applications/URL/UrlAppService.php
Normal file
66
app/Services/Applications/URL/UrlAppService.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Applications\URL;
|
||||||
|
|
||||||
|
use App\Data\Application\StoreURLAppData;
|
||||||
|
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
|
||||||
|
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
|
||||||
|
use App\Models\{ConnectionProtocol, ConnectionStatus};
|
||||||
|
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
|
||||||
|
use Illuminate\Support\Facades\{DB, Log};
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
readonly class UrlAppService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ApplicationLogoUploader $logoUploader,
|
||||||
|
private ConnectedAppService $connectedAppService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
public function createApp(StoreURLAppData $data): ConnectedAppData
|
||||||
|
{
|
||||||
|
Log::info('Creating URL app', [
|
||||||
|
'name' => $data->name,
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
DB::beginTransaction();
|
||||||
|
$logo = $this->logoUploader->store($data->logo);
|
||||||
|
|
||||||
|
$appData = $this->connectedAppService->create(
|
||||||
|
new ConnectAppRequest(
|
||||||
|
name: $data->name,
|
||||||
|
connectionProtocolId: ConnectionProtocol::query()->where('name', ConnectionProtocolEnum::URL->value)->first()->id,
|
||||||
|
connectionProviderId: 0,
|
||||||
|
slug: Str::slug($data->name),
|
||||||
|
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
|
||||||
|
settings: [
|
||||||
|
'access_url' => $data->accessUrl,
|
||||||
|
'is_connected_to_microsoft' => $data->isConnectedToMicrosoft,
|
||||||
|
],
|
||||||
|
logo: $logo,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
Log::info('URL app created', [
|
||||||
|
'name' => $data->name,
|
||||||
|
'id' => $appData->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $appData;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
Log::error('Failed to create URL app', [
|
||||||
|
'name' => $data->name,
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@ public function authorize(): User
|
|||||||
|
|
||||||
$activeServices = $this->userAppAccessService->getActiveServices($user);
|
$activeServices = $this->userAppAccessService->getActiveServices($user);
|
||||||
$hasAccess = $activeServices->contains(
|
$hasAccess = $activeServices->contains(
|
||||||
fn ($service) => self::PROVIDER_SLUG === $service->provider_slug
|
fn ($service) => self::PROVIDER_SLUG === $service->providerSlug
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $hasAccess) {
|
if (! $hasAccess) {
|
||||||
|
|||||||
@ -51,7 +51,7 @@ public function getActiveServices(User $user): Collection
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->addOrUpdateAppInMap($appsMap, $app, $durationStr, $daysRemaining);
|
$this->addOrUpdateAppInMap($appsMap, $app, $pivot->is_unlimited, $durationStr, $daysRemaining);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ private function getDaysRemainingIfActive(string $durationStr): ?int
|
|||||||
*
|
*
|
||||||
* * @param Collection<int, ServiceAppDto> $appsMap
|
* * @param Collection<int, ServiceAppDto> $appsMap
|
||||||
*/
|
*/
|
||||||
private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, string $durationStr, int $daysRemaining): void
|
private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, bool $isUnlimited, string $durationStr, int $daysRemaining): void
|
||||||
{
|
{
|
||||||
/** @var ServiceAppDto|null $existing */
|
/** @var ServiceAppDto|null $existing */
|
||||||
$existing = $appsMap->get($app->id);
|
$existing = $appsMap->get($app->id);
|
||||||
@ -132,10 +132,13 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str
|
|||||||
name: $app->name,
|
name: $app->name,
|
||||||
slug: $app->slug,
|
slug: $app->slug,
|
||||||
duration: $durationStr,
|
duration: $durationStr,
|
||||||
days_remaining: $daysRemaining,
|
isUnlimited: $isUnlimited,
|
||||||
is_warning: $daysRemaining <= 3,
|
daysRemaining: $daysRemaining,
|
||||||
protocol_name: $protocolName,
|
isWarning: $daysRemaining <= 3,
|
||||||
provider_slug: $providerSlug,
|
protocolName: $protocolName,
|
||||||
|
providerSlug: $providerSlug,
|
||||||
|
accessUrl: data_get($app, 'settings.access_url'),
|
||||||
|
isConnectedToMicrosoft: data_get($app, 'settings.is_connected_to_microsoft', false),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
RoleAndAppsPermissionEnum,
|
RoleAndAppsPermissionEnum,
|
||||||
SecurityPermissionEnum,
|
SecurityPermissionEnum,
|
||||||
UserPermissionEnum,
|
UserPermissionEnum,
|
||||||
PermissionPermissionEnum};
|
PermissionPermissionEnum
|
||||||
|
};
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\LaravelData\DataCollection;
|
use Spatie\LaravelData\DataCollection;
|
||||||
|
|
||||||
@ -51,18 +52,18 @@ public function navItems(): DataCollection
|
|||||||
active_pattern: 'apps.index',
|
active_pattern: 'apps.index',
|
||||||
permission: AppPermissionEnum::Access
|
permission: AppPermissionEnum::Access
|
||||||
),
|
),
|
||||||
new NavSubItemData(
|
// new NavSubItemData(
|
||||||
label: 'Providers',
|
// label: 'Providers',
|
||||||
route: 'apps.connectionProviders.index',
|
// route: 'apps.connectionProviders.index',
|
||||||
active_pattern: 'apps.connectionProviders.*',
|
// active_pattern: 'apps.connectionProviders.*',
|
||||||
permission: ConnectionProviderPermissionEnum::Access
|
// permission: ConnectionProviderPermissionEnum::Access
|
||||||
),
|
// ),
|
||||||
new NavSubItemData(
|
// new NavSubItemData(
|
||||||
label: 'Microsoft Graph',
|
// label: 'Microsoft Graph',
|
||||||
route: 'apps.microsoft-federation',
|
// route: 'apps.microsoft-federation',
|
||||||
active_pattern: 'apps.microsoft-federation',
|
// active_pattern: 'apps.microsoft-federation',
|
||||||
permission: ConnectionProviderPermissionEnum::Access
|
// permission: ConnectionProviderPermissionEnum::Access
|
||||||
)
|
// )
|
||||||
], DataCollection::class)
|
], DataCollection::class)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Concerns\HandlesOperations;
|
use App\Concerns\HandlesOperations;
|
||||||
|
use App\Data\Application\CreatedApplicationData;
|
||||||
use App\Data\Application\StoreOIDCAppData;
|
use App\Data\Application\StoreOIDCAppData;
|
||||||
use App\Data\ConnectedApp\ConnectedAppData;
|
use App\Data\ConnectedApp\ConnectedAppData;
|
||||||
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, UserAccessTypeEnum};
|
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, Permissions\AppPermissionEnum, UserAccessTypeEnum};
|
||||||
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
|
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
|
||||||
use App\Livewire\Concerns\WithRepeaterFields;
|
use App\Livewire\Concerns\WithRepeaterFields;
|
||||||
use App\Livewire\Forms\StoreOIDCAppForm;
|
use App\Livewire\Forms\StoreOIDCAppForm;
|
||||||
@ -57,17 +58,14 @@ public function render()
|
|||||||
|
|
||||||
public function save(bool $goBack = true): void
|
public function save(bool $goBack = true): void
|
||||||
{
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Create->value);
|
||||||
$this->form->validate();
|
$this->form->validate();
|
||||||
$this->attempt(
|
$this->attempt(
|
||||||
function () {
|
function () {
|
||||||
$data = StoreOIDCAppData::fromArray($this->applicationType, $this->form->pull());
|
$data = StoreOIDCAppData::fromArray($this->applicationType, $this->form->pull());
|
||||||
$appdata = $this->oidcService->createApp($data);
|
$appdata = $this->oidcService->createApp($data);
|
||||||
|
|
||||||
// Show the credentials
|
$this->showCredentials($appdata);
|
||||||
$this->created = true;
|
|
||||||
$this->clientId = $appdata->clientCredentials->clientId;
|
|
||||||
$this->clientSecret = $appdata->clientCredentials->clientSecret;
|
|
||||||
$this->app = $appdata->application;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -84,7 +82,7 @@ public function title(): string
|
|||||||
$title .= match ($this->applicationType) {
|
$title .= match ($this->applicationType) {
|
||||||
ApplicationTypeEnum::Web => "Web Application ",
|
ApplicationTypeEnum::Web => "Web Application ",
|
||||||
ApplicationTypeEnum::SPA => "Single Page Application ",
|
ApplicationTypeEnum::SPA => "Single Page Application ",
|
||||||
ApplicationTypeEnum::Machine => "Microservice Application ",
|
// ApplicationTypeEnum::Machine => "Microservice Application ",
|
||||||
};
|
};
|
||||||
$title .= "Integration";
|
$title .= "Integration";
|
||||||
return $title;
|
return $title;
|
||||||
@ -105,6 +103,24 @@ public function clearRoles(): void
|
|||||||
$this->clearChoice('roles');
|
$this->clearChoice('roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function hasClientSecret(): bool
|
||||||
|
{
|
||||||
|
return $this->configResolver->for($this->applicationType)->hasClientSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function needSignInUris(): bool
|
||||||
|
{
|
||||||
|
return $this->configResolver->for($this->applicationType)->needSignInUris();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function needSignOutUris(): bool
|
||||||
|
{
|
||||||
|
return $this->configResolver->for($this->applicationType)->needSignOutUris();
|
||||||
|
}
|
||||||
|
|
||||||
protected function choiceFields(): array
|
protected function choiceFields(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -116,10 +132,12 @@ protected function choiceFields(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
private function showCredentials(CreatedApplicationData $appData): void
|
||||||
public function hasClientSecret(): bool
|
|
||||||
{
|
{
|
||||||
return $this->configResolver->for($this->applicationType)->hasClientSecret();
|
$this->clientId = $appData->clientCredentials->clientId;
|
||||||
|
$this->clientSecret = $appData->clientCredentials->clientSecret;
|
||||||
|
$this->app = $appData->application;
|
||||||
|
$this->created = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
@ -132,7 +150,7 @@ public function hasClientSecret(): bool
|
|||||||
>
|
>
|
||||||
<div class="flex w-full justify-center items-center pt-6">
|
<div class="flex w-full justify-center items-center pt-6">
|
||||||
<article class="mx-auto flex items-center gap-4">
|
<article class="mx-auto flex items-center gap-4">
|
||||||
<x-mary-avatar :image="$app->logo ? asset($app->logo) : asset('images/app-icon-placeholder.png')"
|
<x-mary-avatar :image="$app?->logo ? asset($app->logo) : asset('images/app-icon-placeholder.png')"
|
||||||
alt="App logo"
|
alt="App logo"
|
||||||
class="w-14! rounded-lg! p-2"/>
|
class="w-14! rounded-lg! p-2"/>
|
||||||
<h1 class="text-2xl font-medium">{{$app->name}}</h1>
|
<h1 class="text-2xl font-medium">{{$app->name}}</h1>
|
||||||
@ -180,74 +198,81 @@ class="btn-primary"
|
|||||||
<x-mary-input required label="App Name" wire:model="form.name"
|
<x-mary-input required label="App Name" wire:model="form.name"
|
||||||
placeholder="Keka, MS 365 etc."
|
placeholder="Keka, MS 365 etc."
|
||||||
hint="A basic name that matches the actual application"/>
|
hint="A basic name that matches the actual application"/>
|
||||||
|
|
||||||
|
<x-mary-input required label="Access Url" wire:model="form.accessUrl"
|
||||||
|
placeholder="https://app.example.com"
|
||||||
|
hint="The URL by which user will access this application. We will show this url in user dashboard."/>
|
||||||
|
|
||||||
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
|
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
|
||||||
accept="application/jpg, application/png, image/jpeg, image/png"/>
|
accept="application/jpg, application/png, image/jpeg, image/png"/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- General settings end -->
|
<!-- General settings end -->
|
||||||
|
@if($this->needSignInUris)
|
||||||
<!-- Sign-in uris start -->
|
<!-- Sign-in uris start -->
|
||||||
<hr>
|
<hr>
|
||||||
<div class="flex gap-4 flex-col w-full md:flex-row">
|
<div class="flex gap-4 flex-col w-full md:flex-row">
|
||||||
<article class="w-full md:w-2/5">
|
<article class="w-full md:w-2/5">
|
||||||
<h2 class="font-bold">Sign In Redirect URIs</h2>
|
<h2 class="font-bold">Sign In Redirect URIs</h2>
|
||||||
<p class="text-sm text-gray-500 max-w-70 mt-4">
|
<p class="text-sm text-gray-500 max-w-70 mt-4">
|
||||||
{{config('app.name')}} sends response and ID Token back to these URIs.
|
Allowed URIs which this app can ask to get the response back.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<div class="w-full md:w-3/5">
|
<div class="w-full md:w-3/5">
|
||||||
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
|
<p class="text-xs text-gray-500 mb-2">You can add this later.</p>
|
||||||
</div>
|
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
|
||||||
</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>
|
</div>
|
||||||
|
<!-- Sign-in uris end -->
|
||||||
|
@endif
|
||||||
|
@if($this->needSignOutUris)
|
||||||
|
<!-- 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">
|
||||||
|
Allowed URIs which this app can redirect the user back to after logout.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<div class="w-full md:w-3/5">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">You can add this later.</p>
|
||||||
|
<x-shared.repeater-input model="form.signOutUris" :items="$form->signOutUris"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sign-out uris end -->
|
||||||
|
@endif
|
||||||
|
<!-- 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>
|
{{-- </div>--}}
|
||||||
<!-- Access end -->
|
<!-- Access end -->
|
||||||
|
|
||||||
<div class="flex gap-x-4 font-medium justify-end">
|
<div class="flex gap-x-4 font-medium justify-end">
|
||||||
|
|||||||
645
resources/views/pages/apps/oidc/⚡show.blade.php
Normal file
645
resources/views/pages/apps/oidc/⚡show.blade.php
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Concerns\HandlesOperations;
|
||||||
|
use App\Enums\Permissions\AppPermissionEnum;
|
||||||
|
use App\Enums\ConnectionStatusEnum;
|
||||||
|
use App\Models\{ConnectedApp, ConnectionStatus};
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
|
use App\Services\Applications\OIDC\{OidcService, OidcConfigResolver};
|
||||||
|
use App\Services\ConnectedAppService;
|
||||||
|
use App\Services\ApplicationLogoUploader;
|
||||||
|
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||||
|
use App\Livewire\Concerns\WithRepeaterFields;
|
||||||
|
|
||||||
|
new #[Title('OIDC Application Details')]
|
||||||
|
class extends Component {
|
||||||
|
use HandlesOperations, WithFileUploads, WithRepeaterFields;
|
||||||
|
|
||||||
|
public int $appId;
|
||||||
|
public ConnectedApp $app;
|
||||||
|
public ?Client $passportClient = null;
|
||||||
|
public array $signOutUris = [];
|
||||||
|
public string $selectedTab = 'general-tab';
|
||||||
|
public ?string $newSecretPlain = null;
|
||||||
|
|
||||||
|
// Edit fields
|
||||||
|
public bool $editGeneralModal = false;
|
||||||
|
public string $editName = '';
|
||||||
|
public $editLogo = null;
|
||||||
|
|
||||||
|
public bool $editRedirectsMode = false;
|
||||||
|
public array $editSignInUris = [];
|
||||||
|
public array $editSignOutUris = [];
|
||||||
|
|
||||||
|
private OidcService $oidcService;
|
||||||
|
private OidcConfigResolver $configResolver;
|
||||||
|
private ConnectedAppService $connectedAppService;
|
||||||
|
private ApplicationLogoUploader $logoUploader;
|
||||||
|
|
||||||
|
public function boot(
|
||||||
|
OidcService $oidcService,
|
||||||
|
OidcConfigResolver $configResolver,
|
||||||
|
ConnectedAppService $connectedAppService,
|
||||||
|
ApplicationLogoUploader $logoUploader
|
||||||
|
): void {
|
||||||
|
$this->oidcService = $oidcService;
|
||||||
|
$this->configResolver = $configResolver;
|
||||||
|
$this->connectedAppService = $connectedAppService;
|
||||||
|
$this->logoUploader = $logoUploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresPkce(Client $client): bool
|
||||||
|
{
|
||||||
|
return $this->configResolver->requiresPkce($client);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(int $id): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Read->value);
|
||||||
|
$this->appId = $id;
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAppData(): void
|
||||||
|
{
|
||||||
|
$this->app = ConnectedApp::with(['protocol', 'status', 'roles'])->findOrFail($this->appId);
|
||||||
|
$this->passportClient = $this->oidcService->getPassportClient($this->app);
|
||||||
|
$this->signOutUris = $this->oidcService->getSignOutUris($this->app);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateNewSecret(): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
if (!$this->passportClient) {
|
||||||
|
$this->error('No corresponding Passport Client found to update.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () {
|
||||||
|
$plainSecret = $this->oidcService->generateNewSecret($this->passportClient);
|
||||||
|
$this->newSecretPlain = $plainSecret;
|
||||||
|
$this->success('A new client secret has been generated! Make sure to copy it now.');
|
||||||
|
$this->loadAppData();
|
||||||
|
},
|
||||||
|
successMessage: 'New client secret generated successfully!',
|
||||||
|
showSuccess: false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeStatus(string $statusName): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () use ($statusName) {
|
||||||
|
$statusId = ConnectionStatus::where('name', $statusName)->firstOrFail()->id;
|
||||||
|
$request = new ConnectAppRequest(
|
||||||
|
name: $this->app->name,
|
||||||
|
connectionProtocolId: $this->app->connection_protocol_id,
|
||||||
|
connectionProviderId: $this->app->connection_provider_id,
|
||||||
|
slug: $this->app->slug,
|
||||||
|
connectionStatusId: $statusId,
|
||||||
|
settings: $this->app->settings,
|
||||||
|
logo: $this->app->logo,
|
||||||
|
);
|
||||||
|
$this->connectedAppService->update($this->app->id, $request);
|
||||||
|
$this->loadAppData();
|
||||||
|
},
|
||||||
|
successMessage: 'Application status updated successfully!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goBack(): void
|
||||||
|
{
|
||||||
|
$this->redirectRoute('apps.index', navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openEditGeneralModal(): void
|
||||||
|
{
|
||||||
|
$this->editName = $this->app->name;
|
||||||
|
$this->editLogo = null;
|
||||||
|
$this->editGeneralModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveGeneralInfo(): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editName' => 'required|string|max:255',
|
||||||
|
'editLogo' => 'nullable|image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () {
|
||||||
|
$logoPath = $this->editLogo
|
||||||
|
? $this->logoUploader->store($this->editLogo)
|
||||||
|
: $this->app->logo;
|
||||||
|
|
||||||
|
$request = new ConnectAppRequest(
|
||||||
|
name: $this->editName,
|
||||||
|
connectionProtocolId: $this->app->connection_protocol_id,
|
||||||
|
connectionProviderId: $this->app->connection_provider_id,
|
||||||
|
slug: \Illuminate\Support\Str::slug($this->editName),
|
||||||
|
connectionStatusId: $this->app->connection_status_id,
|
||||||
|
settings: $this->app->settings,
|
||||||
|
logo: $logoPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->connectedAppService->update($this->app->id, $request);
|
||||||
|
|
||||||
|
if ($this->passportClient && $this->passportClient->name !== $this->editName) {
|
||||||
|
$this->passportClient->name = $this->editName;
|
||||||
|
$this->passportClient->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->editGeneralModal = false;
|
||||||
|
$this->success('Application updated successfully!');
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startEditRedirects(): void
|
||||||
|
{
|
||||||
|
$clientUris = $this->passportClient->redirect_uris ?? [];
|
||||||
|
$this->editSignOutUris = $this->signOutUris;
|
||||||
|
|
||||||
|
$this->editSignInUris = array_values(array_filter(
|
||||||
|
$clientUris,
|
||||||
|
fn($uri) => !in_array($uri, $this->editSignOutUris, true)
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->editRedirectsMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEditRedirects(): void
|
||||||
|
{
|
||||||
|
$this->editRedirectsMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function saveRedirectUris(): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
if (!$this->passportClient) {
|
||||||
|
$this->error('No corresponding Passport Client found to update.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$signInUris = array_values(array_filter(array_map('trim', $this->editSignInUris)));
|
||||||
|
$signOutUris = array_values(array_filter(array_map('trim', $this->editSignOutUris)));
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () use ($signInUris, $signOutUris) {
|
||||||
|
$this->oidcService->updateRedirectUris($this->app, $this->passportClient, $signInUris, $signOutUris);
|
||||||
|
$this->editRedirectsMode = false;
|
||||||
|
$this->success('Redirect URIs updated successfully!');
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header Card -->
|
||||||
|
<x-shared.card class="p-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="relative group">
|
||||||
|
<x-mary-avatar
|
||||||
|
:image="$app->logo ? asset($app->logo) : null"
|
||||||
|
:placeholder="$app->logo ? null : 'lucide.settings'"
|
||||||
|
class="w-16 h-16 rounded-xl border border-gray-200 p-2 shadow-xs bg-gray-50 group-hover:opacity-85 transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 ">{{ $app->name }}</h1>
|
||||||
|
<button wire:click="openEditGeneralModal" class="text-gray-400 hover:text-gray-600 ">
|
||||||
|
<x-lucide-pencil class="w-4 h-4"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">Protocol: <span
|
||||||
|
class="font-semibold text-gray-700 ">{{ strtoupper($app->protocol->name) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status Dropdown/Trigger -->
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<label tabindex="0"
|
||||||
|
class="btn btn-sm btn-outline gap-1.5 font-semibold {{ $app->status->name === 'connected' ? 'border-emerald-200 text-emerald-700 bg-emerald-50/30' : ($app->status->name === 'disconnected' ? 'border-rose-200 text-rose-700 bg-rose-50/30' : 'border-gray-200 text-gray-700 bg-gray-50/30') }}">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full {{ $app->status->name === 'connected' ? 'bg-emerald-500' : ($app->status->name === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
|
||||||
|
{{ ucfirst($app->status->name) }}
|
||||||
|
<x-lucide-chevron-down class="w-3.5 h-3.5 opacity-60"/>
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0"
|
||||||
|
class="dropdown-content menu p-1 shadow-md bg-base-100 rounded-box w-44 border border-gray-100 mt-1 z-30">
|
||||||
|
@foreach(App\Enums\ConnectionStatusEnum::cases() as $statusCase)
|
||||||
|
<li>
|
||||||
|
<button wire:click="changeStatus('{{ $statusCase->value }}')"
|
||||||
|
class="font-medium text-xs py-2">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full {{ $statusCase->value === 'connected' ? 'bg-emerald-500' : ($statusCase->value === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
|
||||||
|
{{ ucfirst($statusCase->value) }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.arrow-left"
|
||||||
|
class="btn-sm btn-ghost text-gray-500"
|
||||||
|
wire:click="goBack"
|
||||||
|
label="Back"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<x-shared.card class="p-0 overflow-hidden">
|
||||||
|
<x-mary-tabs wire:model="selectedTab" class="px-4" label-div-class="pt-3 bg-gray-50 border-b border-gray-200"
|
||||||
|
label-class="font-bold pb-3">
|
||||||
|
|
||||||
|
<!-- General Tab -->
|
||||||
|
<x-mary-tab name="general-tab" label="General">
|
||||||
|
<div class="space-y-6">
|
||||||
|
@if($newSecretPlain)
|
||||||
|
<x-mary-alert
|
||||||
|
title="New Client Secret Generated"
|
||||||
|
description="Make sure to copy the client secret below now. You won't be able to see it again."
|
||||||
|
icon="lucide.key-round"
|
||||||
|
class="alert-warning alert-soft border border-yellow-200/80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div x-data="{ copied: false }" class="flex items-center gap-2 max-w-xl">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value="{{ $newSecretPlain }}"
|
||||||
|
x-ref="secretInput"
|
||||||
|
class="input input-sm font-mono w-full bg-white border border-gray-200 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="navigator.clipboard.writeText($refs.secretInput.value); copied = true; setTimeout(() => copied = false, 2000)"
|
||||||
|
class="btn btn-sm btn-square btn-outline border-gray-200 shrink-0"
|
||||||
|
>
|
||||||
|
<x-lucide-copy class="w-4 h-4 text-gray-500" x-show="!copied"/>
|
||||||
|
<x-lucide-check class="w-4 h-4 text-green-500" x-show="copied"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!$passportClient)
|
||||||
|
<x-mary-alert
|
||||||
|
title="Passport Client Misconfigured"
|
||||||
|
description="No Laravel Passport Client is associated with this app name. Double check database or create client."
|
||||||
|
icon="lucide.alert-triangle"
|
||||||
|
class="alert-error alert-soft"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Client Credentials Section -->
|
||||||
|
<x-shared.card title="Client Credentials">
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<!-- Client ID -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-center">
|
||||||
|
<span class="text-xs font-medium text-gray-500">Client ID</span>
|
||||||
|
<div class="md:col-span-3 flex items-center gap-2 max-w-lg">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value="{{ $passportClient?->id ?? 'Not found' }}"
|
||||||
|
class="input input-sm input-bordered font-mono w-full bg-gray-50 border-gray-200 focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
@if($passportClient)
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.copy"
|
||||||
|
class="btn-sm btn-square btn-outline border-gray-200"
|
||||||
|
@click="navigator.clipboard.writeText('{{ $passportClient->id }}'); alert('Client ID copied!');"
|
||||||
|
tooltip="Copy Client ID"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||||
|
<div></div>
|
||||||
|
<div class="md:col-span-3 text-xs text-gray-400 -mt-2">
|
||||||
|
Public identifier for the client that is required for all OAuth flows.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Authentication -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-start">
|
||||||
|
<span class="text-xs font-medium text-gray-500">Client Authentication</span>
|
||||||
|
<div class="md:col-span-3 space-y-3">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" checked readonly @click.prevent
|
||||||
|
class="radio radio-primary radio-xs"/>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-gray-700 ">Client secret</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 opacity-50 cursor-not-allowed">
|
||||||
|
<input type="radio" readonly @click.prevent class="radio radio-xs"/>
|
||||||
|
<span class="text-xs font-medium text-gray-700">Public key / Private key</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PKCE -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-center">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-gray-500">Proof Key for Code Exchange (PKCE)</span>
|
||||||
|
<div class="md:col-span-3">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
@click.prevent
|
||||||
|
@checked($passportClient && $this->requiresPkce($passportClient))
|
||||||
|
class="checkbox checkbox-xs"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-600">Require PKCE as additional verification</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Client Secrets Section -->
|
||||||
|
<x-shared.card title="Client Secrets">
|
||||||
|
@if($passportClient)
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-mary-button
|
||||||
|
wire:click="generateNewSecret"
|
||||||
|
class="btn-outline border-blue-200 text-blue-600 hover:bg-blue-50"
|
||||||
|
icon="lucide.plus"
|
||||||
|
label="Generate new secret"
|
||||||
|
spinner="generateNewSecret"
|
||||||
|
/>
|
||||||
|
</x-slot:actions>
|
||||||
|
@endif
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50/30 text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Creation date</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Secret</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if($passportClient)
|
||||||
|
<tr class="hover:bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<td class="py-4 px-6 text-gray-600 ">
|
||||||
|
{{ $passportClient->updated_at?->format('M j, Y H:i:s') ?? 'N/A' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 font-mono text-gray-500 tracking-widest flex items-center gap-3">
|
||||||
|
<span>••••••••••••••••••••••••••••••••••••••••</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 text-right">
|
||||||
|
<span
|
||||||
|
class="badge {{ !$passportClient->revoked ? 'badge-soft badge-success' : 'badge-soft badge-error' }} font-semibold text-xs px-2.5 py-1">
|
||||||
|
{{ !$passportClient->revoked ? 'Active' : 'Revoked' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@else
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-6 text-gray-400 italic">No secrets
|
||||||
|
available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
|
||||||
|
<!-- Sign On Tab -->
|
||||||
|
<x-mary-tab name="signon-tab" label="Sign On">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-shared.card title="Sign In Redirect URIs">
|
||||||
|
<x-slot:actions>
|
||||||
|
@if($editRedirectsMode)
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.check"
|
||||||
|
class="btn-primary"
|
||||||
|
label="Save"
|
||||||
|
wire:click="saveRedirectUris"
|
||||||
|
/>
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.x"
|
||||||
|
class="btn-ghost"
|
||||||
|
label="Cancel"
|
||||||
|
wire:click="cancelEditRedirects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.pencil"
|
||||||
|
class="btn-outline border-gray-200"
|
||||||
|
label="Edit"
|
||||||
|
wire:click="startEditRedirects"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
</x-slot:actions>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-3">
|
||||||
|
@if($editRedirectsMode)
|
||||||
|
<x-shared.repeater-input
|
||||||
|
model="editSignInUris"
|
||||||
|
:items="$editSignInUris"
|
||||||
|
placeholder="https://app.example.com/callback"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$onlySignInUris = array_values(array_filter(
|
||||||
|
$passportClient?->redirect_uris ?? [],
|
||||||
|
fn($uri) => !in_array($uri, $signOutUris, true)
|
||||||
|
));
|
||||||
|
@endphp
|
||||||
|
@if(!empty($onlySignInUris))
|
||||||
|
@foreach($onlySignInUris as $uri)
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-2 border border-gray-150 rounded-lg font-mono text-xs text-gray-600 bg-gray-50">
|
||||||
|
<x-lucide-link class="w-3.5 h-3.5 text-gray-400"/>
|
||||||
|
<span class="flex-1">{{ $uri }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-400 italic">No Sign In Redirect URIs configured</span>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Sign Out Redirect URIs -->
|
||||||
|
<x-shared.card title="Sign Out Redirect URIs">
|
||||||
|
<div class="p-6 space-y-3">
|
||||||
|
@if($editRedirectsMode)
|
||||||
|
<x-shared.repeater-input
|
||||||
|
model="editSignOutUris"
|
||||||
|
:items="$editSignOutUris"
|
||||||
|
placeholder="https://app.example.com/logout"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
@if(!empty($signOutUris))
|
||||||
|
@foreach($signOutUris as $uri)
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-2 border border-gray-150 rounded-lg font-mono text-xs text-gray-600 bg-gray-50">
|
||||||
|
<x-lucide-log-out class="w-3.5 h-3.5 text-gray-400"/>
|
||||||
|
<span class="flex-1">{{ $uri }}</span>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<span
|
||||||
|
class="text-xs text-gray-400 italic">No Sign Out Redirect URIs configured</span>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
|
||||||
|
<!-- Assignments Tab -->
|
||||||
|
<x-mary-tab name="assignments-tab" label="Assignments">
|
||||||
|
<div class="">
|
||||||
|
<x-shared.card title="Assigned Roles">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50/30 text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Role Name</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Priority</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-right">Access Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($app->roles as $role)
|
||||||
|
<tr class="hover:bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<td class="py-4 px-6 font-semibold text-gray-800 0">
|
||||||
|
{{ $role->name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 text-gray-500">
|
||||||
|
{{ $role->priority }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 text-right">
|
||||||
|
<span
|
||||||
|
class="badge badge-soft badge-primary font-semibold text-xs px-2.5 py-1">
|
||||||
|
SSO Access
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-6 text-gray-400 italic">
|
||||||
|
No specific roles assigned. Accessible by everyone depending on user access
|
||||||
|
setting.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
|
||||||
|
<!-- Scopes Tab -->
|
||||||
|
<x-mary-tab name="scopes-tab" label="Scopes">
|
||||||
|
<div class="">
|
||||||
|
<x-shared.card title="Granted OAuth/OIDC Scopes">
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
|
||||||
|
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
|
||||||
|
<x-lucide-user class="w-4 h-4"/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-xs text-gray-800">openid</div>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-0.5">Allows the application to fetch ID
|
||||||
|
tokens for authenticating users.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
|
||||||
|
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
|
||||||
|
<x-lucide-id-card class="w-4 h-4"/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-xs text-gray-800">profile</div>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-0.5">Allows reading default profile
|
||||||
|
fields like name, picture, and locale.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
|
||||||
|
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
|
||||||
|
<x-lucide-mail class="w-4 h-4"/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-xs text-gray-800">email</div>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-0.5">Grants access to the user's primary
|
||||||
|
email address.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
|
||||||
|
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
|
||||||
|
<x-lucide-refresh-cw class="w-4 h-4"/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-xs text-gray-800">offline_access</div>
|
||||||
|
<p class="text-[11px] text-gray-400 mt-0.5">Allows requesting refresh tokens to
|
||||||
|
maintain access offline.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
|
||||||
|
</x-mary-tabs>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Edit General Info Modal -->
|
||||||
|
<x-mary-modal wire:model="editGeneralModal" title="Edit App General Settings" class="backdrop-blur">
|
||||||
|
<x-mary-form wire:submit="saveGeneralInfo">
|
||||||
|
<x-mary-input
|
||||||
|
label="Application Name"
|
||||||
|
wire:model="editName"
|
||||||
|
placeholder="e.g. My App"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<x-mary-file
|
||||||
|
label="Application Logo"
|
||||||
|
wire:model="editLogo"
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
hint="Optional. Max 2MB."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-mary-button label="Cancel" @click="$wire.editGeneralModal = false" class="btn-ghost"/>
|
||||||
|
<x-mary-button label="Save Settings" type="submit" class="btn-primary" spinner="saveGeneralInfo"/>
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-mary-form>
|
||||||
|
</x-mary-modal>
|
||||||
|
</div>
|
||||||
173
resources/views/pages/apps/url/⚡create.blade.php
Normal file
173
resources/views/pages/apps/url/⚡create.blade.php
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Concerns\HandlesOperations;
|
||||||
|
use App\Data\Application\StoreURLAppData;
|
||||||
|
use App\Enums\{ConnectionProtocolEnum, Permissions\AppPermissionEnum, UserAccessTypeEnum};
|
||||||
|
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
|
||||||
|
use App\Livewire\Concerns\WithRepeaterFields;
|
||||||
|
use App\Livewire\Forms\StoreURLAppForm;
|
||||||
|
use App\Services\{Applications\URL\UrlAppService, RoleService};
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
|
||||||
|
new
|
||||||
|
class extends Component {
|
||||||
|
use HandlesOperations, WithRepeaterFields, WithSearchableChoices, WithFileUploads;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $protocol = '';
|
||||||
|
|
||||||
|
public StoreURLAppForm $form;
|
||||||
|
public ConnectionProtocolEnum $connectionProtocol = ConnectionProtocolEnum::URL;
|
||||||
|
private RoleService $roleService;
|
||||||
|
private UrlAppService $urlAppService;
|
||||||
|
|
||||||
|
|
||||||
|
public function boot(RoleService $roleService, UrlAppService $urlAppService): void
|
||||||
|
{
|
||||||
|
$this->roleService = $roleService;
|
||||||
|
$this->urlAppService = $urlAppService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
/** @phpstan-ignore-next-line */
|
||||||
|
return $this->view()->title($this->title());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(bool $goBack = true): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Create->value);
|
||||||
|
$this->form->validate();
|
||||||
|
$this->attempt(
|
||||||
|
function () use ($goBack) {
|
||||||
|
$data = StoreURLAppData::fromArray($this->form->pull());
|
||||||
|
$this->urlAppService->createApp($data);
|
||||||
|
|
||||||
|
if ($goBack) {
|
||||||
|
$this->goBack();
|
||||||
|
} else {
|
||||||
|
$this->form->reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function goBack(): void
|
||||||
|
{
|
||||||
|
$this->redirectRoute("apps.index", navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return "New Redirected App Integration";
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<div class="flex items-center gap-4 w-full">
|
||||||
|
<div class="flex-1">
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div class="max-w-90">
|
||||||
|
<x-mary-checkbox label="Connected to Microsoft ?" wire:model="form.isConnectedToMicrosoft"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-mary-input required label="Access Url" wire:model="form.accessUrl"
|
||||||
|
placeholder="https://app.example.com"
|
||||||
|
hint="The URL by which user will access this application. We will show this url in user dashboard."/>
|
||||||
|
|
||||||
|
<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 -->
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
347
resources/views/pages/apps/url/⚡show.blade.php
Normal file
347
resources/views/pages/apps/url/⚡show.blade.php
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Concerns\HandlesOperations;
|
||||||
|
use App\Enums\Permissions\AppPermissionEnum;
|
||||||
|
use App\Services\ConnectedAppService;
|
||||||
|
use App\Services\ApplicationLogoUploader;
|
||||||
|
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||||
|
use App\Enums\ConnectionStatusEnum;
|
||||||
|
use App\Models\{ConnectedApp, ConnectionStatus};
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
new #[Title('URL Application Details')]
|
||||||
|
class extends Component {
|
||||||
|
use HandlesOperations, WithFileUploads;
|
||||||
|
|
||||||
|
public int $appId;
|
||||||
|
public ConnectedApp $app;
|
||||||
|
public string $selectedTab = 'general-tab';
|
||||||
|
|
||||||
|
// Edit fields
|
||||||
|
public bool $editGeneralModal = false;
|
||||||
|
public string $editName = '';
|
||||||
|
public string $editAccessUrl = '';
|
||||||
|
public $editLogo = null;
|
||||||
|
public bool $editAccessUrlMode = false;
|
||||||
|
|
||||||
|
private ConnectedAppService $appService;
|
||||||
|
private ApplicationLogoUploader $logoUploader;
|
||||||
|
|
||||||
|
public function boot(ConnectedAppService $service, ApplicationLogoUploader $logoUploader): void
|
||||||
|
{
|
||||||
|
$this->appService = $service;
|
||||||
|
$this->logoUploader = $logoUploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(int $id): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Read->value);
|
||||||
|
$this->appId = $id;
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAppData(): void
|
||||||
|
{
|
||||||
|
$this->app = ConnectedApp::with(['protocol', 'status', 'roles'])->findOrFail($this->appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeStatus(string $statusName): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () use ($statusName) {
|
||||||
|
$statusId = ConnectionStatus::where('name', $statusName)->firstOrFail()->id;
|
||||||
|
$request = new ConnectAppRequest(
|
||||||
|
name: $this->app->name,
|
||||||
|
connectionProtocolId: $this->app->connection_protocol_id,
|
||||||
|
connectionProviderId: $this->app->connection_provider_id,
|
||||||
|
slug: $this->app->slug,
|
||||||
|
connectionStatusId: $statusId,
|
||||||
|
settings: $this->app->settings,
|
||||||
|
logo: $this->app->logo,
|
||||||
|
);
|
||||||
|
$this->appService->update($this->app->id, $request);
|
||||||
|
$this->loadAppData();
|
||||||
|
},
|
||||||
|
successMessage: 'Application status updated successfully!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goBack(): void
|
||||||
|
{
|
||||||
|
$this->redirectRoute('apps.index', navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openEditGeneralModal(): void
|
||||||
|
{
|
||||||
|
$this->editName = $this->app->name;
|
||||||
|
$this->editLogo = null;
|
||||||
|
$this->editGeneralModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveGeneralInfo(): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editName' => 'required|string|max:255',
|
||||||
|
'editLogo' => 'nullable|image|max:2048',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () {
|
||||||
|
$logoPath = $this->editLogo
|
||||||
|
? $this->logoUploader->store($this->editLogo)
|
||||||
|
: $this->app->logo;
|
||||||
|
|
||||||
|
$request = new ConnectAppRequest(
|
||||||
|
name: $this->editName,
|
||||||
|
connectionProtocolId: $this->app->connection_protocol_id,
|
||||||
|
connectionProviderId: $this->app->connection_provider_id,
|
||||||
|
slug: \Illuminate\Support\Str::slug($this->editName),
|
||||||
|
connectionStatusId: $this->app->connection_status_id,
|
||||||
|
settings: $this->app->settings,
|
||||||
|
logo: $logoPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->appService->update($this->app->id, $request);
|
||||||
|
|
||||||
|
$this->editGeneralModal = false;
|
||||||
|
$this->success('Application updated successfully!');
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startEditAccessUrl(): void
|
||||||
|
{
|
||||||
|
$this->editAccessUrl = data_get($this->app, 'settings.access_url', '');
|
||||||
|
$this->editAccessUrlMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEditAccessUrl(): void
|
||||||
|
{
|
||||||
|
$this->editAccessUrlMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAccessUrl(): void
|
||||||
|
{
|
||||||
|
$this->authorize(AppPermissionEnum::Update->value);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'editAccessUrl' => 'required|url|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->attempt(
|
||||||
|
action: function () {
|
||||||
|
$settings = $this->app->settings ?? [];
|
||||||
|
$settings['access_url'] = $this->editAccessUrl;
|
||||||
|
|
||||||
|
$request = new ConnectAppRequest(
|
||||||
|
name: $this->app->name,
|
||||||
|
connectionProtocolId: $this->app->connection_protocol_id,
|
||||||
|
connectionProviderId: $this->app->connection_provider_id,
|
||||||
|
slug: $this->app->slug,
|
||||||
|
connectionStatusId: $this->app->connection_status_id,
|
||||||
|
settings: $settings,
|
||||||
|
logo: $this->app->logo,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->appService->update($this->app->id, $request);
|
||||||
|
|
||||||
|
$this->editAccessUrlMode = false;
|
||||||
|
$this->success('Access URL updated successfully!');
|
||||||
|
$this->loadAppData();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header Card -->
|
||||||
|
<x-shared.card class="p-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="relative group">
|
||||||
|
<x-mary-avatar
|
||||||
|
:image="$app->logo ? asset($app->logo) : null"
|
||||||
|
:placeholder="$app->logo ? null : 'lucide.settings'"
|
||||||
|
class="w-16 h-16 rounded-xl border border-gray-200 p-2 shadow-xs bg-gray-50 group-hover:opacity-85 transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 ">{{ $app->name }}</h1>
|
||||||
|
<button wire:click="openEditGeneralModal" class="text-gray-400 hover:text-gray-600 ">
|
||||||
|
<x-lucide-pencil class="w-4 h-4"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-0.5">Protocol: <span
|
||||||
|
class="font-semibold text-gray-700 ">{{ strtoupper($app->protocol->name) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status Dropdown/Trigger -->
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<label tabindex="0"
|
||||||
|
class="btn btn-sm btn-outline gap-1.5 font-semibold {{ $app->status->name === 'connected' ? 'border-emerald-200 text-emerald-700 bg-emerald-50/30' : ($app->status->name === 'disconnected' ? 'border-rose-200 text-rose-700 bg-rose-50/30' : 'border-gray-200 text-gray-700 bg-gray-50/30') }}">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full {{ $app->status->name === 'connected' ? 'bg-emerald-500' : ($app->status->name === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
|
||||||
|
{{ ucfirst($app->status->name) }}
|
||||||
|
<x-lucide-chevron-down class="w-3.5 h-3.5 opacity-60"/>
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0"
|
||||||
|
class="dropdown-content menu p-1 shadow-md bg-base-100 rounded-box w-44 border border-gray-100 mt-1 z-30">
|
||||||
|
@foreach(App\Enums\ConnectionStatusEnum::cases() as $statusCase)
|
||||||
|
<li>
|
||||||
|
<button wire:click="changeStatus('{{ $statusCase->value }}')"
|
||||||
|
class="font-medium text-xs py-2">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full {{ $statusCase->value === 'connected' ? 'bg-emerald-500' : ($statusCase->value === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
|
||||||
|
{{ ucfirst($statusCase->value) }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.arrow-left"
|
||||||
|
class="btn-sm btn-ghost text-gray-500"
|
||||||
|
wire:click="goBack"
|
||||||
|
label="Back"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<x-shared.card class="p-0 overflow-hidden">
|
||||||
|
<x-mary-tabs wire:model="selectedTab" class="px-4" label-div-class="pt-3 bg-gray-50 border-b border-gray-200"
|
||||||
|
label-class="font-bold pb-3">
|
||||||
|
|
||||||
|
<!-- General Tab -->
|
||||||
|
<x-mary-tab name="general-tab" label="General">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<x-shared.card title="Access URL">
|
||||||
|
<x-slot:actions>
|
||||||
|
@if($editAccessUrlMode)
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.check"
|
||||||
|
class="btn-primary"
|
||||||
|
label="Save"
|
||||||
|
wire:click="saveAccessUrl"
|
||||||
|
/>
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.x"
|
||||||
|
class="btn-ghost"
|
||||||
|
label="Cancel"
|
||||||
|
wire:click="cancelEditAccessUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<x-mary-button
|
||||||
|
icon="lucide.pencil"
|
||||||
|
label="Edit"
|
||||||
|
wire:click="startEditAccessUrl"
|
||||||
|
class="btn-primary btn-outline"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
</x-slot:actions>
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
@if($editAccessUrlMode)
|
||||||
|
<x-mary-input
|
||||||
|
type="text"
|
||||||
|
wire:model="editAccessUrl"
|
||||||
|
class="input-sm font-mono w-full bg-white border-gray-200"
|
||||||
|
placeholder="https://app.example.com"
|
||||||
|
/>
|
||||||
|
@else
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value="{{data_get($app, 'settings.access_url')}}"
|
||||||
|
class="input input-sm input-bordered font-mono w-full bg-gray-50 border-gray-200 focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
|
||||||
|
<!-- Assignments Tab -->
|
||||||
|
<x-mary-tab name="assignments-tab" label="Assignments">
|
||||||
|
<div class="">
|
||||||
|
<x-shared.card title="Assigned Roles">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50/30 text-gray-400 border-b border-gray-100">
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Role Name</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-left">Priority</th>
|
||||||
|
<th class="font-semibold py-3 px-6 text-right">Access Type</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($app->roles as $role)
|
||||||
|
<tr class="hover:bg-gray-50/50 border-b border-gray-100">
|
||||||
|
<td class="py-4 px-6 font-semibold text-gray-800 0">
|
||||||
|
{{ $role->name }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 text-gray-500">
|
||||||
|
{{ $role->priority }}
|
||||||
|
</td>
|
||||||
|
<td class="py-4 px-6 text-right">
|
||||||
|
<span
|
||||||
|
class="badge badge-soft badge-primary font-semibold text-xs px-2.5 py-1">
|
||||||
|
SSO Access
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-6 text-gray-400 italic">
|
||||||
|
No specific roles assigned. Accessible by everyone depending on user access
|
||||||
|
setting.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
|
</x-mary-tab>
|
||||||
|
</x-mary-tabs>
|
||||||
|
</x-shared.card>
|
||||||
|
|
||||||
|
<!-- Edit General Info Modal -->
|
||||||
|
<x-mary-modal wire:model="editGeneralModal" title="Edit App General Settings" class="backdrop-blur">
|
||||||
|
<x-mary-form wire:submit="saveGeneralInfo">
|
||||||
|
<x-mary-input
|
||||||
|
label="Application Name"
|
||||||
|
wire:model="editName"
|
||||||
|
placeholder="e.g. My App"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<x-mary-file
|
||||||
|
label="Application Logo"
|
||||||
|
wire:model="editLogo"
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
hint="Optional. Max 2MB."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-mary-button label="Cancel" @click="$wire.editGeneralModal = false" class="btn-ghost"/>
|
||||||
|
<x-mary-button label="Save Settings" type="submit" class="btn-primary" spinner="saveGeneralInfo"/>
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-mary-form>
|
||||||
|
</x-mary-modal>
|
||||||
|
</div>
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Enums\ApplicationTypeEnum;
|
use App\Enums\ApplicationTypeEnum;
|
||||||
use App\Enums\ConnectionProtocolEnum;
|
use App\Enums\ConnectionProtocolEnum;
|
||||||
use App\Livewire\Forms\CreateAppSelectionForm;
|
use App\Livewire\Forms\CreateAppSelectionForm;
|
||||||
|
use App\Models\ConnectedApp;
|
||||||
use App\Services\ConnectedAppService;
|
use App\Services\ConnectedAppService;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
@ -42,7 +43,6 @@ private function initTableHeaders(): void
|
|||||||
{
|
{
|
||||||
$this->headers = TableHeader::collect([
|
$this->headers = TableHeader::collect([
|
||||||
new TableHeader(key: 'name', label: 'Name',),
|
new TableHeader(key: 'name', label: 'Name',),
|
||||||
new TableHeader(key: 'provider', label: 'Provider'),
|
|
||||||
new TableHeader(key: 'protocol', label: 'Protocol'),
|
new TableHeader(key: 'protocol', label: 'Protocol'),
|
||||||
new TableHeader(key: 'status', label: 'Status'),
|
new TableHeader(key: 'status', label: 'Status'),
|
||||||
], DataCollection::class);
|
], DataCollection::class);
|
||||||
@ -62,6 +62,18 @@ public function edit(int $appId): void
|
|||||||
$this->redirectRoute('apps.edit', ['id' => $appId], navigate: true);
|
$this->redirectRoute('apps.edit', ['id' => $appId], navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function viewApp(int $appId): void
|
||||||
|
{
|
||||||
|
$app = ConnectedApp::with('protocol')->findOrFail($appId);
|
||||||
|
$protocol = mb_strtolower($app->protocol->name ?? '');
|
||||||
|
|
||||||
|
if ($protocol === 'oidc') {
|
||||||
|
$this->redirectRoute('apps.oidc.show', ['id' => $appId], navigate: true);
|
||||||
|
} else {
|
||||||
|
$this->redirectRoute('apps.url.show', ['id' => $appId], navigate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(int $appId): void
|
public function delete(int $appId): void
|
||||||
{
|
{
|
||||||
$this->attempt(
|
$this->attempt(
|
||||||
@ -102,8 +114,8 @@ public function createApp(): void
|
|||||||
|
|
||||||
@scope('actions', $row)
|
@scope('actions', $row)
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})"
|
<x-mary-button icon="lucide.eye" wire:click="viewApp({{$row->id}})" spinner="viewApp({{$row->id}})"
|
||||||
class="mr-2 btn-sm btn-circle text-gray-500"/>
|
class="mr-2 btn-sm btn-circle text-blue-500" tooltip="View Details"/>
|
||||||
<x-mary-button icon="lucide.trash" wire:click="requireConfirmation('delete', {{$row->id}})"
|
<x-mary-button icon="lucide.trash" wire:click="requireConfirmation('delete', {{$row->id}})"
|
||||||
spinner="requireConfirmation('delete', {{$row->id}})"
|
spinner="requireConfirmation('delete', {{$row->id}})"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@ -40,14 +40,6 @@ public function mount(): void
|
|||||||
if (session("entra_error")) {
|
if (session("entra_error")) {
|
||||||
$this->error(session("entra_error"));
|
$this->error(session("entra_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session("keka_success")) {
|
|
||||||
$this->success(session("keka_success"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session("keka_error")) {
|
|
||||||
$this->error(session("keka_error"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@ -75,20 +67,6 @@ public function isEntraConnected(): bool
|
|||||||
|
|
||||||
return $token !== null && !$token->isExpired();
|
return $token !== null && !$token->isExpired();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
|
||||||
public function kekaToken(): ?OauthToken
|
|
||||||
{
|
|
||||||
return auth()->user()?->oauthToken('keka');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Computed]
|
|
||||||
public function isKekaConnected(): bool
|
|
||||||
{
|
|
||||||
$token = $this->kekaToken();
|
|
||||||
|
|
||||||
return $token !== null && !$token->isExpired();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@ -141,10 +119,10 @@ class="w-12 h-12 text-gray-600 animate-pulse"
|
|||||||
<x-shared.card
|
<x-shared.card
|
||||||
:title="$service->name"
|
:title="$service->name"
|
||||||
icon="lucide-key-round"
|
icon="lucide-key-round"
|
||||||
class="group relative {{ $service->is_warning ? 'border-amber-300 dark:border-amber-900/40 bg-amber-50/10' :'' }} rounded-2xl shadow-xs flex flex-col justify-between"
|
class="group relative {{ $service->isWarning ? 'border-amber-300 dark:border-amber-900/40 bg-amber-50/10' :'' }} rounded-2xl shadow-xs flex flex-col justify-between"
|
||||||
>
|
>
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
@if($service->is_warning)
|
@if($service->isWarning)
|
||||||
<x-mary-badge
|
<x-mary-badge
|
||||||
class="badge-soft badge-warning font-semibold text-xs px-2.5 py-1 flex items-center gap-1.5">
|
class="badge-soft badge-warning font-semibold text-xs px-2.5 py-1 flex items-center gap-1.5">
|
||||||
<x-mary-icon name="lucide.alert-triangle"
|
<x-mary-icon name="lucide.alert-triangle"
|
||||||
@ -160,13 +138,9 @@ class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>
|
|||||||
|
|
||||||
<div class="p-4 flex-1 flex flex-col justify-between gap-4">
|
<div class="p-4 flex-1 flex flex-col justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd')
|
@if($service->isConnectedToMicrosoft)
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3">
|
class="flex flex-col gap-3">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs font-semibold text-gray-700">Microsoft Integration</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if($this->isEntraConnected)
|
@if($this->isEntraConnected)
|
||||||
<x-mary-button
|
<x-mary-button
|
||||||
@ -202,60 +176,28 @@ class="btn-primary flex-1"
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr')
|
@if($service->protocolName === 'oidc' || ( $service->protocolName === 'url' && !$service->isConnectedToMicrosoft))
|
||||||
<div
|
<x-mary-button
|
||||||
class="flex flex-col gap-3">
|
external
|
||||||
<div class="flex items-center justify-between">
|
:link="$service->accessUrl"
|
||||||
<span class="text-xs font-semibold text-gray-700">Keka Integration</span>
|
class="btn-primary w-full"
|
||||||
</div>
|
label="Open"
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
@if($this->isKekaConnected)
|
|
||||||
<x-mary-button
|
|
||||||
icon="lucide.external-link"
|
|
||||||
:link="route('keka.connect')"
|
|
||||||
class=" btn-primary flex-1"
|
|
||||||
label="Open Keka"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('keka.disconnect') }}"
|
|
||||||
class="inline">
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<x-mary-button
|
|
||||||
icon="lucide.unplug"
|
|
||||||
type="submit"
|
|
||||||
class=" btn-error"
|
|
||||||
tooltip="Disconnect"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
@else
|
|
||||||
<x-mary-button
|
|
||||||
external
|
|
||||||
no-wire-navigate
|
|
||||||
icon="lucide.plug"
|
|
||||||
:link="route('keka.connect')"
|
|
||||||
class="btn-primary flex-1"
|
|
||||||
label="Connect"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">
|
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<x-mary-icon name="lucide.clock"
|
<x-mary-icon name="lucide.clock"
|
||||||
class="w-4 h-4 {{ $service->is_warning ? 'text-amber-500 animate-pulse' : 'text-gray-400 dark:text-gray-500' }}"/>
|
class="w-4 h-4 {{ $service->isWarning ? 'text-amber-500 animate-pulse' : 'text-gray-400 dark:text-gray-500' }}"/>
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
|
class="text-sm font-medium {{ $service->isWarning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
|
||||||
{{ Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
|
{{ $service->isUnlimited ? 'Unlimited' : Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@if($service->is_warning)
|
@if($service->isWarning)
|
||||||
<x-mary-button
|
<x-mary-button
|
||||||
icon="lucide.life-buoy"
|
icon="lucide.life-buoy"
|
||||||
:link="route('tickets.index', ['raise' => 1, 'app_id' => $service->id])"
|
:link="route('tickets.index', ['raise' => 1, 'app_id' => $service->id])"
|
||||||
|
|||||||
@ -88,6 +88,14 @@ public function boot(TicketService $ticketService, UserAppAccessService $appAcce
|
|||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
// Auto-open drawer if requested from URL
|
||||||
|
if ($this->raise) {
|
||||||
|
$this->drawerOpen = true;
|
||||||
|
if ($this->appId) {
|
||||||
|
$this->form->connectedAppId = $this->appId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-populate dynamic choice lists
|
// Pre-populate dynamic choice lists
|
||||||
$this->searchApps();
|
$this->searchApps();
|
||||||
$this->searchRoles();
|
$this->searchRoles();
|
||||||
@ -100,14 +108,6 @@ public function mount(): void
|
|||||||
$this->form->notifiedRoleIds = [$defaultRole->id];
|
$this->form->notifiedRoleIds = [$defaultRole->id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-open drawer if requested from URL
|
|
||||||
if ($this->raise) {
|
|
||||||
$this->drawerOpen = true;
|
|
||||||
if ($this->appId) {
|
|
||||||
$this->form->connectedAppId = $this->appId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-open ticket if requested from URL
|
// Auto-open ticket if requested from URL
|
||||||
if ($this->ticketParam && (int) $this->ticketParam > 0) {
|
if ($this->ticketParam && (int) $this->ticketParam > 0) {
|
||||||
$this->showTicket((int) $this->ticketParam);
|
$this->showTicket((int) $this->ticketParam);
|
||||||
@ -140,17 +140,12 @@ public function rendering(): void
|
|||||||
public function tickets(): PaginatedDataCollection
|
public function tickets(): PaginatedDataCollection
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (!$user) {
|
|
||||||
return TicketListData::collect(collect(), PaginatedDataCollection::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
$search = trim($this->search);
|
$search = trim($this->search);
|
||||||
$search = empty($search) ? null : $search;
|
$search = empty($search) ? null : $search;
|
||||||
|
|
||||||
if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) {
|
if ($user->hasRole(DefaultUserRole::Admin) || $user->can(TicketPermissionEnum::Read->value)) {
|
||||||
return $this->ticketService->getList($search, $this->statusFilter, 10);
|
return $this->ticketService->getList($search, $this->statusFilter, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10);
|
return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,7 +161,7 @@ public function eligibleApps(): Collection
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->appAccessService->getActiveServices($user)
|
return $this->appAccessService->getActiveServices($user)
|
||||||
->filter(fn($app) => $app->is_warning);
|
->filter(fn($app) => $app->isWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -193,14 +188,14 @@ public function searchApps(string $value = ''): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->appsSearchable = $apps
|
$this->appsSearchable = array_values($apps
|
||||||
->when('' !== $value,
|
->when('' !== $value,
|
||||||
fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value))))
|
fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value))))
|
||||||
->map(fn($app) => [
|
->map(fn($app) => [
|
||||||
'id' => $app->id,
|
'id' => $app->id,
|
||||||
'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''),
|
'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''),
|
||||||
])
|
])
|
||||||
->toArray();
|
->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,7 +205,7 @@ public function searchRoles(string $value = ''): void
|
|||||||
{
|
{
|
||||||
$selectedIds = $this->form->notifiedRoleIds;
|
$selectedIds = $this->form->notifiedRoleIds;
|
||||||
|
|
||||||
$this->rolesSearchable = Role::query()
|
$this->rolesSearchable = array_values(Role::query()
|
||||||
->where(function ($query) use ($value, $selectedIds): void {
|
->where(function ($query) use ($value, $selectedIds): void {
|
||||||
if ('' !== $value) {
|
if ('' !== $value) {
|
||||||
$query->where('name', 'like', "%{$value}%");
|
$query->where('name', 'like', "%{$value}%");
|
||||||
@ -221,7 +216,7 @@ public function searchRoles(string $value = ''): void
|
|||||||
})
|
})
|
||||||
->select('id', 'name')
|
->select('id', 'name')
|
||||||
->get()
|
->get()
|
||||||
->toArray();
|
->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -231,7 +226,7 @@ public function searchUsers(string $value = ''): void
|
|||||||
{
|
{
|
||||||
$selectedIds = $this->form->notifiedUserIds;
|
$selectedIds = $this->form->notifiedUserIds;
|
||||||
|
|
||||||
$this->usersSearchable = User::query()
|
$this->usersSearchable = array_values(User::query()
|
||||||
->where('id', '!=', auth()->id())
|
->where('id', '!=', auth()->id())
|
||||||
->where(function ($query) use ($value, $selectedIds): void {
|
->where(function ($query) use ($value, $selectedIds): void {
|
||||||
if ('' !== $value) {
|
if ('' !== $value) {
|
||||||
@ -244,7 +239,7 @@ public function searchUsers(string $value = ''): void
|
|||||||
})
|
})
|
||||||
->select('id', 'name')
|
->select('id', 'name')
|
||||||
->get()
|
->get()
|
||||||
->toArray();
|
->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -522,7 +517,7 @@ public function toggleDrawer(): void
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@if(!auth()->user()->hasRole('Admin') && !auth()->user()->hasRole('admin') && $this->eligibleApps->isEmpty())
|
@if(!auth()->user()->hasRole(DefaultUserRole::Admin) && $this->eligibleApps->isEmpty())
|
||||||
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs shadow-sm rounded-xl" dismissible>
|
<x-mary-alert icon="lucide.alert-triangle" class="alert-warning text-xs shadow-sm rounded-xl" dismissible>
|
||||||
You do not currently have any active services expiring within 3 days. You can still submit a general
|
You do not currently have any active services expiring within 3 days. You can still submit a general
|
||||||
inquiry ticket without choosing a service.
|
inquiry ticket without choosing a service.
|
||||||
@ -672,14 +667,14 @@ class="w-11/12 md:w-5/12 p-6 bg-white"
|
|||||||
<!-- Connected App Selection (Nullable & Searchable) -->
|
<!-- Connected App Selection (Nullable & Searchable) -->
|
||||||
<x-mary-choices
|
<x-mary-choices
|
||||||
wire:model="form.connectedAppId"
|
wire:model="form.connectedAppId"
|
||||||
:label="__('Select Service (Expiring Soon)')"
|
:label="__('Select Apps')"
|
||||||
placeholder="Search/Select service..."
|
placeholder="Search/Select apps..."
|
||||||
:options="$appsSearchable"
|
:options="$appsSearchable"
|
||||||
:single="true"
|
:single="true"
|
||||||
search-function="searchApps"
|
search-function="searchApps"
|
||||||
searchable
|
searchable
|
||||||
|
option-value="id"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Issue Category -->
|
<!-- Issue Category -->
|
||||||
<x-mary-select
|
<x-mary-select
|
||||||
wire:model.live="form.issueCategory"
|
wire:model.live="form.issueCategory"
|
||||||
|
|||||||
@ -18,6 +18,17 @@
|
|||||||
->name(ConnectionProtocolEnum::OIDC->value.'.')
|
->name(ConnectionProtocolEnum::OIDC->value.'.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::livewire('create', 'pages::apps.oidc.create')->name('create');
|
Route::livewire('create', 'pages::apps.oidc.create')->name('create');
|
||||||
|
Route::livewire(
|
||||||
|
'{id}/view',
|
||||||
|
'pages::apps.oidc.show'
|
||||||
|
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Read))->name('show');
|
||||||
|
|
||||||
|
});
|
||||||
|
Route::prefix(ConnectionProtocolEnum::URL->value)
|
||||||
|
->name(ConnectionProtocolEnum::URL->value.'.')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::livewire('create', 'pages::apps.url.create')->name('create');
|
||||||
|
Route::livewire('{id}/view', 'pages::apps.url.show')->name('show');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -25,6 +36,7 @@
|
|||||||
'{id}/edit',
|
'{id}/edit',
|
||||||
'pages::apps.edit'
|
'pages::apps.edit'
|
||||||
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit');
|
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit');
|
||||||
|
|
||||||
Route::livewire('/', 'pages::apps.index')->name('index');
|
Route::livewire('/', 'pages::apps.index')->name('index');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
56
tests/Feature/OidcAppCreationTest.php
Normal file
56
tests/Feature/OidcAppCreationTest.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(Database\Seeders\PermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
||||||
|
|
||||||
|
$this->oidcProtocol = ConnectionProtocol::query()->where('name', 'oidc')->firstOrFail();
|
||||||
|
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully creates an OIDC connected app with access URL', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.oidc.create', ['type' => 'web'])
|
||||||
|
->set('form.name', 'My OIDC App')
|
||||||
|
->set('form.accessUrl', 'https://oidc-app.example.com')
|
||||||
|
->set('form.signInUris', ['https://oidc-app.example.com/callback'])
|
||||||
|
->set('form.signOutUris', ['https://oidc-app.example.com/logout'])
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('connected_apps', [
|
||||||
|
'name' => 'My OIDC App',
|
||||||
|
'connection_protocol_id' => $this->oidcProtocol->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app = ConnectedApp::query()->where('name', 'My OIDC App')->firstOrFail();
|
||||||
|
expect($app->settings)->toBeArray()
|
||||||
|
->and(data_get($app->settings, 'access_url'))->toBe('https://oidc-app.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates access URL format when creating an OIDC app', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.oidc.create', ['type' => 'web'])
|
||||||
|
->set('form.name', 'Invalid OIDC App')
|
||||||
|
->set('form.accessUrl', 'not-a-valid-url')
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors(['form.accessUrl' => 'url']);
|
||||||
|
});
|
||||||
162
tests/Feature/OidcAppViewTest.php
Normal file
162
tests/Feature/OidcAppViewTest.php
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(Database\Seeders\PermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
||||||
|
|
||||||
|
$this->oidcProtocol = ConnectionProtocol::query()->where('name', 'oidc')->firstOrFail();
|
||||||
|
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
||||||
|
|
||||||
|
// Create a mock OIDC application in database
|
||||||
|
$this->connectedApp = ConnectedApp::query()->create([
|
||||||
|
'name' => 'Test OIDC App',
|
||||||
|
'slug' => 'test-oidc-app',
|
||||||
|
'connection_protocol_id' => $this->oidcProtocol->id,
|
||||||
|
'connection_provider_id' => 0,
|
||||||
|
'connection_status_id' => $this->status->id,
|
||||||
|
'settings' => [
|
||||||
|
'access_url' => 'https://test-oidc-app.example.com',
|
||||||
|
'signOutUris' => ['https://test-oidc-app.example.com/logout'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create corresponding Passport Client
|
||||||
|
$this->passportClient = Client::query()->create([
|
||||||
|
'id' => (string) Str::uuid(),
|
||||||
|
'name' => 'Test OIDC App',
|
||||||
|
'secret' => bcrypt('old-secret-value'),
|
||||||
|
'provider' => 'users',
|
||||||
|
'redirect_uris' => ['https://test-oidc-app.example.com/callback'],
|
||||||
|
'grant_types' => ['authorization_code'],
|
||||||
|
'revoked' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes only admin users to access the OIDC view page', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('user'); // standard user
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Should fail with 403 Forbidden
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin'); // admin user
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
// Should load successfully
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays client credentials and allows generating a new client secret', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$component = Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->assertSet('appId', $this->connectedApp->id)
|
||||||
|
->assertSet('passportClient.id', $this->passportClient->id);
|
||||||
|
|
||||||
|
// Call generateNewSecret
|
||||||
|
$component->call('generateNewSecret');
|
||||||
|
|
||||||
|
// Get plain secret set in component
|
||||||
|
$newSecretPlain = $component->get('newSecretPlain');
|
||||||
|
expect($newSecretPlain)->toBeString()
|
||||||
|
->and(mb_strlen($newSecretPlain))->toBe(40);
|
||||||
|
|
||||||
|
// Verify it is updated in DB
|
||||||
|
$this->passportClient->refresh();
|
||||||
|
expect(Hash::check($newSecretPlain, $this->passportClient->secret))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating app name and logo', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$logo = Illuminate\Http\UploadedFile::fake()->image('logo.png');
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('openEditGeneralModal')
|
||||||
|
->assertSet('editName', 'Test OIDC App')
|
||||||
|
->set('editName', 'Updated OIDC App Name')
|
||||||
|
->set('editLogo', $logo)
|
||||||
|
->call('saveGeneralInfo')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
// Verify it updated in Database
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect($this->connectedApp->name)->toBe('Updated OIDC App Name')
|
||||||
|
->and($this->connectedApp->logo)->not->toBeNull();
|
||||||
|
|
||||||
|
// Verify Passport client name was updated
|
||||||
|
$this->passportClient->refresh();
|
||||||
|
expect($this->passportClient->name)->toBe('Updated OIDC App Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating connection status via changeStatus', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
// Initial status should be 'connected'
|
||||||
|
expect($this->connectedApp->status->name)->toBe('connected');
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('changeStatus', 'disconnected')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
// Verify status updated in DB
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect($this->connectedApp->status->name)->toBe('disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating redirect URIs', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('startEditRedirects')
|
||||||
|
->assertSet('editSignInUris', ['https://test-oidc-app.example.com/callback'])
|
||||||
|
->assertSet('editSignOutUris', ['https://test-oidc-app.example.com/logout'])
|
||||||
|
// Add a new sign-in URI
|
||||||
|
->call('addRepeaterItem', 'editSignInUris')
|
||||||
|
->set('editSignInUris.1', 'https://test-oidc-app.example.com/callback2')
|
||||||
|
// Remove a sign-out URI
|
||||||
|
->call('removeRepeaterItem', 'editSignOutUris', 0)
|
||||||
|
->call('saveRedirectUris')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
// Verify changes in Passport Client and Connected App settings
|
||||||
|
$this->passportClient->refresh();
|
||||||
|
expect($this->passportClient->redirect_uris)->toBe([
|
||||||
|
'https://test-oidc-app.example.com/callback',
|
||||||
|
'https://test-oidc-app.example.com/callback2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect($this->connectedApp->settings['signOutUris'] ?? [])->toBe([]);
|
||||||
|
});
|
||||||
56
tests/Feature/UrlAppCreationTest.php
Normal file
56
tests/Feature/UrlAppCreationTest.php
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(Database\Seeders\PermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
||||||
|
|
||||||
|
$this->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail();
|
||||||
|
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully creates a URL connected app with access URL and microsoft flag', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.url.create')
|
||||||
|
->set('form.name', 'My URL App')
|
||||||
|
->set('form.accessUrl', 'https://url-app.example.com')
|
||||||
|
->set('form.isConnectedToMicrosoft', true)
|
||||||
|
->call('save');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('connected_apps', [
|
||||||
|
'name' => 'My URL App',
|
||||||
|
'connection_protocol_id' => $this->urlProtocol->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app = ConnectedApp::query()->where('name', 'My URL App')->firstOrFail();
|
||||||
|
expect($app->settings)->toBeArray()
|
||||||
|
->and(data_get($app->settings, 'access_url'))->toBe('https://url-app.example.com')
|
||||||
|
->and(data_get($app->settings, 'is_connected_to_microsoft'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates access URL format when creating a URL app', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.url.create')
|
||||||
|
->set('form.name', 'Invalid URL App')
|
||||||
|
->set('form.accessUrl', 'not-a-valid-url')
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors(['form.accessUrl' => 'url']);
|
||||||
|
});
|
||||||
102
tests/Feature/UrlAppViewTest.php
Normal file
102
tests/Feature/UrlAppViewTest.php
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->seed(Database\Seeders\PermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
||||||
|
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
||||||
|
|
||||||
|
$this->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail();
|
||||||
|
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
||||||
|
|
||||||
|
$this->connectedApp = ConnectedApp::query()->create([
|
||||||
|
'name' => 'Test URL App',
|
||||||
|
'slug' => 'test-url-app',
|
||||||
|
'connection_protocol_id' => $this->urlProtocol->id,
|
||||||
|
'connection_provider_id' => 0,
|
||||||
|
'connection_status_id' => $this->status->id,
|
||||||
|
'settings' => [
|
||||||
|
'access_url' => 'https://test-url-app.example.com',
|
||||||
|
'is_connected_to_microsoft' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes only admin users to view the URL show page', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('user');
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test('pages::apps.url.show', ['id' => $this->connectedApp->id])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
Livewire::actingAs($admin)
|
||||||
|
->test('pages::apps.url.show', ['id' => $this->connectedApp->id])
|
||||||
|
->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating name and logo', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$logo = Illuminate\Http\UploadedFile::fake()->image('logo.png');
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('openEditGeneralModal')
|
||||||
|
->assertSet('editName', 'Test URL App')
|
||||||
|
->set('editName', 'Updated URL App Name')
|
||||||
|
->set('editLogo', $logo)
|
||||||
|
->call('saveGeneralInfo')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect($this->connectedApp->name)->toBe('Updated URL App Name')
|
||||||
|
->and($this->connectedApp->logo)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating access URL in place', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('startEditAccessUrl')
|
||||||
|
->assertSet('editAccessUrl', 'https://test-url-app.example.com')
|
||||||
|
->set('editAccessUrl', 'https://updated-url-app.example.com')
|
||||||
|
->call('saveAccessUrl')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect(data_get($this->connectedApp->settings, 'access_url'))->toBe('https://updated-url-app.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating connection status via changeStatus', function (): void {
|
||||||
|
$admin = User::factory()->create();
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
expect($this->connectedApp->status->name)->toBe('connected');
|
||||||
|
|
||||||
|
Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id])
|
||||||
|
->call('changeStatus', 'disconnected')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->connectedApp->refresh();
|
||||||
|
expect($this->connectedApp->status->name)->toBe('disconnected');
|
||||||
|
});
|
||||||
@ -80,8 +80,8 @@
|
|||||||
expect($activeServices->count())->toBe(1);
|
expect($activeServices->count())->toBe(1);
|
||||||
|
|
||||||
$dto = $activeServices->first();
|
$dto = $activeServices->first();
|
||||||
expect($dto->protocol_name)->toBe('url')
|
expect($dto->protocolName)->toBe('url')
|
||||||
->and($dto->provider_slug)->toBe('azure-fd');
|
->and($dto->providerSlug)->toBe('azure-fd');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void {
|
it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void {
|
||||||
|
|||||||
@ -178,8 +178,8 @@
|
|||||||
name: 'Active SAML App',
|
name: 'Active SAML App',
|
||||||
slug: 'active-saml-app',
|
slug: 'active-saml-app',
|
||||||
duration: '2026-12-31',
|
duration: '2026-12-31',
|
||||||
days_remaining: 300,
|
daysRemaining: 300,
|
||||||
is_warning: false
|
isWarning: false
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user