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 $slug,
|
||||
public string $duration,
|
||||
public int $days_remaining,
|
||||
public bool $is_warning,
|
||||
public ?string $protocol_name = null,
|
||||
public ?string $provider_slug = null,
|
||||
public bool $isUnlimited,
|
||||
public int $daysRemaining,
|
||||
public bool $isWarning,
|
||||
public ?string $protocolName = null,
|
||||
public ?string $providerSlug = null,
|
||||
public ?string $accessUrl = null,
|
||||
public bool $isConnectedToMicrosoft = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
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\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
@ -13,6 +15,10 @@ class EntraController extends Controller
|
||||
{
|
||||
// ── 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 $clientId;
|
||||
@ -21,10 +27,6 @@ class EntraController extends Controller
|
||||
|
||||
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()
|
||||
{
|
||||
$this->tenantId = config('services.microsoft.tenant_id');
|
||||
@ -35,20 +37,13 @@ public function __construct()
|
||||
|
||||
// ── Step 1: Redirect user to Microsoft login ──────────────────────────────
|
||||
|
||||
public function connect(): RedirectResponse
|
||||
public function connect(#[CurrentUser] User $user): 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.');
|
||||
}
|
||||
|
||||
$state = Str::random(40);
|
||||
session(['oauth_state' => $state]);
|
||||
|
||||
@ -62,7 +57,7 @@ public function connect(): RedirectResponse
|
||||
'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 ────────────────────
|
||||
@ -74,8 +69,8 @@ public function callback(Request $request): RedirectResponse
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $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.');
|
||||
@ -92,7 +87,7 @@ public function callback(Request $request): RedirectResponse
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
$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_secret' => $this->clientSecret,
|
||||
@ -135,8 +130,8 @@ public function openEntra(): RedirectResponse
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user);
|
||||
$hasAzureFdAccess = $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.');
|
||||
@ -166,27 +161,6 @@ public function openEntra(): RedirectResponse
|
||||
|
||||
// ── 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
|
||||
{
|
||||
if (! $token->refresh_token) {
|
||||
@ -194,7 +168,7 @@ private function refreshToken(OauthToken $token): bool
|
||||
}
|
||||
|
||||
$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_secret' => $this->clientSecret,
|
||||
@ -218,4 +192,25 @@ private function refreshToken(OauthToken $token): bool
|
||||
|
||||
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 $role_id
|
||||
* @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'])]
|
||||
class AppRole extends Pivot
|
||||
|
||||
@ -32,7 +32,7 @@ public function apps(): BelongsToMany
|
||||
'app_id'
|
||||
)
|
||||
->using(AppRole::class)
|
||||
->withPivot('id', 'duration');
|
||||
->withPivot('id', 'duration', 'is_unlimited');
|
||||
}
|
||||
|
||||
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);
|
||||
$hasAccess = $activeServices->contains(
|
||||
fn ($service) => self::PROVIDER_SLUG === $service->provider_slug
|
||||
fn ($service) => self::PROVIDER_SLUG === $service->providerSlug
|
||||
);
|
||||
|
||||
if (! $hasAccess) {
|
||||
|
||||
@ -51,7 +51,7 @@ public function getActiveServices(User $user): Collection
|
||||
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
|
||||
*/
|
||||
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 */
|
||||
$existing = $appsMap->get($app->id);
|
||||
@ -132,11 +132,13 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str
|
||||
name: $app->name,
|
||||
slug: $app->slug,
|
||||
duration: $durationStr,
|
||||
days_remaining: $daysRemaining,
|
||||
is_warning: $daysRemaining <= 3,
|
||||
protocol_name: $protocolName,
|
||||
provider_slug: $providerSlug,
|
||||
isUnlimited: $isUnlimited,
|
||||
daysRemaining: $daysRemaining,
|
||||
isWarning: $daysRemaining <= 3,
|
||||
protocolName: $protocolName,
|
||||
providerSlug: $providerSlug,
|
||||
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 -->
|
||||
@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>
|
||||
{{-- <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 -->
|
||||
|
||||
<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")) {
|
||||
$this->error(session("entra_error"));
|
||||
}
|
||||
|
||||
if (session("keka_success")) {
|
||||
$this->success(session("keka_success"));
|
||||
}
|
||||
|
||||
if (session("keka_error")) {
|
||||
$this->error(session("keka_error"));
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@ -75,20 +67,6 @@ public function isEntraConnected(): bool
|
||||
|
||||
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
|
||||
:title="$service->name"
|
||||
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>
|
||||
@if($service->is_warning)
|
||||
@if($service->isWarning)
|
||||
<x-mary-badge
|
||||
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"
|
||||
@ -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>
|
||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd')
|
||||
@if($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">Microsoft Integration</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if($this->isEntraConnected)
|
||||
<x-mary-button
|
||||
@ -202,48 +176,7 @@ class="btn-primary flex-1"
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr')
|
||||
<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')
|
||||
@if($service->protocolName === 'oidc' || ( $service->protocolName === 'url' && !$service->isConnectedToMicrosoft))
|
||||
<x-mary-button
|
||||
external
|
||||
: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="flex items-center gap-2">
|
||||
<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
|
||||
class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
|
||||
{{ Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
|
||||
class="text-sm font-medium {{ $service->isWarning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
|
||||
{{ $service->isUnlimited ? 'Unlimited' : Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if($service->is_warning)
|
||||
@if($service->isWarning)
|
||||
<x-mary-button
|
||||
icon="lucide.life-buoy"
|
||||
:link="route('tickets.index', ['raise' => 1, 'app_id' => $service->id])"
|
||||
|
||||
@ -166,7 +166,7 @@ public function eligibleApps(): Collection
|
||||
}
|
||||
|
||||
return $this->appAccessService->getActiveServices($user)
|
||||
->filter(fn($app) => $app->is_warning);
|
||||
->filter(fn($app) => $app->isWarning);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
->group(function (): void {
|
||||
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(
|
||||
|
||||
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);
|
||||
|
||||
$dto = $activeServices->first();
|
||||
expect($dto->protocol_name)->toBe('url')
|
||||
->and($dto->provider_slug)->toBe('azure-fd');
|
||||
expect($dto->protocolName)->toBe('url')
|
||||
->and($dto->providerSlug)->toBe('azure-fd');
|
||||
});
|
||||
|
||||
it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void {
|
||||
|
||||
@ -178,8 +178,8 @@
|
||||
name: 'Active SAML App',
|
||||
slug: 'active-saml-app',
|
||||
duration: '2026-12-31',
|
||||
days_remaining: 300,
|
||||
is_warning: false
|
||||
daysRemaining: 300,
|
||||
isWarning: false
|
||||
),
|
||||
]));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user