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:
= 2026-06-22 07:13:29 +00:00
parent 7f02209631
commit feaa8b6c93
17 changed files with 474 additions and 162 deletions

View 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'] ?? [],
);
}
}

View File

@ -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,
) {} ) {}
} }

View File

@ -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.');
}
} }

View 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',
];
}
}

View File

@ -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

View File

@ -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

View 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;
}
}
}

View File

@ -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) {

View File

@ -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),
)); ));
} }
} }

View File

@ -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">

View 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>

View File

@ -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])"

View File

@ -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);
} }
/** /**

View File

@ -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(

View 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']);
});

View File

@ -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 {

View File

@ -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
), ),
])); ]));