Compare commits
15
Commits
01f5fa8e75
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2fe2c7c05 | ||
|
|
7073a81b99 | ||
|
|
e241c08ef6 | ||
|
|
efb8ee3898 | ||
|
|
0056a6034e | ||
|
|
fe68238143 | ||
|
|
2f422a5320 | ||
|
|
18e95bc653 | ||
|
|
942645f880 | ||
|
|
dc6dd989ed | ||
|
|
c71479d119 | ||
|
|
8db97cd60c | ||
|
|
afa2ee472e | ||
|
|
912c120299 | ||
|
|
cb98e67e04 |
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -0,0 +1,168 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application running on PHP 8.3. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||
|
||||
Before relying on a package's API, confirm its installed version:
|
||||
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||
- JS packages: check `package.json` for the installed versions.
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Project Rules
|
||||
|
||||
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
|
||||
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
@@ -683,3 +683,12 @@ html.dark .doc-content code {
|
||||
}
|
||||
.doc-pagination nav span[aria-disabled="true"] > span { color: #697180 !important; cursor: not-allowed; }
|
||||
.doc-pagination nav svg { width: 1rem; height: 1rem; }
|
||||
|
||||
/* Documentation: professional contrast and strict light/dark mode parity. */
|
||||
html:not(.dark) .documentation-surface,
|
||||
html:not(.dark) [data-documentation-surface] { color:#18181b !important; }
|
||||
html:not(.dark) .documentation-surface :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600),
|
||||
html:not(.dark) [data-documentation-surface] :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600) { color:#52525b !important; }
|
||||
html.dark .documentation-surface :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600),
|
||||
html.dark [data-documentation-surface] :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-gray-400,.text-gray-500,.text-gray-600) { color:#a1a1aa !important; }
|
||||
html:not(.dark) .documentation-surface :is(section,article,.card,[class*="rounded"]):not([class*="bg-"]){ color:#27272a; }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
final class NativeAuthController extends Controller
|
||||
{
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'device_name' => ['sometimes', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$user = User::query()
|
||||
->where('email', $data['email'])
|
||||
->first();
|
||||
|
||||
if (! $user || ! Hash::check($data['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['The provided credentials are incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Native two-factor and passkey challenges are not implemented yet.
|
||||
// Never issue a token to an account requiring these challenges.
|
||||
if (
|
||||
! empty($user->two_factor_secret)
|
||||
|| ! empty($user->two_factor_confirmed_at)
|
||||
) {
|
||||
return response()->json([
|
||||
'message' => 'This account requires additional authentication.',
|
||||
'code' => 'additional_authentication_required',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$token = $user->createToken(
|
||||
$data['device_name'] ?? 'HelpdeskMac',
|
||||
['helpdesk:native'],
|
||||
now()->addDays(30)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'token_type' => 'Bearer',
|
||||
'access_token' => $token->plainTextToken,
|
||||
'expires_at' => $token->accessToken->expires_at?->toIso8601String(),
|
||||
'user' => $this->userData($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function user(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'user' => $this->userData($request->user()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$token = $request->user()->currentAccessToken();
|
||||
|
||||
if ($token instanceof PersonalAccessToken) {
|
||||
$token->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Logged out successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function userData(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->getKey(),
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'helpdesk_agent' => $user->isHelpdeskAgent(),
|
||||
'helpdesk_admin' => $user->isHelpdeskAdmin(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class NativeAuthorizationController extends Controller
|
||||
{
|
||||
public function authorize(Request $request): View
|
||||
{
|
||||
$data = $this->validateAuthorizationRequest($request);
|
||||
|
||||
return view('helpdesk::native.authorize', [
|
||||
'clientId' => $data['client_id'],
|
||||
'parameters' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateAuthorizationRequest($request);
|
||||
|
||||
$code = Str::random(64);
|
||||
|
||||
DB::table('helpdesk_native_authorization_codes')->insert([
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'client_id' => $data['client_id'],
|
||||
'code_hash' => hash('sha256', $code),
|
||||
'redirect_uri' => $data['redirect_uri'],
|
||||
'code_challenge' => $data['code_challenge'],
|
||||
'code_challenge_method' => 'S256',
|
||||
'expires_at' => now()->addSeconds(
|
||||
(int) config('helpdesk-native.authorization_code_lifetime', 60)
|
||||
),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$separator = str_contains($data['redirect_uri'], '?') ? '&' : '?';
|
||||
\Log::info('HelpdeskMac authorization redirect', [
|
||||
'client_id' => $data['client_id'],
|
||||
'redirect_scheme' => parse_url($data['redirect_uri'], PHP_URL_SCHEME),
|
||||
'redirect_host' => parse_url($data['redirect_uri'], PHP_URL_HOST),
|
||||
'redirect_path' => parse_url($data['redirect_uri'], PHP_URL_PATH),
|
||||
]);
|
||||
return redirect()->away(
|
||||
$data['redirect_uri'].$separator.http_build_query([
|
||||
'code' => $code,
|
||||
'state' => $data['state'],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private function validateAuthorizationRequest(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'client_id' => ['required', 'string', 'max:100'],
|
||||
'redirect_uri' => ['required', 'string', 'max:2048'],
|
||||
'response_type' => ['required', 'in:code'],
|
||||
'state' => ['required', 'string', 'min:32', 'max:512'],
|
||||
'code_challenge' => [
|
||||
'required',
|
||||
'string',
|
||||
'regex:/^[A-Za-z0-9_-]{43}$/',
|
||||
],
|
||||
'code_challenge_method' => ['required', 'in:S256'],
|
||||
]);
|
||||
|
||||
$registeredUris = config(
|
||||
'helpdesk-native.clients.'.$data['client_id'].'.redirect_uris',
|
||||
[]
|
||||
);
|
||||
|
||||
abort_unless(
|
||||
in_array($data['redirect_uri'], $registeredUris, true),
|
||||
400,
|
||||
'Unregistered native application callback.'
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
|
||||
final class NativeTicketController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TicketService $tickets,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$query = Ticket::query()
|
||||
->visibleTo($user)
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = addcslashes(
|
||||
(string) $request->query('search'),
|
||||
'%_\\'
|
||||
);
|
||||
|
||||
$query->where(
|
||||
'subject',
|
||||
'like',
|
||||
'%'.$search.'%'
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$query->paginate(30)
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, Ticket $ticket): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$ticket = Ticket::query()
|
||||
->visibleTo($user)
|
||||
->whereKey($ticket->getKey())
|
||||
->firstOrFail();
|
||||
|
||||
return response()->json(
|
||||
$ticket->load([
|
||||
'comments',
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'agent',
|
||||
'attachments',
|
||||
'tags',
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'subject' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'body' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
'priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_priorities', 'id'),
|
||||
],
|
||||
'category_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_categories', 'id'),
|
||||
],
|
||||
'project_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_projects', 'id'),
|
||||
],
|
||||
'product_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_products', 'id'),
|
||||
],
|
||||
'team_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_teams', 'id'),
|
||||
],
|
||||
'board_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_kanban_boards', 'id'),
|
||||
],
|
||||
'due_at' => [
|
||||
'nullable',
|
||||
'date',
|
||||
],
|
||||
'tag_ids' => [
|
||||
'sometimes',
|
||||
'array',
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('helpdesk_tags', 'id'),
|
||||
],
|
||||
'attachments' => [
|
||||
'sometimes',
|
||||
'array',
|
||||
],
|
||||
'attachments.*' => [
|
||||
'file',
|
||||
'max:10240',
|
||||
],
|
||||
]);
|
||||
|
||||
$files = $request->file('attachments', []);
|
||||
|
||||
unset($data['attachments']);
|
||||
|
||||
$ticket = $this->tickets->create(
|
||||
$request->user(),
|
||||
$data,
|
||||
$files
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
$ticket,
|
||||
201
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class NativeTokenController extends Controller
|
||||
{
|
||||
public function exchange(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'grant_type' => ['required', 'in:authorization_code'],
|
||||
'client_id' => ['required', 'string'],
|
||||
'code' => ['required', 'string'],
|
||||
'redirect_uri' => ['required', 'string'],
|
||||
'code_verifier' => [
|
||||
'required',
|
||||
'string',
|
||||
'regex:/^[A-Za-z0-9._~-]{43,128}$/',
|
||||
],
|
||||
]);
|
||||
|
||||
$challenge = rtrim(
|
||||
strtr(
|
||||
base64_encode(hash('sha256', $data['code_verifier'], true)),
|
||||
'+/',
|
||||
'-_'
|
||||
),
|
||||
'='
|
||||
);
|
||||
|
||||
$result = DB::transaction(function () use ($data, $challenge): ?array {
|
||||
$authorization = DB::table('helpdesk_native_authorization_codes')
|
||||
->where('code_hash', hash('sha256', $data['code']))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
$authorization->consumed_at !== null
|
||||
|| now()->greaterThanOrEqualTo($authorization->expires_at)
|
||||
|| ! hash_equals($authorization->client_id, $data['client_id'])
|
||||
|| ! hash_equals($authorization->redirect_uri, $data['redirect_uri'])
|
||||
|| $authorization->code_challenge_method !== 'S256'
|
||||
|| ! hash_equals($authorization->code_challenge, $challenge)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->find($authorization->user_id);
|
||||
|
||||
if (! $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_native_authorization_codes')
|
||||
->where('id', $authorization->id)
|
||||
->update([
|
||||
'consumed_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$token = $user->createToken(
|
||||
'HelpdeskMac',
|
||||
['helpdesk:native'],
|
||||
now()->addDays(
|
||||
(int) config('helpdesk-native.access_token_lifetime_days', 30)
|
||||
)
|
||||
);
|
||||
|
||||
return [
|
||||
'access_token' => $token->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_at' => $token->accessToken->expires_at?->toIso8601String(),
|
||||
'user' => [
|
||||
'id' => $user->getKey(),
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
if ($result === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'code' => ['Invalid or expired authorization code.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($result)
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('Pragma', 'no-cache');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -20,7 +20,6 @@ use Modules\Helpdesk\Livewire\TicketTimer;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Policies\TicketPolicy;
|
||||
use Modules\Helpdesk\Support\ModuleInfo;
|
||||
|
||||
final class HelpdeskServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
|
||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
||||
use Modules\Helpdesk\Support\ModuleInfo;
|
||||
use Modules\Helpdesk\Http\Controllers\NativeAuthorizationController;
|
||||
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -42,6 +43,17 @@ final class RouteServiceProvider extends ServiceProvider
|
||||
->prefix(config('helpdesk.route_prefix', 'helpdesk'))
|
||||
->name('helpdesk.')
|
||||
->group(module_path(ModuleInfo::name(), 'Routes/web.php'));
|
||||
// Native macOS authentication through Laravel Fortify.
|
||||
Route::middleware(['web', 'auth:web'])
|
||||
->prefix('helpdesk/native')
|
||||
->name('helpdesk.native.')
|
||||
->group(function (): void {
|
||||
Route::get('/authorize', [NativeAuthorizationController::class, 'authorize'])
|
||||
->name('authorize');
|
||||
|
||||
Route::post('/authorize', [NativeAuthorizationController::class, 'approve'])
|
||||
->name('approve');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1439,3 +1439,72 @@ html:not(.dark) .helpdesk-ticket-form select:-webkit-autofill:focus {
|
||||
html:not(.dark) .hd-helpdesk-surface {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Professional theme contract: readable contrast in both application modes. */
|
||||
html:not(.dark) .hd-helpdesk-surface {
|
||||
color: #18181b !important;
|
||||
}
|
||||
html:not(.dark) .hd-helpdesk-surface :is(.text-zinc-100,.text-zinc-200,.text-zinc-300,.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-zinc-700,.text-zinc-900) {
|
||||
color: #3f3f46 !important;
|
||||
}
|
||||
html:not(.dark) .hd-helpdesk-surface :is(h1,h2,h3,h4,.font-semibold,.font-bold,.font-black):not(.hd-btn):not([class*="text-white"]) {
|
||||
color: #18181b;
|
||||
}
|
||||
html:not(.dark) .hd-helpdesk-surface .text-blue-100\/90,
|
||||
html:not(.dark) .hd-helpdesk-surface .text-blue-100,
|
||||
html:not(.dark) .hd-helpdesk-surface .text-blue-200,
|
||||
html:not(.dark) .hd-helpdesk-surface .text-amber-100\/85,
|
||||
html:not(.dark) .hd-helpdesk-surface .text-amber-100,
|
||||
html:not(.dark) .hd-helpdesk-surface .text-amber-200 {
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
/* Heroes are light in light mode and intentionally dark in dark mode. */
|
||||
html:not(.dark) .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) {
|
||||
background: linear-gradient(135deg,#ffffff 0%,#f8fafc 55%,#eef4ff 100%) !important;
|
||||
color: #18181b !important;
|
||||
}
|
||||
html.dark .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) {
|
||||
background: linear-gradient(135deg,#020617 0%,#0f172a 55%,#172554 100%) !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
html:not(.dark) .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) :is(h1,h2,h3) { color:#18181b !important; }
|
||||
html:not(.dark) .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) p { color:#475569 !important; }
|
||||
html.dark .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) :is(h1,h2,h3) { color:#f8fafc !important; }
|
||||
html.dark .hd-helpdesk-surface :is(.hd-page-hero,.hd-settings-hero-inner) p { color:#cbd5e1 !important; }
|
||||
|
||||
/* Search/filter surface follows the switch instead of remaining black. */
|
||||
html:not(.dark) .hd-helpdesk-surface .hd-filter-panel {
|
||||
background:#ffffff !important; border-color:#d4d4d8 !important; color:#18181b !important;
|
||||
}
|
||||
html.dark .hd-helpdesk-surface .hd-filter-panel {
|
||||
background:#18181b !important; border-color:#3f3f46 !important; color:#f4f4f5 !important;
|
||||
}
|
||||
html:not(.dark) .hd-ticket-search,
|
||||
html:not(.dark) .hd-ticket-search-wrap { background:#fff !important; color:#18181b !important; border-color:#cbd5e1 !important; }
|
||||
html:not(.dark) .hd-ticket-search::placeholder { color:#71717a !important; opacity:1; }
|
||||
html:not(.dark) .hd-ticket-search-icon { color:#52525b !important; }
|
||||
html.dark .hd-ticket-search,
|
||||
html.dark .hd-ticket-search-wrap { background:#09090b !important; color:#f4f4f5 !important; border-color:#3f3f46 !important; }
|
||||
html.dark .hd-ticket-search::placeholder { color:#a1a1aa !important; opacity:1; }
|
||||
html.dark .hd-ticket-search-icon { color:#a1a1aa !important; }
|
||||
|
||||
/* Tables: body copy must never fade into its background. */
|
||||
html:not(.dark) .hd-helpdesk-surface table tbody { color:#27272a !important; }
|
||||
html:not(.dark) .hd-helpdesk-surface table tbody :is(.text-zinc-500,.text-zinc-600,.text-zinc-700) { color:#52525b !important; }
|
||||
html:not(.dark) .hd-helpdesk-surface table tbody :is(.font-medium,.font-semibold) { color:#18181b !important; }
|
||||
html.dark .hd-helpdesk-surface table tbody { color:#e4e4e7 !important; }
|
||||
html.dark .hd-helpdesk-surface table tbody :is(.text-zinc-400,.text-zinc-500,.text-zinc-600,.text-zinc-700) { color:#a1a1aa !important; }
|
||||
html.dark .hd-helpdesk-surface table tbody :is(.font-medium,.font-semibold) { color:#f4f4f5 !important; }
|
||||
|
||||
/* Settings navigation cards use dark copy on translucent pastel cards in light mode. */
|
||||
.hd-settings-section-card { border-color:var(--hd-card-border) !important; background:var(--hd-card-bg) !important; }
|
||||
html:not(.dark) .hd-settings-section-card { color:#18181b !important; box-shadow:0 8px 20px rgba(15,23,42,.08) !important; }
|
||||
html:not(.dark) .hd-settings-section-card.is-active { color:#fff !important; background:var(--hd-card-active) !important; }
|
||||
html.dark .hd-settings-section-card { color:#f8fafc !important; }
|
||||
html.dark .hd-settings-section-card.is-active { background:var(--hd-card-active) !important; }
|
||||
.hd-settings-section-card > div:first-child { opacity:.82 !important; }
|
||||
|
||||
/* Muted text remains secondary, but readable. */
|
||||
html:not(.dark) .hd-helpdesk-surface .hd-muted { color:#52525b !important; }
|
||||
html.dark .hd-helpdesk-surface .hd-muted { color:#a1a1aa !important; }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Authorize HelpdeskMac</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Authorize HelpdeskMac</h1>
|
||||
|
||||
<p>
|
||||
HelpdeskMac is requesting access to your Helpdesk account.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Signed in as {{ auth()->user()->email }}
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ route('helpdesk.native.approve') }}">
|
||||
@csrf
|
||||
|
||||
@foreach ($parameters as $name => $value)
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ $name }}"
|
||||
value="{{ $value }}"
|
||||
>
|
||||
@endforeach
|
||||
|
||||
<button type="submit">
|
||||
Authorize HelpdeskMac
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p>
|
||||
Only approve this request if you initiated login from HelpdeskMac.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
|
||||
use Modules\Helpdesk\Http\Controllers\HelpdeskSyncController;
|
||||
use Modules\Helpdesk\Http\Controllers\NativeTicketController;
|
||||
use Modules\Helpdesk\Http\Middleware\VerifyHelpdeskSyncToken;
|
||||
use Modules\Helpdesk\Http\Controllers\NativeAuthController;
|
||||
use Modules\Helpdesk\Http\Controllers\NativeTokenController;
|
||||
|
||||
Route::get('/status', static fn (): array => [
|
||||
'module' => 'helpdesk',
|
||||
@@ -27,3 +30,36 @@ Route::post('/git/webhook/{repository}', GitWebhookController::class)
|
||||
|
||||
Route::post('/git/gitea/webhook', [GitWebhookController::class, 'gitea'])
|
||||
->name('git.gitea.webhook');
|
||||
// Append inside Modules/Helpdesk/Routes/api.php. Verify your module route prefix.
|
||||
Route::middleware('auth:sanctum')->prefix('native/v1')->group(function () {
|
||||
Route::get('tickets', [NativeTicketController::class, 'index']);
|
||||
Route::get('tickets/{ticket}', [NativeTicketController::class, 'show']);
|
||||
Route::post('tickets', [NativeTicketController::class, 'store']);
|
||||
});
|
||||
|
||||
Route::prefix('native/v1')
|
||||
->name('native.')
|
||||
->group(function (): void {
|
||||
|
||||
Route::middleware('auth:sanctum')->group(function (): void {
|
||||
|
||||
Route::get('auth/user', [NativeAuthController::class, 'user'])
|
||||
->name('auth.user');
|
||||
|
||||
Route::post('auth/logout', [NativeAuthController::class, 'logout'])
|
||||
->name('auth.logout');
|
||||
|
||||
Route::get('tickets', [NativeTicketController::class, 'index'])
|
||||
->name('tickets.index');
|
||||
|
||||
Route::get('tickets/{ticket}', [NativeTicketController::class, 'show'])
|
||||
->name('tickets.show');
|
||||
|
||||
Route::post('tickets', [NativeTicketController::class, 'store'])
|
||||
->name('tickets.store');
|
||||
});
|
||||
Route::post('auth/token', [NativeTokenController::class, 'exchange'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('auth.token');
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -15,6 +15,7 @@ use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Modules\UserManagement\Models\Role;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -28,9 +29,7 @@ use Modules\UserManagement\Models\Role;
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
use HasApiTokens, HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -101,4 +100,5 @@ class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
return $this->isHelpdeskAgent();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"laravel/chisel": "^0.1.0",
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/blaze": "^1.0",
|
||||
"livewire/flux": "^2.13.1",
|
||||
|
||||
Generated
+64
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "0baf4a77af5bdd712ea8c8ae0dd072ca",
|
||||
"content-hash": "3ba979872819ad8b9e06875e8c2231b0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1680,6 +1680,69 @@
|
||||
},
|
||||
"time": "2026-08-04T14:50:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.3.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sanctum.git",
|
||||
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6",
|
||||
"reference": "fee27a573d1a013af3721d86153a65e0b11927e6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||
"illuminate/database": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2",
|
||||
"symfony/console": "^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sanctum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel",
|
||||
"sanctum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sanctum/issues",
|
||||
"source": "https://github.com/laravel/sanctum"
|
||||
},
|
||||
"time": "2026-06-23T18:26:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.15",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'clients' => [
|
||||
'helpdesk-macos' => [
|
||||
'redirect_uris' => [
|
||||
'helpdeskmac://auth/callback',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'authorization_code_lifetime' => 60,
|
||||
|
||||
'access_token_lifetime_days' => 30,
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('helpdesk_native_authorization_codes', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('user_id')
|
||||
->constrained('users')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->string('client_id', 100);
|
||||
|
||||
// Store only the SHA-256 hash of the authorization code.
|
||||
$table->char('code_hash', 64)->unique();
|
||||
|
||||
// Exact registered callback URI for this authorization attempt.
|
||||
$table->string('redirect_uri', 2048);
|
||||
|
||||
// PKCE S256 challenge (base64url encoded).
|
||||
$table->string('code_challenge', 128);
|
||||
|
||||
$table->string('code_challenge_method', 10)
|
||||
->default('S256');
|
||||
|
||||
$table->timestamp('expires_at');
|
||||
|
||||
// Set atomically when the code is redeemed.
|
||||
$table->timestamp('consumed_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['client_id', 'expires_at']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('helpdesk_native_authorization_codes');
|
||||
}
|
||||
};
|
||||
+24
-4
@@ -8,7 +8,7 @@ set('writable_use_sudo', true);
|
||||
set('clear_use_sudo', true);
|
||||
set('cleanup_use_sudo', true);
|
||||
set('keep_releases', 1);
|
||||
set('repository', 'git@github.com:magabsdev/Helpdesk.git');
|
||||
set('repository', 'git@git.absdev.net:mag/Helpddesk.git');
|
||||
set('node_version', '22.12.0');
|
||||
set('allow_anonymous_stats', false);
|
||||
|
||||
@@ -29,10 +29,10 @@ host('develop')
|
||||
->set('deploy_path', '/var/www/helpdesk_dev');
|
||||
|
||||
host('mpba')
|
||||
->set('hostname', 'helpdesk.mpba.dev')
|
||||
->set('hostname', 'sirius.absdev.net')
|
||||
->set('remote_user', 'mag')
|
||||
->set('branch', 'main')
|
||||
->set('deploy_path', '/var/www/helpdesk_mpba');
|
||||
->set('deploy_path', '/var/www/helpdesk');
|
||||
|
||||
/*
|
||||
* Install the root npm tree, then every module that owns its own lock file,
|
||||
@@ -86,9 +86,29 @@ task('artisan:clear', function (): void {
|
||||
});
|
||||
|
||||
task('services:restart', function (): void {
|
||||
// Validate and reload nginx.
|
||||
run('sudo -n nginx -t');
|
||||
run('sudo -n systemctl reload nginx');
|
||||
run('sudo -n systemctl restart php8.3-fpm');
|
||||
|
||||
// Detect the PHP-FPM socket configured in nginx.
|
||||
$socket = trim(run(
|
||||
"sudo -n nginx -T 2>/dev/null"
|
||||
. " | grep -oE 'php[0-9]+\\.[0-9]+-fpm\\.sock'"
|
||||
. " | sort -Vu"
|
||||
. " | tail -n 1"
|
||||
));
|
||||
|
||||
if ($socket === '') {
|
||||
throw new \RuntimeException(
|
||||
'Unable to detect the PHP-FPM version from nginx configuration.'
|
||||
);
|
||||
}
|
||||
// Convert php8.5-fpm.sock into php8.5-fpm.
|
||||
$service = str_replace('.sock', '', $socket);
|
||||
writeln("<info>Detected PHP-FPM service: {$service}</info>");
|
||||
// Restart the detected PHP-FPM service.
|
||||
run("sudo -n systemctl restart {$service}");
|
||||
writeln("<info>PHP-FPM restarted successfully.</info>");
|
||||
});
|
||||
|
||||
after('deploy:update_code', 'deploy:write_revision_file');
|
||||
|
||||
@@ -2497,118 +2497,102 @@ html:not(.dark) .hd-appearance-choice.is-active { background: #fff; color: #1118
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
Appearance switch: final application-wide neutral surface normalisation
|
||||
Keep semantic/action colours, but every neutral view follows html.dark.
|
||||
======================================================================== */
|
||||
html:not(.dark) .hd-dashboard-heading h1,
|
||||
html:not(.dark) .hd-metric-card strong,
|
||||
html:not(.dark) .hd-dashboard-card > header h2,
|
||||
html:not(.dark) .hd-donut span,
|
||||
html:not(.dark) .hd-priority-legend strong,
|
||||
html:not(.dark) .hd-page-hero h1,
|
||||
html:not(.dark) .hd-role-card h2,
|
||||
html:not(.dark) .hd-role-card strong {
|
||||
color: #111827 !important;
|
||||
/* ============================================================
|
||||
LIGHT APPEARANCE — COMPLETE MODULE SURFACE OVERRIDES
|
||||
Keep semantic/action colours, but remove hard-coded dark chrome.
|
||||
============================================================ */
|
||||
html:not(.dark) .hd-page-hero {
|
||||
background: linear-gradient(115deg,#ffffff,#eef4ff) !important;
|
||||
border-color:#d8dee7 !important;
|
||||
box-shadow:0 1px 2px rgba(15,23,42,.06) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-dashboard-heading p,
|
||||
html:not(.dark) .hd-metric-card small,
|
||||
html:not(.dark) .hd-metric-card em,
|
||||
html:not(.dark) .hd-chart-legend,
|
||||
html:not(.dark) .hd-chart-labels,
|
||||
html:not(.dark) .hd-priority-legend > div,
|
||||
html:not(.dark) .hd-latest-list small,
|
||||
html:not(.dark) .hd-latest-list time,
|
||||
html:not(.dark) .hd-requester-list em,
|
||||
html:not(.dark) .hd-empty,
|
||||
html:not(.dark) .hd-page-hero p,
|
||||
html:not(.dark) .hd-role-card p,
|
||||
html:not(.dark) .hd-field span,
|
||||
html:not(.dark) .hd-role-options small {
|
||||
color: #667085 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-range-select,
|
||||
html:not(.dark) .hd-metric-card,
|
||||
html:not(.dark) .hd-dashboard-card,
|
||||
html:not(.dark) .hd-page-hero h1 { color:#111827 !important; }
|
||||
html:not(.dark) .hd-page-hero p { color:#667085 !important; }
|
||||
html:not(.dark) .hd-eyebrow { color:#41658f !important; }
|
||||
html:not(.dark) .hd-panel,
|
||||
html:not(.dark) .hd-role-card,
|
||||
html:not(.dark) .hd-role-options label {
|
||||
background: #ffffff !important;
|
||||
border-color: #d8dee7 !important;
|
||||
color: #1f2937 !important;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, .06) !important;
|
||||
html:not(.dark) .helpdesk-kanban-column,
|
||||
html:not(.dark) .hd-dashboard-card,
|
||||
html:not(.dark) .hd-role-card {
|
||||
background:#ffffff !important;
|
||||
border-color:#d8dee7 !important;
|
||||
color:#1f2937 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-range-select select,
|
||||
html:not(.dark) .hd-search-field input,
|
||||
html:not(.dark) .hd-field input {
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-dashboard-card > header,
|
||||
html:not(.dark) .hd-latest-list > a,
|
||||
html:not(.dark) .hd-requester-list > div,
|
||||
html:not(.dark) .hd-toolbar,
|
||||
html:not(.dark) .hd-dashboard-card>header,
|
||||
html:not(.dark) .hd-latest-list>a,
|
||||
html:not(.dark) .hd-requester-list>div,
|
||||
html:not(.dark) .hd-table th,
|
||||
html:not(.dark) .hd-table td {
|
||||
border-color: #e2e8f0 !important;
|
||||
border-color:#e2e8f0 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-dashboard-card > header a,
|
||||
html:not(.dark) .hd-action,
|
||||
html:not(.dark) .hd-search-field,
|
||||
html:not(.dark) .hd-toolbar { background:#f8fafc !important; }
|
||||
html:not(.dark) .hd-search-field {
|
||||
background:#ffffff !important;
|
||||
border-color:#cfd6e1 !important;
|
||||
color:#667085 !important;
|
||||
}
|
||||
html:not(.dark) .hd-search-field input { color:#1f2937 !important; }
|
||||
html:not(.dark) .hd-table th { background:#f3f5f8 !important; color:#667085 !important; }
|
||||
html:not(.dark) .hd-table td { background:#ffffff !important; color:#1f2937 !important; }
|
||||
html:not(.dark) .hd-table tbody tr:hover td { background:#f8fafc !important; }
|
||||
html:not(.dark) .hd-action:not(.hd-action-blue):not(.hd-action-green):not(.hd-action-red) {
|
||||
background:#ffffff !important; border-color:#cfd6e1 !important; color:#1f2937 !important;
|
||||
}
|
||||
html:not(.dark) .hd-role-card h2,
|
||||
html:not(.dark) .hd-role-card strong,
|
||||
html:not(.dark) .hd-dashboard-card>header h2 { color:#111827 !important; }
|
||||
html:not(.dark) .hd-role-card p,
|
||||
html:not(.dark) .hd-dashboard-card>header span,
|
||||
html:not(.dark) .hd-dashboard-card>header a,
|
||||
html:not(.dark) .hd-requester-list em,
|
||||
html:not(.dark) .hd-empty { color:#667085 !important; }
|
||||
html:not(.dark) .hd-metric-card {
|
||||
background:linear-gradient(145deg,#ffffff,#f8fafc) !important;
|
||||
border-color:#d8dee7 !important;
|
||||
box-shadow:0 8px 20px rgba(15,23,42,.07) !important;
|
||||
}
|
||||
html:not(.dark) .hd-donut:after { background:#ffffff !important; }
|
||||
html:not(.dark) .hd-donut span,
|
||||
html:not(.dark) .hd-priority-legend strong { color:#111827 !important; }
|
||||
html:not(.dark) .hd-priority-legend>div,
|
||||
html:not(.dark) .hd-latest-list>a { color:#374151 !important; }
|
||||
html:not(.dark) .hd-latest-list>a:hover { background:#f8fafc !important; }
|
||||
html:not(.dark) .hd-chart-grid-lines line { stroke:#d8dee7 !important; }
|
||||
html:not(.dark) .hd-form-grid .hd-field span { color:#374151 !important; }
|
||||
html:not(.dark) .hd-field input {
|
||||
background: #f8fafc !important;
|
||||
border-color: #d8dee7 !important;
|
||||
color: #374151 !important;
|
||||
background:#ffffff !important; color:#1f2937 !important; border-color:#cfd6e1 !important;
|
||||
}
|
||||
html:not(.dark) .hd-role-options label { background:#ffffff !important; border-color:#d8dee7 !important; }
|
||||
html:not(.dark) .hd-role-options small { color:#667085 !important; }
|
||||
html:not(.dark) .hd-kanban-drag-handle {
|
||||
background:#f3f5f8 !important; border-color:#d8dee7 !important; color:#667085 !important;
|
||||
}
|
||||
html:not(.dark) .hd-kanban-drag-handle:hover { background:#e9eef5 !important; color:#111827 !important; }
|
||||
|
||||
html:not(.dark) .hd-dashboard-card > header a:hover,
|
||||
html:not(.dark) .hd-latest-list > a:hover,
|
||||
html:not(.dark) .hd-action:hover {
|
||||
background: #eef2f7 !important;
|
||||
color: #111827 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-latest-list > a,
|
||||
html:not(.dark) .hd-table td,
|
||||
html:not(.dark) .hd-priority-legend strong {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .hd-table th {
|
||||
background: #f8fafc !important;
|
||||
color: #667085 !important;
|
||||
}
|
||||
|
||||
/* The page hero is a neutral container. Accent buttons/badges keep their colour. */
|
||||
html:not(.dark) .hd-page-hero {
|
||||
background: linear-gradient(115deg, #ffffff, #f1f5f9) !important;
|
||||
border-color: #d8dee7 !important;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, .06) !important;
|
||||
}
|
||||
html:not(.dark) .hd-eyebrow { color: #2563eb !important; }
|
||||
|
||||
/* Neutral Tailwind surfaces used by module views. */
|
||||
html.dark .bg-white { background-color: #18181b !important; }
|
||||
html.dark .bg-zinc-50,
|
||||
html.dark .bg-zinc-100 { background-color: #27272a !important; }
|
||||
html.dark .text-zinc-900,
|
||||
html.dark .text-zinc-800,
|
||||
html.dark .text-zinc-700 { color: #f4f4f5 !important; }
|
||||
html.dark .border-zinc-200,
|
||||
html.dark .border-zinc-300 { border-color: #3f3f46 !important; }
|
||||
|
||||
/* Native controls must follow the selected colour scheme in every module. */
|
||||
html:not(.dark) .hd-shell-main input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]),
|
||||
html:not(.dark) .hd-shell-main textarea,
|
||||
html:not(.dark) .hd-shell-main select {
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark .hd-shell-main input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]),
|
||||
html.dark .hd-shell-main textarea,
|
||||
html.dark .hd-shell-main select {
|
||||
color-scheme: dark;
|
||||
/* Tailwind utility surfaces used directly by Helpdesk Blade views. */
|
||||
html:not(.dark) .bg-black,
|
||||
html:not(.dark) .bg-slate-950,
|
||||
html:not(.dark) .bg-slate-900,
|
||||
html:not(.dark) .bg-slate-800,
|
||||
html:not(.dark) .bg-slate-700,
|
||||
html:not(.dark) .bg-slate-600,
|
||||
html:not(.dark) .bg-neutral-950,
|
||||
html:not(.dark) .bg-neutral-900,
|
||||
html:not(.dark) .bg-neutral-800 {
|
||||
background-color:#ffffff !important;
|
||||
}
|
||||
html:not(.dark) [class*="bg-slate-950/"],
|
||||
html:not(.dark) [class*="bg-slate-900/"],
|
||||
html:not(.dark) [class*="bg-black/"] { background-color:#ffffff !important; }
|
||||
html:not(.dark) [class*="border-slate-"],
|
||||
html:not(.dark) [class*="border-neutral-"] { border-color:#d8dee7 !important; }
|
||||
html:not(.dark) [class*="text-slate-100"],
|
||||
html:not(.dark) [class*="text-slate-200"],
|
||||
html:not(.dark) [class*="text-neutral-100"],
|
||||
html:not(.dark) [class*="text-neutral-200"] { color:#1f2937 !important; }
|
||||
html:not(.dark) [class*="text-slate-300"],
|
||||
html:not(.dark) [class*="text-neutral-300"] { color:#374151 !important; }
|
||||
html:not(.dark) [class*="text-slate-400"],
|
||||
html:not(.dark) [class*="text-slate-500"],
|
||||
html:not(.dark) [class*="text-neutral-400"],
|
||||
html:not(.dark) [class*="text-neutral-500"] { color:#667085 !important; }
|
||||
|
||||
Executable → Regular
+15
-14
@@ -10,10 +10,11 @@
|
||||
class="space-y-2"
|
||||
x-data="{
|
||||
model: @js($model),
|
||||
value: @entangle($model),
|
||||
value: '',
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
this.writeEditorValue(this.value || '');
|
||||
this.value = this.$wire.get(this.model) || '';
|
||||
this.writeEditorValue(this.value);
|
||||
});
|
||||
},
|
||||
writeEditorValue(value) {
|
||||
@@ -30,29 +31,29 @@
|
||||
|
||||
this.$refs.editor.textContent = nextValue;
|
||||
},
|
||||
syncToLivewire(live = true) {
|
||||
this.value = this.$refs.editor?.innerText || '';
|
||||
|
||||
syncToLivewire(live = false) {
|
||||
const text = this.$refs.editor?.innerText || '';
|
||||
this.value = text;
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.value = this.value;
|
||||
this.$refs.input.value = text;
|
||||
}
|
||||
|
||||
this.$wire.set(this.model, this.value, live);
|
||||
// Update Livewire's local state before the save request is assembled.
|
||||
this.$wire.set(this.model, text, live);
|
||||
},
|
||||
resetEditor() {
|
||||
this.value = '';
|
||||
this.writeEditorValue('');
|
||||
this.syncToLivewire(true);
|
||||
this.syncToLivewire(false);
|
||||
},
|
||||
runCommand(command) {
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand(command, false, null);
|
||||
this.syncToLivewire(true);
|
||||
this.syncToLivewire(false);
|
||||
},
|
||||
wrapBlock(tagName) {
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand('formatBlock', false, tagName);
|
||||
this.syncToLivewire(true);
|
||||
this.syncToLivewire(false);
|
||||
},
|
||||
}"
|
||||
x-on:helpdesk-editor-sync.window="if (! $event.detail?.model || $event.detail.model === @js($model)) syncToLivewire($event.detail?.live ?? true)"
|
||||
@@ -116,14 +117,14 @@
|
||||
data-helpdesk-sync-target="{{ $model }}"
|
||||
data-helpdesk-editor-model="{{ $model }}"
|
||||
@if ($editorDusk) data-dusk="{{ $editorDusk }}" dusk="{{ $editorDusk }}" @endif
|
||||
x-on:paste="syncToLivewire(false)"
|
||||
x-on:blur="syncToLivewire(true)"
|
||||
x-on:input="syncToLivewire(false)"
|
||||
x-on:paste="$nextTick(() => syncToLivewire(false))"
|
||||
x-on:blur="syncToLivewire(false)"
|
||||
class="helpdesk-rich-editor block min-h-[14rem] w-full overflow-y-auto whitespace-pre-wrap break-words border-0 bg-zinc-950 px-5 py-4 text-base leading-7 text-zinc-100 outline-none transition placeholder:text-zinc-500 empty:before:pointer-events-none empty:before:text-zinc-500 empty:before:content-[attr(data-placeholder)] focus:ring-2 focus:ring-blue-500"
|
||||
></div>
|
||||
|
||||
<textarea
|
||||
x-ref="input"
|
||||
wire:model.defer="{{ $model }}"
|
||||
spellcheck="true"
|
||||
lang="en-GB"
|
||||
autocorrect="on"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<div class="hd-helpdesk-surface hd-mobile-ready space-y-6" dusk="helpdesk-ticket-archive">
|
||||
<section class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-amber-950 px-6 py-7 text-white">
|
||||
<div class="hd-page-hero bg-gradient-to-br from-slate-950 via-slate-900 to-amber-950 px-6 py-7 text-white">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-200">Ticket archive</p>
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<section class="hd-filter-panel rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="hd-ticket-search-wrap max-w-2xl">
|
||||
<i class="fa-solid fa-magnifying-glass hd-ticket-search-icon" aria-hidden="true"></i>
|
||||
<input type="search" class="hd-ticket-search" wire:model.live.debounce.350ms="search"
|
||||
|
||||
+4
-13
@@ -77,22 +77,13 @@
|
||||
default => ['border' => '#71717a', 'bg' => 'rgba(63, 63, 70, .22)', 'active' => '#3f3f46'],
|
||||
};
|
||||
|
||||
$sectionStyle =
|
||||
$section === $key
|
||||
? 'border-color: ' .
|
||||
$sectionPalette['border'] .
|
||||
'; background-color: ' .
|
||||
$sectionPalette['active'] .
|
||||
'; color: #ffffff; box-shadow: 0 16px 36px rgba(0,0,0,.32);'
|
||||
: 'border-color: ' .
|
||||
$sectionPalette['border'] .
|
||||
'; background-color: ' .
|
||||
$sectionPalette['bg'] .
|
||||
'; color: #ffffff; box-shadow: 0 10px 24px rgba(0,0,0,.20);';
|
||||
$sectionStyle = '--hd-card-border: ' . $sectionPalette['border'] .
|
||||
'; --hd-card-bg: ' . $sectionPalette['bg'] .
|
||||
'; --hd-card-active: ' . $sectionPalette['active'] . ';';
|
||||
@endphp
|
||||
<a href="{{ route('helpdesk.settings', ['section' => $key]) }}"
|
||||
dusk="helpdesk-settings-section-{{ $key }}"
|
||||
class="hd-settings-section-card rounded-xl border px-3 py-2 text-left shadow-sm transition hover:-translate-y-0.5"
|
||||
class="hd-settings-section-card {{ $section === $key ? 'is-active' : '' }} rounded-xl border px-3 py-2 text-left shadow-sm transition hover:-translate-y-0.5"
|
||||
style="{{ $sectionStyle }}">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
|
||||
<div class="mt-0.5 text-xl font-semibold">{{ $item['count'] }}</div>
|
||||
|
||||
Executable → Regular
+5
-25
@@ -18,33 +18,9 @@
|
||||
x-on:submit.capture="
|
||||
if (helpdeskAttachmentsUploading) {
|
||||
$event.preventDefault();
|
||||
$event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = document.querySelector('[data-helpdesk-sync-target="body"], [data-helpdesk-editor-model="body"]');
|
||||
const target = Array.from(document.querySelectorAll('textarea')).find(function (textarea) {
|
||||
|
||||
return textarea.getAttribute('wire:model.defer') === 'body'
|
||||
|
||||
|| textarea.getAttribute('wire:model') === 'body'
|
||||
|
||||
|| textarea.getAttribute('name') === 'body'
|
||||
|
||||
|| textarea.id === 'body';
|
||||
|
||||
});
|
||||
const body = (editor?.innerText || editor?.textContent || editor?.value || '').trim();
|
||||
|
||||
if (target) {
|
||||
target.value = body;
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
target.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
if ($wire) {
|
||||
$wire.set('body', body, false);
|
||||
}
|
||||
|
||||
$dispatch('helpdesk-editor-sync', { model: 'body', live: false });
|
||||
"
|
||||
class="helpdesk-ticket-form space-y-6"
|
||||
@@ -245,6 +221,10 @@
|
||||
editor-dusk="ticket-body-editor"
|
||||
/>
|
||||
|
||||
@error('body')
|
||||
<p class="mt-2 text-sm text-red-500" role="alert">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<p class="mt-2 flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||||
<i class="fa-solid fa-circle-info text-blue-400" aria-hidden="true"></i>
|
||||
Rich formatting is available. Browser spelling suggestions appear by right-clicking or Ctrl-clicking red-underlined words.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="hd-helpdesk-surface hd-mobile-ready space-y-6" dusk="helpdesk-ticket-index">
|
||||
<div
|
||||
class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<div class="hd-page-hero bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Ticket management</p>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="hd-filter-panel rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="hd-ticket-search-wrap">
|
||||
<i class="fa-solid fa-magnifying-glass hd-ticket-search-icon" aria-hidden="true"></i>
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-2 rounded-xl bg-white/15 px-3 py-1.5 text-xs font-extrabold ring-1 ring-white/20">
|
||||
<i class="fa-solid fa-paperclip text-xs" aria-hidden="true"></i>
|
||||
{{ $ticket->attachments->count() }} file{{ $ticket->attachments->count() === 1 ? '' : 's' }}
|
||||
{{ $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment())->count() }} file{{ $ticket->attachments->filter(fn ($attachment) => ! $attachment->isDeletedAttachment())->count() === 1 ? '' : 's' }}
|
||||
<i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i>
|
||||
</span>
|
||||
</summary>
|
||||
@@ -510,12 +510,12 @@
|
||||
class="inline-flex cursor-pointer items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70">
|
||||
<i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i>
|
||||
<span>Choose files</span>
|
||||
<input name="comment_attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
|
||||
<input name="attachments[]" type="file" multiple accept="{{ $helpdeskAttachmentAccept }}" class="sr-only"
|
||||
data-helpdesk-file-input>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@error('comment_attachments')
|
||||
@error('attachments')
|
||||
<p class="mt-2 text-xs font-semibold text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
Respond
|
||||
</button>
|
||||
|
||||
@if (auth()->user()?->isMpbaPlatformAdmin())
|
||||
@if (auth()->user()?->isHelpdeskAdmin())
|
||||
<form method="POST"
|
||||
action="{{ route('helpdesk.tickets.comments.destroy', [$currentTicket, $comment]) }}"
|
||||
onsubmit="return confirm('Delete this response? This will remove it from the visible conversation but keep the replication identity/audit trail.');">
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
JAVA_HOME="/opt/homebrew/opt/openjdk" \
|
||||
PATH="/opt/homebrew/opt/openjdk/bin:$PATH" \
|
||||
sonar-scanner \
|
||||
-Dsonar.projectKey=Helpdesk \
|
||||
-Dsonar.sources=. \
|
||||
-Dsonar.host.url=http://127.0.0.1:9001 \
|
||||
-Dsonar.login=sqp_36027127786b9a8ed4d0d165bdb3405ed07421d8
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# sqp_c0b887930297d93cf088476023c244d901c51204
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
JAVA_HOME="/opt/homebrew/opt/openjdk" \
|
||||
PATH="/opt/homebrew/opt/openjdk/bin:$PATH" \
|
||||
sonar-scanner \
|
||||
-Dsonar.projectKey=Helpdesk-System \
|
||||
-Dsonar.sources=. \
|
||||
-Dsonar.host.url=https://sonarcube.absdev.net \
|
||||
-Dsonar.token="${SONAR_TOKEN:?SONAR_TOKEN is not set}"
|
||||
Reference in New Issue
Block a user