Feat: Add modular URL app creation with role-based access and Microsoft integration support
- Introduced `StoreURLAppForm` and `StoreURLAppData` for form handling and data transformation. - Added `UrlAppService` to streamline URL app creation with settings like `accessUrl` and `isConnectedToMicrosoft`. - Enhanced user access controls with role-based permissions using `UserAccessTypeEnum`. - Updated UI for URL app creation with support for logos and dynamic role selection. - Implemented feature tests to validate URL app creation and access URL formatting.
This commit is contained in:
parent
7f02209631
commit
feaa8b6c93
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,10 +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 ?string $accessUrl = null,
|
||||||
|
public bool $isConnectedToMicrosoft = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\OauthToken;
|
use App\Models\{OauthToken, User};
|
||||||
|
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;
|
||||||
@ -13,6 +15,10 @@ class EntraController extends Controller
|
|||||||
{
|
{
|
||||||
// ── OAuth config from .env ────────────────────────────────────────────────
|
// ── OAuth config from .env ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
@ -21,10 +27,6 @@ class EntraController extends Controller
|
|||||||
|
|
||||||
private string $redirectUri;
|
private string $redirectUri;
|
||||||
|
|
||||||
private const PROVIDER = 'microsoft';
|
|
||||||
|
|
||||||
private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send';
|
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tenantId = config('services.microsoft.tenant_id');
|
$this->tenantId = config('services.microsoft.tenant_id');
|
||||||
@ -35,20 +37,13 @@ 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();
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
abort(403, 'You do not have permission to access the required application.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = Str::random(40);
|
$state = Str::random(40);
|
||||||
session(['oauth_state' => $state]);
|
session(['oauth_state' => $state]);
|
||||||
|
|
||||||
@ -62,7 +57,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 ────────────────────
|
||||||
@ -74,8 +69,8 @@ public function callback(Request $request): RedirectResponse
|
|||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
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.');
|
||||||
@ -92,7 +87,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,
|
||||||
@ -135,8 +130,8 @@ public function openEntra(): RedirectResponse
|
|||||||
abort(403, 'Unauthorized');
|
abort(403, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
$activeServices = app(UserAppAccessService::class)->getActiveServices($user);
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug);
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
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.');
|
||||||
@ -166,27 +161,6 @@ public function openEntra(): RedirectResponse
|
|||||||
|
|
||||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function disconnect(): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
if (! $user) {
|
|
||||||
abort(403, 'Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
|
||||||
$hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug);
|
|
||||||
|
|
||||||
if (! $hasAzureFdAccess) {
|
|
||||||
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: Refresh access token using refresh_token ───────────────────
|
|
||||||
|
|
||||||
private function refreshToken(OauthToken $token): bool
|
private function refreshToken(OauthToken $token): bool
|
||||||
{
|
{
|
||||||
if (! $token->refresh_token) {
|
if (! $token->refresh_token) {
|
||||||
@ -194,7 +168,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,
|
||||||
@ -218,4 +192,25 @@ private function refreshToken(OauthToken $token): bool
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Internal: Refresh access token using refresh_token ───────────────────
|
||||||
|
|
||||||
|
public function disconnect(): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user) {
|
||||||
|
abort(403, 'Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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,11 +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'),
|
accessUrl: data_get($app, 'settings.access_url'),
|
||||||
|
isConnectedToMicrosoft: data_get($app, 'settings.is_connected_to_microsoft', false),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -243,36 +243,36 @@ class="btn-primary"
|
|||||||
<!-- Sign-out uris end -->
|
<!-- Sign-out uris end -->
|
||||||
@endif
|
@endif
|
||||||
<!-- Access start -->
|
<!-- Access 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">User Access</h2>
|
{{-- <h2 class="font-bold">User Access</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">--}}
|
||||||
Choose whether to allow all users be able to connect this app or selected ones.
|
{{-- Choose whether to allow all users be able to connect this app or selected ones.--}}
|
||||||
</p>
|
{{-- </p>--}}
|
||||||
</article>
|
{{-- </article>--}}
|
||||||
<div class="w-full md:w-3/5">
|
{{-- <div class="w-full md:w-3/5">--}}
|
||||||
<x-mary-radio
|
{{-- <x-mary-radio--}}
|
||||||
:options="UserAccessTypeEnum::asOptions()"
|
{{-- :options="UserAccessTypeEnum::asOptions()"--}}
|
||||||
option-value="value"
|
{{-- option-value="value"--}}
|
||||||
wire:model.change.live="form.userAccessOption"
|
{{-- wire:model.change.live="form.userAccessOption"--}}
|
||||||
/>
|
{{-- />--}}
|
||||||
<div class=""
|
{{-- <div class=""--}}
|
||||||
x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"
|
{{-- x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"--}}
|
||||||
x-cloak
|
{{-- x-cloak--}}
|
||||||
>
|
{{-- >--}}
|
||||||
<x-shared.choices
|
{{-- <x-shared.choices--}}
|
||||||
wire:model.live="form.roleIds"
|
{{-- wire:model.live="form.roleIds"--}}
|
||||||
:options="$choicesSearchable['roles'] ?? []"
|
{{-- :options="$choicesSearchable['roles'] ?? []"--}}
|
||||||
search-function="searchRoles"
|
{{-- search-function="searchRoles"--}}
|
||||||
select-all-action="selectAllRoles"
|
{{-- select-all-action="selectAllRoles"--}}
|
||||||
clear-action="clearRoles"
|
{{-- clear-action="clearRoles"--}}
|
||||||
label="Roles"
|
{{-- label="Roles"--}}
|
||||||
/>
|
{{-- />--}}
|
||||||
</div>
|
{{-- </div>--}}
|
||||||
</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">
|
||||||
|
|||||||
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>
|
||||||
@ -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,48 +176,7 @@ 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
|
|
||||||
class="flex flex-col gap-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs font-semibold text-gray-700">Keka Integration</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
|
||||||
|
|
||||||
@if($service->protocol_name === 'oidc')
|
|
||||||
<x-mary-button
|
<x-mary-button
|
||||||
external
|
external
|
||||||
:link="$service->accessUrl"
|
:link="$service->accessUrl"
|
||||||
@ -256,15 +189,15 @@ class="btn-primary w-full"
|
|||||||
<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])"
|
||||||
|
|||||||
@ -166,7 +166,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,6 +19,11 @@
|
|||||||
->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::prefix(ConnectionProtocolEnum::URL->value)
|
||||||
|
->name(ConnectionProtocolEnum::URL->value.'.')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::livewire('create', 'pages::apps.url.create')->name('create');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::livewire(
|
Route::livewire(
|
||||||
|
|||||||
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']);
|
||||||
|
});
|
||||||
@ -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