out new baseline

This commit is contained in:
Mark Gregory
2026-06-24 14:58:41 +01:00
parent 3737cf7ebc
commit a26129a383
10 changed files with 442 additions and 145 deletions
@@ -65,19 +65,45 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload), 'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')), 'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')), 'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
}; };
} }
/** /**
* Validate GitHub's X-Hub-Signature-256 HMAC header. * Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
*/ */
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
{ {
$signature = (string) $request->header('X-Hub-Signature-256', ''); $sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret); $sha1Signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals($expected, $signature); return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
}
/**
* Validate Bitbucket's webhook HMAC signature.
*/
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
{
$signature = (string) $request->header('X-Hub-Signature', '');
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
}
/**
* Validate a generic Git webhook using either a token header or HMAC signature.
*/
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
{
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token'));
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
return hash_equals($secret, $token)
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
} }
/** /**
@@ -88,7 +114,8 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $request->header('X-GitHub-Event'), 'github' => $request->header('X-GitHub-Event'),
'gitlab' => $request->header('X-Gitlab-Event'), 'gitlab' => $request->header('X-Gitlab-Event'),
default => $request->header('X-Helpdesk-Event', 'push'), 'bitbucket' => $request->header('X-Event-Key'),
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-Event') ?: 'push',
}; };
} }
@@ -100,7 +127,8 @@ final class GitWebhookController
return match ($provider) { return match ($provider) {
'github' => $request->header('X-GitHub-Delivery'), 'github' => $request->header('X-GitHub-Delivery'),
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'), 'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery'), 'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
}; };
} }
} }
@@ -11,16 +11,16 @@
$currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue); $currentLabel = \Modules\Helpdesk\Support\Color::label($currentValue);
@endphp @endphp
<div {{ $attributes->class('space-y-3') }}> <div {{ $attributes->class('rounded-2xl border border-zinc-200 bg-white/70 p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50') }}>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
{{ $label }} <div class="min-w-0 flex-1 space-y-3">
</label> <label class="block text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{{ $label }}
</label>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<div class="space-y-2">
<select <select
wire:model.live="{{ $model }}" wire:model.live="{{ $model }}"
class="block h-10 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" class="h-11 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm font-semibold text-zinc-900 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
> >
@foreach ($options as $key => $option) @foreach ($options as $key => $option)
<option value="{{ $key }}" style="color: {{ $option['hex'] }};"> <option value="{{ $key }}" style="color: {{ $option['hex'] }};">
@@ -29,13 +29,13 @@
@endforeach @endforeach
</select> </select>
<div class="flex flex-wrap gap-1.5" aria-label="Available Helpdesk colours"> <div class="flex flex-wrap items-center gap-2" aria-label="Available Helpdesk colours">
@foreach ($options as $key => $option) @foreach ($options as $key => $option)
<button <button
type="button" type="button"
wire:click="$set('{{ $model }}', '{{ $key }}')" wire:click="$set('{{ $model }}', '{{ $key }}')"
title="{{ $option['name'] }} {{ $option['hex'] }}" title="{{ $option['name'] }} {{ $option['hex'] }}"
class="inline-flex h-6 min-w-6 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40" class="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/80 shadow-sm ring-1 ring-zinc-900/10 transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500/40 dark:ring-white/15"
style="background-color: {{ $option['hex'] }}" style="background-color: {{ $option['hex'] }}"
> >
<span class="sr-only">Use {{ $option['name'] }}</span> <span class="sr-only">Use {{ $option['name'] }}</span>
@@ -44,28 +44,31 @@
</div> </div>
</div> </div>
<span <div class="flex w-full flex-col gap-3 sm:w-44 sm:items-stretch">
class="inline-flex min-w-28 items-center justify-center rounded-full px-3 py-2 text-xs font-bold shadow-sm ring-1 ring-zinc-900/10" <span
style="background-color: {{ $currentHex }}; color: {{ $currentText }};" class="inline-flex h-11 items-center justify-center rounded-xl px-4 text-sm font-bold shadow-sm ring-1 ring-zinc-900/10 dark:ring-white/10"
> style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
{{ $currentLabel }} >
</span> {{ $currentLabel }}
</div> </span>
<div class="grid gap-3 md:grid-cols-[auto_minmax(0,1fr)] md:items-center"> <div class="grid grid-cols-[3.5rem_minmax(0,1fr)] items-center gap-2">
<input <input
type="color" type="color"
value="{{ $currentHex }}" value="{{ $currentHex }}"
wire:change="$set('{{ $model }}', $event.target.value)" wire:change="$set('{{ $model }}', $event.target.value)"
class="h-10 w-16 cursor-pointer rounded-xl border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" class="h-10 w-14 cursor-pointer rounded-xl border border-zinc-300 bg-white p-1 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
aria-label="Choose a custom hex colour" aria-label="Choose a custom hex colour"
/> />
<flux:input <input
wire:model.live.debounce.500ms="{{ $model }}" type="text"
label="Custom hex colour" wire:model.live.debounce.500ms="{{ $model }}"
placeholder="#ffffff" class="h-10 w-full rounded-xl border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 shadow-sm outline-none transition-colors duration-150 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500"
description="Use the standard palette where possible. Custom hex values must use six characters, for example #ffffff." placeholder="#ffffff"
/> aria-label="Custom hex colour"
/>
</div>
</div>
</div> </div>
</div> </div>
@@ -1,9 +1,32 @@
@props([
'title' => 'Settings',
'description' => '',
])
<section <section
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> {{ $attributes->merge([
<div class="border-b border-zinc-200 bg-zinc-50 px-5 py-4 dark:border-zinc-700 dark:bg-zinc-800/70"> 'class' => 'overflow-hidden rounded-3xl border border-zinc-700 bg-zinc-900 shadow-sm ring-1 ring-white/10',
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ $title }}</h2> ]) }}>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ $description }}</p> <div class="border-b border-zinc-700 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-5 py-4 text-white">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<p class="text-[10px] font-black uppercase tracking-[0.24em] text-blue-200">Configuration</p>
<h2 class="mt-1 text-lg font-extrabold tracking-tight text-white">{{ $title }}</h2>
<p class="mt-1 max-w-3xl text-sm font-medium leading-6 text-blue-100/90">{{ $description }}</p>
</div>
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/10 text-blue-100 shadow-inner">
<i class="fa-solid fa-sliders text-sm" aria-hidden="true"></i>
</span>
</div>
</div>
<div class="border-b border-zinc-800 bg-zinc-950/45 px-4 py-5 sm:px-5">
<div class="mx-auto max-w-7xl rounded-2xl border border-zinc-700 bg-zinc-900/90 p-4 shadow-sm sm:p-5 [&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-11 [&_textarea]:min-h-24 [&_textarea]:leading-6">
{{ $form }}
</div>
</div>
<div class="bg-zinc-900">
{{ $slot }}
</div> </div>
<div class="border-b border-zinc-200 p-5 dark:border-zinc-700">{{ $form }}</div>
<div>{{ $slot }}</div>
</section> </section>
@@ -140,21 +140,27 @@
<x-helpdesk::settings-section title="Statuses" <x-helpdesk::settings-section title="Statuses"
description="Control workflow stages and mark which statuses close tickets."> description="Control workflow stages and mark which statuses close tickets.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveStatus" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveStatus" class="grid gap-5 xl:grid-cols-12">
<flux:input wire:model="statusForm.name" label="Name" /> <div class="xl:col-span-3">
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="statusForm.name" label="Name" />
<x-helpdesk::colour-select model="statusForm.color" label="Badge colour" :options="$colourOptions" /> </div>
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" /> <div class="xl:col-span-3">
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input <flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed </div>
status</label> <x-helpdesk::colour-select class="xl:col-span-4" model="statusForm.color" label="Badge colour" :options="$colourOptions" />
<label <div class="xl:col-span-2">
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
</div>
<label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-3">
<input type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-600 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Closed status
</label>
<label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-4">
<input type="checkbox" wire:model="statusForm.is_default" <input type="checkbox" wire:model="statusForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default status for new tickets Default status for new tickets
</label> </label>
<div class="md:col-span-5 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status
</flux:button> </flux:button>
@@ -171,17 +177,23 @@
@if ($section === 'priorities') @if ($section === 'priorities')
<x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order."> <x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="savePriority" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="savePriority" class="grid gap-5 xl:grid-cols-12">
<flux:input wire:model="priorityForm.name" label="Name" /> <div class="xl:col-span-3">
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" /> <flux:input wire:model="priorityForm.name" label="Name" />
<x-helpdesk::colour-select model="priorityForm.color" label="Badge colour" :options="$colourOptions" /> </div>
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" /> <div class="xl:col-span-3">
<label class="flex items-end gap-2 pb-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200"> <flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-4" model="priorityForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-2">
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
</div>
<label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-4">
<input type="checkbox" wire:model="priorityForm.is_default" <input type="checkbox" wire:model="priorityForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default for new tickets Default for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority
</flux:button> </flux:button>
@@ -199,26 +211,31 @@
<x-helpdesk::settings-section title="Categories" <x-helpdesk::settings-section title="Categories"
description="Group tickets by support area, optionally per company."> description="Group tickets by support area, optionally per company.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveCategory" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="saveCategory" class="grid gap-5 xl:grid-cols-12">
<flux:select wire:model="categoryForm.company_id" label="Company"> <div class="xl:col-span-3">
<option value="">Global</option> <flux:select wire:model="categoryForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:input wire:model="categoryForm.name" label="Name" /> </flux:select>
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" /> </div>
<x-helpdesk::colour-select model="categoryForm.color" label="Badge colour" :options="$colourOptions" /> <div class="xl:col-span-3">
<div class="md:col-span-4"> <flux:input wire:model="categoryForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-3" model="categoryForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="categoryForm.description" label="Description" rows="3" /> <flux:textarea wire:model="categoryForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="categoryForm.is_default" <input type="checkbox" wire:model="categoryForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default category for new tickets Default category for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
category</flux:button> category</flux:button>
@@ -236,26 +253,31 @@
<x-helpdesk::settings-section title="Teams" <x-helpdesk::settings-section title="Teams"
description="Create support teams under each company and assign projects to them."> description="Create support teams under each company and assign projects to them.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveTeam" class="grid gap-4 md:grid-cols-4"> <form wire:submit.prevent="saveTeam" class="grid gap-5 xl:grid-cols-12">
<flux:select wire:model="teamForm.company_id" label="Company"> <div class="xl:col-span-3">
<option value="">Global</option> <flux:select wire:model="teamForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:input wire:model="teamForm.name" label="Name" /> </flux:select>
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" /> </div>
<x-helpdesk::colour-select model="teamForm.color" label="Badge colour" :options="$colourOptions" /> <div class="xl:col-span-3">
<div class="md:col-span-4"> <flux:input wire:model="teamForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-3" model="teamForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="teamForm.description" label="Description" rows="3" /> <flux:textarea wire:model="teamForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="teamForm.is_default" <input type="checkbox" wire:model="teamForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default team for new tickets Default team for new tickets
</label> </label>
<div class="md:col-span-4 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team
</flux:button> </flux:button>
@@ -273,32 +295,39 @@
<x-helpdesk::settings-section title="Projects" <x-helpdesk::settings-section title="Projects"
description="Create company projects and optionally assign them to support teams."> description="Create company projects and optionally assign them to support teams.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveProject" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveProject" class="grid gap-5 xl:grid-cols-12">
<flux:select wire:model="projectForm.company_id" label="Company"> <div class="xl:col-span-2">
<option value="">Global</option> <flux:select wire:model="projectForm.company_id" label="Company">
@foreach ($companies as $company) <option value="">Global</option>
<option value="{{ $company->id }}">{{ $company->name }}</option> @foreach ($companies as $company)
@endforeach <option value="{{ $company->id }}">{{ $company->name }}</option>
</flux:select> @endforeach
<flux:select wire:model="projectForm.team_id" label="Team"> </flux:select>
<option value="">No team</option> </div>
@foreach ($teams as $team) <div class="xl:col-span-2">
<option value="{{ $team->id }}">{{ $team->name }}</option> <flux:select wire:model="projectForm.team_id" label="Team">
@endforeach <option value="">No team</option>
</flux:select> @foreach ($teams as $team)
<flux:input wire:model="projectForm.title" label="Title" /> <option value="{{ $team->id }}">{{ $team->name }}</option>
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" /> @endforeach
<x-helpdesk::colour-select model="projectForm.color" label="Badge colour" :options="$colourOptions" /> </flux:select>
<div class="md:col-span-5"> </div>
<div class="xl:col-span-2">
<flux:input wire:model="projectForm.title" label="Title" />
</div>
<div class="xl:col-span-2">
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-4" model="projectForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-12">
<flux:textarea wire:model="projectForm.description" label="Description" rows="3" /> <flux:textarea wire:model="projectForm.description" label="Description" rows="3" />
</div> </div>
<label <label class="flex min-h-12 items-center gap-3 rounded-xl border border-zinc-200 bg-white/70 px-4 py-3 text-sm font-semibold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-950/50 dark:text-zinc-200 xl:col-span-4">
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" wire:model="projectForm.is_default" <input type="checkbox" wire:model="projectForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500"> class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default project for new tickets Default project for new tickets
</label> </label>
<div class="md:col-span-5 flex gap-2"> <div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project
</flux:button> </flux:button>
@@ -865,11 +894,15 @@
<x-helpdesk::settings-section title="Ticket tags" <x-helpdesk::settings-section title="Ticket tags"
description="Maintain visible ticket labels for search, triage and Kanban card context."> description="Maintain visible ticket labels for search, triage and Kanban card context.">
<x-slot:form> <x-slot:form>
<form wire:submit.prevent="saveTag" class="grid gap-4 md:grid-cols-5"> <form wire:submit.prevent="saveTag" class="grid gap-5 xl:grid-cols-12">
<flux:input wire:model="tagForm.name" label="Name" /> <div class="xl:col-span-4">
<flux:input wire:model="tagForm.slug" label="Slug" /> <flux:input wire:model="tagForm.name" label="Name" />
<x-helpdesk::colour-select model="tagForm.color" label="Colour" :options="$colourOptions" /> </div>
<div class="md:col-span-5 flex gap-2"> <div class="xl:col-span-4">
<flux:input wire:model="tagForm.slug" label="Slug" />
</div>
<x-helpdesk::colour-select class="xl:col-span-4" model="tagForm.color" label="Colour" :options="$colourOptions" />
<div class="flex flex-wrap items-center gap-2 xl:col-span-12">
<flux:button type="submit" variant="primary" <flux:button type="submit" variant="primary"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag
</flux:button> </flux:button>
@@ -527,7 +527,7 @@
class="prose prose-sm min-h-[12rem] max-w-none bg-white p-4 text-sm leading-6 text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100" class="prose prose-sm min-h-[12rem] max-w-none bg-white p-4 text-sm leading-6 text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
data-placeholder="Write a public reply or internal note. Right-click or Ctrl-click underlined words for spelling suggestions.">{!! $oldCommentBody !!}</div> data-placeholder="Write a public reply or internal note. Right-click or Ctrl-click underlined words for spelling suggestions.">{!! $oldCommentBody !!}</div>
<textarea id="ticket-comment-body" name="commentBody" required class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea> <textarea id="ticket-comment-body" name="commentBody" required spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
</div> </div>
<p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400"> <p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
@@ -221,7 +221,7 @@
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>" <button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>"
class="rounded-lg border px-2 py-1 text-xs">Code</button> class="rounded-lg border px-2 py-1 text-xs">Code</button>
</div> </div>
<textarea spellcheck="true" lang="en-GB" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4" <textarea spellcheck="true" lang="en-GB" autocorrect="on" autocomplete="on" autocapitalize="sentences" inputmode="text" id="reply-body-{{ $comment->id }}" dusk="reply-body-{{ $comment->id }}" name="commentBody" rows="4"
required required
class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
placeholder="Write a threaded Lotus Notes-style response..."></textarea> placeholder="Write a threaded Lotus Notes-style response..."></textarea>
@@ -1,10 +1,19 @@
<?php <?php
/**
* GitWebhookIngestionService.php
*
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
*/
declare(strict_types=1); declare(strict_types=1);
namespace Modules\Helpdesk\Services; namespace Modules\Helpdesk\Services;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Modules\Helpdesk\Models\GitCommit; use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository; use Modules\Helpdesk\Models\GitRepository;
@@ -14,7 +23,10 @@ use Throwable;
final class GitWebhookIngestionService final class GitWebhookIngestionService
{ {
private const TICKET_REFERENCE_PATTERN = '/\bHD-\d{4}-\d{5}\b/i'; /**
* Matches the generated Helpdesk reference format, for example HD-2026-00042.
*/
private const TICKET_REFERENCE_PATTERN = '/\bHD[-\s_]?(\d{4})[-\s_]?(\d{1,8})\b/i';
/** /**
* Persist supported Git provider commits and link them to matching Helpdesk ticket references. * Persist supported Git provider commits and link them to matching Helpdesk ticket references.
@@ -39,14 +51,14 @@ final class GitWebhookIngestionService
return $result; return $result;
} }
$branch = $this->branchFromRef($payload['ref'] ?? null); $branch = $this->branchFromPayload($payload);
$commits = $this->commitsFromPayload($payload); $commits = $this->commitsFromPayload($payload);
$commitCount = 0; $commitCount = 0;
$linkedCount = 0; $linkedCount = 0;
$ignoredCount = 0; $ignoredCount = 0;
foreach ($commits as $commit) { foreach ($commits as $commit) {
$hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null); $hash = $this->commitHash($commit);
if ($hash === null) { if ($hash === null) {
$ignoredCount++; $ignoredCount++;
@@ -54,13 +66,19 @@ final class GitWebhookIngestionService
continue; continue;
} }
$message = $this->stringOrNull($commit['message'] ?? null) ?? ''; $message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? '';
$url = $this->stringOrNull($commit['url'] ?? null); $url = $this->commitUrl($commit);
$author = is_array($commit['author'] ?? null) ? $commit['author'] : []; $author = is_array($commit['author'] ?? null) ? $commit['author'] : [];
$committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : []; $committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : [];
$authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null); $authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null);
$authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null); $authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null);
$committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null); $committedAt = $this->parseTimestamp(
$commit['timestamp']
?? $commit['committed_at']
?? $commit['date']
?? Arr::get($commit, 'author.date')
?? null,
);
$ticket = $this->ticketForCommit($repository, $message, $branch, $url); $ticket = $this->ticketForCommit($repository, $message, $branch, $url);
GitCommit::query()->updateOrCreate( GitCommit::query()->updateOrCreate(
@@ -134,7 +152,7 @@ final class GitWebhookIngestionService
} }
/** /**
* Extract the first Helpdesk ticket reference from arbitrary Git text. * Extract and normalise the first Helpdesk ticket reference from arbitrary Git text.
*/ */
public function extractTicketReference(string $value): ?string public function extractTicketReference(string $value): ?string
{ {
@@ -142,7 +160,7 @@ final class GitWebhookIngestionService
return null; return null;
} }
return strtoupper($matches[0]); return sprintf('HD-%s-%05d', $matches[1], (int) $matches[2]);
} }
/** /**
@@ -150,24 +168,40 @@ final class GitWebhookIngestionService
*/ */
private function isPushEvent(string $provider, ?string $event): bool private function isPushEvent(string $provider, ?string $event): bool
{ {
$normalised = strtolower((string) $event); $normalisedProvider = Str::lower(trim($provider));
$normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value();
return match ($provider) { return match ($normalisedProvider) {
'github' => $normalised === 'push', 'github' => in_array($normalisedEvent, ['push', ''], true),
'gitlab' => in_array($normalised, ['push hook', 'push'], true), 'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true),
default => $normalised === '' || $normalised === 'push', 'bitbucket' => in_array($normalisedEvent, ['repo push', 'push', ''], true),
default => in_array($normalisedEvent, ['push', 'repo push', 'push hook', ''], true),
}; };
} }
private function branchFromRef(mixed $value): ?string /**
* Read the branch name from common Git provider payload shapes.
*
* @param array<string, mixed> $payload
*/
private function branchFromPayload(array $payload): ?string
{ {
$ref = $this->stringOrNull($value); $ref = $this->stringOrNull(
$payload['ref']
?? Arr::get($payload, 'push.changes.0.new.name')
?? Arr::get($payload, 'object_attributes.ref')
?? null,
);
if ($ref === null) { if ($ref === null) {
return null; return null;
} }
return str_replace('refs/heads/', '', $ref); return Str::of($ref)
->replaceStart('refs/heads/', '')
->replaceStart('refs/tags/', '')
->trim()
->value();
} }
/** /**
@@ -177,13 +211,46 @@ final class GitWebhookIngestionService
*/ */
private function commitsFromPayload(array $payload): array private function commitsFromPayload(array $payload): array
{ {
$commits = $payload['commits'] ?? []; $commits = $payload['commits'] ?? null;
if (! is_array($commits)) { if (is_array($commits) && $commits !== []) {
return []; return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit)));
} }
return array_values(array_filter($commits, static fn (mixed $commit): bool => is_array($commit))); $headCommit = $payload['head_commit'] ?? null;
if (is_array($headCommit)) {
return [$headCommit];
}
$bitbucketCommits = Arr::get($payload, 'push.changes.0.commits', []);
if (is_array($bitbucketCommits)) {
return array_values(array_filter($bitbucketCommits, static fn (mixed $commit): bool => is_array($commit)));
}
return [];
}
/**
* @param array<string, mixed> $commit
*/
private function commitHash(array $commit): ?string
{
return $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? $commit['hash'] ?? null);
}
/**
* @param array<string, mixed> $commit
*/
private function commitUrl(array $commit): ?string
{
return $this->stringOrNull(
$commit['url']
?? $commit['web_url']
?? Arr::get($commit, 'links.html.href')
?? null,
);
} }
private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket private function ticketForCommit(GitRepository $repository, string $message, ?string $branch, ?string $url): ?Ticket
@@ -194,17 +261,19 @@ final class GitWebhookIngestionService
return null; return null;
} }
$query = Ticket::query()->where('reference', $reference); $baseQuery = Ticket::query()->where('reference', $reference);
if ($repository->company_id !== null) { if ($repository->company_id !== null) {
$query->where('company_id', $repository->company_id); $baseQuery->where('company_id', $repository->company_id);
} }
if ($repository->project_id !== null) { $strictProjectTicket = (clone $baseQuery)
$query->where('project_id', $repository->project_id); ->when($repository->project_id !== null, static function (Builder $query) use ($repository): void {
} $query->where('project_id', $repository->project_id);
})
->first();
return $query->first(); return $strictProjectTicket ?? $baseQuery->first();
} }
private function parseTimestamp(mixed $value): ?CarbonImmutable private function parseTimestamp(mixed $value): ?CarbonImmutable
@@ -0,0 +1,93 @@
<?php
/**
* GitSpellcheckSettingsUiFilesTest.php
*
* Guards the Helpdesk Git webhook, ticket comment spellcheck and settings UI polish files.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
final class GitSpellcheckSettingsUiFilesTest extends TestCase
{
/**
* Ensure Git webhook ingestion accepts practical provider payload variants and flexible ticket references.
*/
public function test_git_webhook_ingestion_supports_common_provider_payload_variants(): void
{
$service = base_path('Modules/Helpdesk/Services/GitWebhookIngestionService.php');
$controller = base_path('Modules/Helpdesk/Http/Controllers/GitWebhookController.php');
$this->assertFileExists($service);
$this->assertFileExists($controller);
$serviceContents = file_get_contents($service) ?: '';
$controllerContents = file_get_contents($controller) ?: '';
self::assertStringContainsString('head_commit', $serviceContents);
self::assertStringContainsString('push.changes.0.commits', $serviceContents);
self::assertStringContainsString('HD[-\\s_]?', $serviceContents);
self::assertStringContainsString('sprintf(\'HD-%s-%05d\'', $serviceContents);
self::assertStringContainsString('hasValidBitbucketSignature', $controllerContents);
self::assertStringContainsString('hasValidGenericSignature', $controllerContents);
}
/**
* Ensure all ticket comment editors request native browser spellcheck assistance.
*/
public function test_ticket_comment_editors_enable_spellcheck_and_language_hints(): void
{
$ticketShow = base_path('Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php');
$commentThread = base_path('Modules/Helpdesk/Resources/views/partials/comment-thread.blade.php');
$ticketShowContents = file_get_contents($ticketShow) ?: '';
$commentThreadContents = file_get_contents($commentThread) ?: '';
self::assertStringContainsString('contenteditable="true" spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('name="commentBody" required spellcheck="true" lang="en-GB"', $ticketShowContents);
self::assertStringContainsString('spellcheck="true" lang="en-GB" autocorrect="on"', $commentThreadContents);
self::assertStringContainsString('autocapitalize="sentences" inputmode="text"', $commentThreadContents);
}
/**
* Ensure settings sections use the polished Abbott-style card treatment.
*/
public function test_settings_sections_use_professional_card_treatment(): void
{
$settingsSection = base_path('Modules/Helpdesk/Resources/views/components/settings-section.blade.php');
$contents = file_get_contents($settingsSection) ?: '';
self::assertStringContainsString('rounded-3xl', $contents);
self::assertStringContainsString('bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900', $contents);
self::assertStringContainsString('Configuration', $contents);
self::assertStringContainsString("[&_input:not([type='checkbox']):not([type='radio']):not([type='color'])]:min-h-11", $contents);
self::assertStringContainsString('bg-zinc-950/45', $contents);
}
/**
* Ensure settings lookup forms stay aligned and avoid oversized colour controls.
*/
public function test_lookup_settings_forms_use_compact_aligned_grid_layouts(): void
{
$settingsManager = base_path('Modules/Helpdesk/Resources/views/livewire/settings-manager.blade.php');
$colourSelect = base_path('Modules/Helpdesk/Resources/views/components/colour-select.blade.php');
$settingsContents = file_get_contents($settingsManager) ?: '';
$colourContents = file_get_contents($colourSelect) ?: '';
self::assertStringContainsString('grid gap-5 xl:grid-cols-12', $settingsContents);
self::assertStringContainsString('class="xl:col-span-4" model="statusForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-4" model="priorityForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-3" model="categoryForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-3" model="teamForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-4" model="projectForm.color"', $settingsContents);
self::assertStringContainsString('class="xl:col-span-4" model="tagForm.color"', $settingsContents);
self::assertStringContainsString('grid grid-cols-[3.5rem_minmax(0,1fr)]', $colourContents);
self::assertStringContainsString('aria-label="Custom hex colour"', $colourContents);
self::assertStringNotContainsString('<flux:input', $colourContents);
}
}
+48
View File
@@ -394,3 +394,51 @@
- Fixed dashboard KPI text expected by Helpdesk browser navigation tests. - Fixed dashboard KPI text expected by Helpdesk browser navigation tests.
- Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked. - Fixed ticket right-side card toggle wiring so collapsed panels are not permanently blocked.
- Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas. - Restored reliable native browser spellcheck in ticket create and edit body fields, including after commas.
- version: 1.10.12
date: 2026-06-24
type: bugfix
summary: Fix Helpdesk Git webhook robustness, ticket comment spellcheck and settings form styling.
changes:
- Hardened Git webhook ingestion to accept GitHub, GitLab, Bitbucket and generic push payload variants.
- Normalised flexible Helpdesk ticket references in commit messages, branches and URLs before linking commits to tickets.
- Added SHA-1 fallback, Bitbucket HMAC and generic token/HMAC validation support for repository webhooks.
- Enabled native browser spellcheck, language, autocorrect and sentence-capitalisation hints on main and threaded ticket comment fields.
- Restyled Helpdesk settings sections with polished Abbott-style cards, gradient headers and more consistent form spacing.
- Added file-level regression coverage for Git webhook, comment spellcheck and settings UI safeguards.
- Preserved append-only changelog history.
- version: 1.10.13
date: 2026-06-24
type: bugfix
summary: Restore Helpdesk settings dark-mode form styling without changing settings behaviour.
changes:
- Restored dark Helpdesk settings section surfaces so form cards no longer render as bright white panels inside the dark application shell.
- Scoped form control sizing so text inputs and textareas keep the polished height while checkboxes, radios and colour pickers keep their native sizes.
- Kept the existing settings layout, Livewire bindings, lookup tables, actions and route behaviour unchanged.
- Preserved append-only changelog history.
- version: 1.10.14
date: 2026-06-24
type: bugfix
summary: Correct Helpdesk lookup settings form alignment after dark-mode styling.
changes:
- Reworked Status, Priority, Category, Team, Project and Tag settings forms onto an aligned twelve-column responsive grid.
- Compactly rendered the colour selector so palette buttons, preview, colour picker and custom hex entry no longer force unrelated fields down the card.
- Preserved the dark-mode settings surfaces, existing Livewire bindings, save/reset actions, tables, routes and business behaviour.
- Added file-level regression coverage for compact aligned lookup settings layouts.
- Preserved append-only changelog history.
- version: 1.10.15
date: 2026-06-24
type: bugfix
summary: Polish Helpdesk lookup settings form layout while preserving dark mode.
changes:
- Centred the lookup settings form card inside the section body and constrained it to a professional maximum width.
- Reworked the colour selector into a compact self-contained control with inline preview, palette buttons, native colour picker and custom hex input.
- Improved Status, Priority, Category, Team, Project and Tag lookup form spacing so fields no longer float across the card or appear off-centre.
- Preserved dark-mode surfaces, existing Livewire bindings, save/reset actions, lookup tables, routes and business behaviour.
- Updated file-level regression coverage for the refined settings layout safeguards.
- Preserved append-only changelog history.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.10.11" "version": "1.10.15"
} }