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) {
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
'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
{
$signature = (string) $request->header('X-Hub-Signature-256', '');
$expected = 'sha256=' . hash_hmac('sha256', $rawPayload, $secret);
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
$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) {
'github' => $request->header('X-GitHub-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) {
'github' => $request->header('X-GitHub-Delivery'),
'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);
@endphp
<div {{ $attributes->class('space-y-3') }}>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">
{{ $label }}
</label>
<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') }}>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-3">
<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
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)
<option value="{{ $key }}" style="color: {{ $option['hex'] }};">
@@ -29,13 +29,13 @@
@endforeach
</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)
<button
type="button"
wire:click="$set('{{ $model }}', '{{ $key }}')"
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'] }}"
>
<span class="sr-only">Use {{ $option['name'] }}</span>
@@ -44,28 +44,31 @@
</div>
</div>
<span
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"
style="background-color: {{ $currentHex }}; color: {{ $currentText }};"
>
{{ $currentLabel }}
</span>
</div>
<div class="flex w-full flex-col gap-3 sm:w-44 sm:items-stretch">
<span
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>
<div class="grid gap-3 md:grid-cols-[auto_minmax(0,1fr)] md:items-center">
<input
type="color"
value="{{ $currentHex }}"
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"
aria-label="Choose a custom hex colour"
/>
<div class="grid grid-cols-[3.5rem_minmax(0,1fr)] items-center gap-2">
<input
type="color"
value="{{ $currentHex }}"
wire:change="$set('{{ $model }}', $event.target.value)"
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"
/>
<flux:input
wire:model.live.debounce.500ms="{{ $model }}"
label="Custom hex colour"
placeholder="#ffffff"
description="Use the standard palette where possible. Custom hex values must use six characters, for example #ffffff."
/>
<input
type="text"
wire:model.live.debounce.500ms="{{ $model }}"
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"
placeholder="#ffffff"
aria-label="Custom hex colour"
/>
</div>
</div>
</div>
</div>
@@ -1,9 +1,32 @@
@props([
'title' => 'Settings',
'description' => '',
])
<section
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 bg-zinc-50 px-5 py-4 dark:border-zinc-700 dark:bg-zinc-800/70">
<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>
{{ $attributes->merge([
'class' => 'overflow-hidden rounded-3xl border border-zinc-700 bg-zinc-900 shadow-sm ring-1 ring-white/10',
]) }}>
<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 class="border-b border-zinc-200 p-5 dark:border-zinc-700">{{ $form }}</div>
<div>{{ $slot }}</div>
</section>
@@ -140,21 +140,27 @@
<x-helpdesk::settings-section title="Statuses"
description="Control workflow stages and mark which statuses close tickets.">
<x-slot:form>
<form wire:submit.prevent="saveStatus" class="grid gap-4 md:grid-cols-5">
<flux:input wire:model="statusForm.name" label="Name" />
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
<x-helpdesk::colour-select model="statusForm.color" label="Badge colour" :options="$colourOptions" />
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input
type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed
status</label>
<label
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<form wire:submit.prevent="saveStatus" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:input wire:model="statusForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
</div>
<x-helpdesk::colour-select class="xl:col-span-4" model="statusForm.color" label="Badge colour" :options="$colourOptions" />
<div class="xl:col-span-2">
<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"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default status for new tickets
</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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save status
</flux:button>
@@ -171,17 +177,23 @@
@if ($section === 'priorities')
<x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order.">
<x-slot:form>
<form wire:submit.prevent="savePriority" class="grid gap-4 md:grid-cols-4">
<flux:input wire:model="priorityForm.name" label="Name" />
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
<x-helpdesk::colour-select model="priorityForm.color" label="Badge colour" :options="$colourOptions" />
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
<label class="flex items-end gap-2 pb-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<form wire:submit.prevent="savePriority" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:input wire:model="priorityForm.name" label="Name" />
</div>
<div class="xl:col-span-3">
<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"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default for new tickets
</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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save priority
</flux:button>
@@ -199,26 +211,31 @@
<x-helpdesk::settings-section title="Categories"
description="Group tickets by support area, optionally per company.">
<x-slot:form>
<form wire:submit.prevent="saveCategory" class="grid gap-4 md:grid-cols-4">
<flux:select wire:model="categoryForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="categoryForm.name" label="Name" />
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
<x-helpdesk::colour-select model="categoryForm.color" label="Badge colour" :options="$colourOptions" />
<div class="md:col-span-4">
<form wire:submit.prevent="saveCategory" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:select wire:model="categoryForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-3">
<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" />
</div>
<label
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<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="categoryForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default category for new tickets
</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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save
category</flux:button>
@@ -236,26 +253,31 @@
<x-helpdesk::settings-section title="Teams"
description="Create support teams under each company and assign projects to them.">
<x-slot:form>
<form wire:submit.prevent="saveTeam" class="grid gap-4 md:grid-cols-4">
<flux:select wire:model="teamForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="teamForm.name" label="Name" />
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
<x-helpdesk::colour-select model="teamForm.color" label="Badge colour" :options="$colourOptions" />
<div class="md:col-span-4">
<form wire:submit.prevent="saveTeam" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-3">
<flux:select wire:model="teamForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-3">
<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" />
</div>
<label
class="md:col-span-4 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<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="teamForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default team for new tickets
</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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save team
</flux:button>
@@ -273,32 +295,39 @@
<x-helpdesk::settings-section title="Projects"
description="Create company projects and optionally assign them to support teams.">
<x-slot:form>
<form wire:submit.prevent="saveProject" class="grid gap-4 md:grid-cols-5">
<flux:select wire:model="projectForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="projectForm.team_id" label="Team">
<option value="">No team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="projectForm.title" label="Title" />
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
<x-helpdesk::colour-select model="projectForm.color" label="Badge colour" :options="$colourOptions" />
<div class="md:col-span-5">
<form wire:submit.prevent="saveProject" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-2">
<flux:select wire:model="projectForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
</div>
<div class="xl:col-span-2">
<flux:select wire:model="projectForm.team_id" label="Team">
<option value="">No team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</flux:select>
</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" />
</div>
<label
class="md:col-span-5 flex items-center gap-2 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
<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="projectForm.is_default"
class="rounded border-zinc-300 bg-zinc-950 text-blue-600 focus:ring-blue-500">
Default project for new tickets
</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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save project
</flux:button>
@@ -865,11 +894,15 @@
<x-helpdesk::settings-section title="Ticket tags"
description="Maintain visible ticket labels for search, triage and Kanban card context.">
<x-slot:form>
<form wire:submit.prevent="saveTag" class="grid gap-4 md:grid-cols-5">
<flux:input wire:model="tagForm.name" label="Name" />
<flux:input wire:model="tagForm.slug" label="Slug" />
<x-helpdesk::colour-select model="tagForm.color" label="Colour" :options="$colourOptions" />
<div class="md:col-span-5 flex gap-2">
<form wire:submit.prevent="saveTag" class="grid gap-5 xl:grid-cols-12">
<div class="xl:col-span-4">
<flux:input wire:model="tagForm.name" label="Name" />
</div>
<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"
class="!bg-green-600 !text-white hover:!bg-green-700 focus:!ring-green-400/60">Save tag
</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"
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>
<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>"
class="rounded-lg border px-2 py-1 text-xs">Code</button>
</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
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>
@@ -1,10 +1,19 @@
<?php
/**
* GitWebhookIngestionService.php
*
* Normalises Git provider webhooks and links commits to Helpdesk tickets.
*/
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\GitRepository;
@@ -14,7 +23,10 @@ use Throwable;
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.
@@ -39,14 +51,14 @@ final class GitWebhookIngestionService
return $result;
}
$branch = $this->branchFromRef($payload['ref'] ?? null);
$branch = $this->branchFromPayload($payload);
$commits = $this->commitsFromPayload($payload);
$commitCount = 0;
$linkedCount = 0;
$ignoredCount = 0;
foreach ($commits as $commit) {
$hash = $this->stringOrNull($commit['id'] ?? $commit['sha'] ?? null);
$hash = $this->commitHash($commit);
if ($hash === null) {
$ignoredCount++;
@@ -54,13 +66,19 @@ final class GitWebhookIngestionService
continue;
}
$message = $this->stringOrNull($commit['message'] ?? null) ?? '';
$url = $this->stringOrNull($commit['url'] ?? null);
$message = $this->stringOrNull($commit['message'] ?? $commit['title'] ?? $commit['summary'] ?? null) ?? '';
$url = $this->commitUrl($commit);
$author = is_array($commit['author'] ?? null) ? $commit['author'] : [];
$committer = is_array($commit['committer'] ?? null) ? $commit['committer'] : [];
$authorName = $this->stringOrNull($author['name'] ?? $committer['name'] ?? null);
$authorEmail = $this->stringOrNull($author['email'] ?? $committer['email'] ?? null);
$committedAt = $this->parseTimestamp($commit['timestamp'] ?? $commit['committed_at'] ?? null);
$authorName = $this->stringOrNull($author['name'] ?? $author['username'] ?? $committer['name'] ?? null);
$authorEmail = $this->stringOrNull($author['email'] ?? $author['raw'] ?? $committer['email'] ?? null);
$committedAt = $this->parseTimestamp(
$commit['timestamp']
?? $commit['committed_at']
?? $commit['date']
?? Arr::get($commit, 'author.date')
?? null,
);
$ticket = $this->ticketForCommit($repository, $message, $branch, $url);
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
{
@@ -142,7 +160,7 @@ final class GitWebhookIngestionService
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
{
$normalised = strtolower((string) $event);
$normalisedProvider = Str::lower(trim($provider));
$normalisedEvent = Str::of((string) $event)->lower()->replace(['_', '-'], ' ')->squish()->value();
return match ($provider) {
'github' => $normalised === 'push',
'gitlab' => in_array($normalised, ['push hook', 'push'], true),
default => $normalised === '' || $normalised === 'push',
return match ($normalisedProvider) {
'github' => in_array($normalisedEvent, ['push', ''], true),
'gitlab' => in_array($normalisedEvent, ['push hook', 'push', ''], true),
'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) {
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
{
$commits = $payload['commits'] ?? [];
$commits = $payload['commits'] ?? null;
if (! is_array($commits)) {
return [];
if (is_array($commits) && $commits !== []) {
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
@@ -194,17 +261,19 @@ final class GitWebhookIngestionService
return null;
}
$query = Ticket::query()->where('reference', $reference);
$baseQuery = Ticket::query()->where('reference', $reference);
if ($repository->company_id !== null) {
$query->where('company_id', $repository->company_id);
$baseQuery->where('company_id', $repository->company_id);
}
if ($repository->project_id !== null) {
$query->where('project_id', $repository->project_id);
}
$strictProjectTicket = (clone $baseQuery)
->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
@@ -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 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.
- 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"
],
"files": [],
"version": "1.10.11"
"version": "1.10.15"
}