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

View File

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

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

View File

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

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);
$hasAccess = $activeServices->contains(
fn ($service) => self::PROVIDER_SLUG === $service->provider_slug
fn ($service) => self::PROVIDER_SLUG === $service->providerSlug
);
if (! $hasAccess) {

View File

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

View File

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

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")) {
$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])"

View File

@ -166,7 +166,7 @@ public function eligibleApps(): Collection
}
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 {
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(

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);
$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 {

View File

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