Merge branch 'develop' into use_new_laravel_scim_server

This commit is contained in:
Brady Wetherington
2026-05-04 18:39:48 +01:00
58 changed files with 1111 additions and 86 deletions
+1
View File
@@ -28,6 +28,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
- "8.2"
- "8.3"
- "8.4"
- "8.5"
name: PHP ${{ matrix.php-version }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
php-version:
- "8.3"
- "8.5"
name: PHP ${{ matrix.php-version }}
@@ -208,6 +208,30 @@ class AcceptanceController extends Controller
'qty' => $acceptance->qty ?? 1,
];
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$customFields = [];
$fields = $item->model->fieldset->fields
->where('show_in_email', true)
->where('field_encrypted', false);
foreach ($fields as $field) {
$dbColumn = $field->db_column;
$value = $item->{$dbColumn};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
if (! empty($customFields)) {
$data['custom_fields'] = $customFields;
}
}
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
@@ -1164,7 +1164,7 @@ class AssetsController extends Controller
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => e($request->input('note')),
'note' => $request->filled('note') ? e($request->input('note')) : null,
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
@@ -1205,7 +1205,7 @@ class AssetsController extends Controller
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag' => $asset->asset_tag], $asset->getErrors()));
return response()->json(Helper::formatStandardApiResponse('error', $payload, $asset->getErrors()));
}
/**
@@ -433,7 +433,7 @@ class LoginController extends Controller
$user->saveQuietly();
$request->session()->put('2fa_authed', $user->id);
return redirect()->route('home')->with('success', trans('auth/message.signin.success'));
return redirect()->intended()->with('success', trans('auth/message.signin.success'));
}
return redirect()->route('two-factor')->with('error', trans('auth/message.two_factor.invalid_code'));
+2 -2
View File
@@ -74,8 +74,7 @@ class SamlController extends Controller
public function login(Request $request)
{
$auth = $this->saml->getAuth();
$ssoUrl = $auth->login(null, [], false, false, false, false);
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
return redirect()->away($ssoUrl);
}
@@ -96,6 +95,7 @@ class SamlController extends Controller
$saml = $this->saml;
$auth = $saml->getAuth();
$saml_exception = false;
session()->put('url.intended', $request->post('RelayState'));
try {
$auth->processResponse();
} catch (\Exception $e) {
@@ -46,6 +46,7 @@ class CheckForTwoFactor
return $next($request);
}
redirect()->setIntendedUrl(url()->full()); // save the 'current' URL so we can send the user back to it?
// Otherwise make sure they're enrolled and show them the 2FA code screen
if ((auth()->user()->two_factor_secret != '') && (auth()->user()->two_factor_enrolled == '1')) {
return redirect()->route('two-factor')->with('info', trans('auth/message.two_factor.enter_two_factor_code'));
+1
View File
@@ -57,6 +57,7 @@ class ImageUploadRequest extends Request
* had it once to allow encoded image uploads.
*/
return [
'avatar' => 'auto',
'image' => 'auto',
'image_source' => 'auto',
];
+17
View File
@@ -58,10 +58,26 @@ class CheckinAssetMail extends BaseMailable
{
$this->item->load('status');
$fields = [];
$customFields = [];
// Check if the item has custom fields associated with it
if (($this->item->model) && ($this->item->model->fieldset)) {
$fields = $this->item->model->fieldset->fields;
foreach ($fields as $field) {
if (! $field->show_in_email || $field->field_encrypted == '1') {
continue;
}
$value = $this->item->{$field->db_column_name()};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
}
return new Content(
@@ -73,6 +89,7 @@ class CheckinAssetMail extends BaseMailable
'note' => $this->note,
'target' => $this->target,
'fields' => $fields,
'custom_fields' => $customFields,
'expected_checkin' => $this->expected_checkin,
],
);
+17
View File
@@ -75,6 +75,7 @@ class CheckoutAssetMail extends BaseMailable
$eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : '';
$req_accept = $this->requiresAcceptance();
$fields = [];
$customFields = [];
$name = null;
if ($this->target instanceof User) {
@@ -88,6 +89,21 @@ class CheckoutAssetMail extends BaseMailable
// Check if the item has custom fields associated with it
if (($this->item->model) && ($this->item->model->fieldset)) {
$fields = $this->item->model->fieldset->fields;
foreach ($fields as $field) {
if (! $field->show_in_email || $field->field_encrypted == '1') {
continue;
}
$value = $this->item->{$field->db_column_name()};
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $field->name,
'value' => $value,
];
}
}
}
$accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance);
@@ -101,6 +117,7 @@ class CheckoutAssetMail extends BaseMailable
'note' => $this->note,
'target' => $name,
'fields' => $fields,
'custom_fields' => $customFields,
'eula' => $eula,
'req_accept' => $req_accept,
'accept_url' => $accept_url,
+83 -18
View File
@@ -9,9 +9,11 @@ use App\Presenters\ActionlogPresenter;
use App\Presenters\Presentable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
@@ -53,6 +55,13 @@ class Actionlog extends SnipeModel
use Searchable;
/**
* Cache whether a model table has a company_id column.
*
* @var array<string, bool>
*/
protected static array $companyColumnCache = [];
/**
* The attributes that should be included when searching the model.
*
@@ -116,25 +125,81 @@ class Actionlog extends SnipeModel
public static function boot()
{
parent::boot();
static::creating(
function (self $actionlog) {
// If the admin is a superadmin, let's see if the target instead has a company.
if (auth()->user() && auth()->user()->isSuperUser()) {
if ($actionlog->target) {
$actionlog->company_id = $actionlog->target->company_id;
} elseif ($actionlog->item) {
$actionlog->company_id = $actionlog->item->company_id;
}
} elseif (auth()->user() && auth()->user()->company) {
$actionlog->company_id = auth()->user()->company_id;
}
if ($actionlog->action_date == '') {
$actionlog->action_date = Carbon::now();
}
static::creating(function (self $actionlog): void {
// Only resolve company_id if it was never explicitly set by the caller.
// Using array_key_exists on getRawOriginal() / getAttributes() lets us
// distinguish "was set to null intentionally" from "was never set at all".
if (! array_key_exists('company_id', $actionlog->getAttributes())) {
$actionlog->company_id = static::resolveCompanyIdFromAttributes(
$actionlog->target_type,
$actionlog->target_id,
$actionlog->item_type,
$actionlog->item_id,
);
}
);
if ($actionlog->action_date == '') {
$actionlog->action_date = Carbon::now();
}
});
}
/**
* Resolve the company_id for a new action log by querying the item model
* directly, bypassing all global scopes to avoid FMCS filtering issues.
*
* We intentionally prefer the item (asset, license, etc.) over the target
* (user, location) because FMCS visibility is based on who *owns* the item,
* not who it was checked out to. If the item has no company_id we fall back
* to the target so that logs on unowned items still get a company stamp where
* possible.
*
* This has to include an exception for the asset models table, since they are
* not company-constrained (on purpose.)
*/
protected static function resolveCompanyIdFromAttributes(
?string $targetType,
?int $targetId,
?string $itemType,
?int $itemId,
): ?int {
// Prefer the item (the thing being acted upon) for FMCS ownership.
$companyId = static::resolveCompanyIdFromModelClass($itemType, $itemId);
if ($companyId !== null) {
return $companyId;
}
// Fall back to target only when the item has no company_id.
return static::resolveCompanyIdFromModelClass($targetType, $targetId);
}
/**
* Resolve company_id from a model class and ID, but only if that model's
* table has a company_id column.
*/
protected static function resolveCompanyIdFromModelClass(?string $modelClass, ?int $id): ?int
{
if (! $modelClass || ! $id || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
return null;
}
/** @var Model $instance */
$instance = app($modelClass);
$table = $instance->getTable();
$hasCompanyColumn = static::$companyColumnCache[$table]
??= Schema::hasColumn($table, 'company_id');
if (! $hasCompanyColumn) {
return null;
}
return $modelClass::withoutGlobalScopes()
->whereKey($id)
->value('company_id');
}
/**
+14
View File
@@ -243,13 +243,27 @@ class CheckoutAcceptance extends Model
if ($data['item_serial'] != null) {
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
}
if (!empty($data['custom_fields']) && is_iterable($data['custom_fields'])) {
foreach ($data['custom_fields'] as $customField) {
$label = $customField['label'] ?? null;
$value = $customField['value'] ?? null;
if (($label !== null) && ($value !== null) && ($value !== '')) {
$pdf->writeHTML(e((string) $label) . ': ' . e((string) $value), true, 0, true, 0, '');
}
}
}
if (($data['qty'] != null) && ($data['qty'] > 1)) {
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
$pdf->writeHTML(trans('general.assignee').': '.e($data['assigned_to']).($data['employee_num'] ? ' ('.$data['employee_num'].')' : ''), true, 0, true, 0, '');
if ($data['email'] != null) {
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
+21
View File
@@ -177,6 +177,7 @@ trait Loggable
$log->note = $note;
$log->action_date = $action_date;
$log->quantity = $quantity;
$log->company_id = $this->resolveLoggableCompanyId();
$changed = [];
$array_to_flip = array_keys($fields_array);
@@ -221,6 +222,22 @@ trait Loggable
return $log;
}
/**
* Resolve the company_id that should be stamped on an action log entry.
*
* LicenseSeat does not carry a company_id directly it belongs to a License,
* so we fetch the parent license's company_id in that case. All other models
* that use the Loggable trait have a company_id column directly.
*/
private function resolveLoggableCompanyId(): ?int
{
if (static::class === LicenseSeat::class) {
return $this->license?->company_id;
}
return $this->company_id ?? null;
}
/**
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
*
@@ -267,6 +284,7 @@ trait Loggable
$log->location_id = null;
$log->note = $note;
$log->action_date = $action_date;
$log->company_id = $this->resolveLoggableCompanyId();
if (! $action_date) {
$log->action_date = date('Y-m-d H:i:s');
@@ -383,6 +401,8 @@ trait Loggable
$log->created_by = auth()->id();
$log->filename = $filename;
$log->action_date = date('Y-m-d H:i:s');
// Explicitly stamp company_id from the item being audited so FMCS scoping works correctly.
$log->company_id = $this->resolveLoggableCompanyId();
$log->logaction('audit');
$params = [
@@ -468,6 +488,7 @@ trait Loggable
$log->action_date = date('Y-m-d H:i:s');
$log->note = $note;
$log->created_by = $created_by;
$log->company_id = $this->resolveLoggableCompanyId();
$log->logaction('create');
$log->save();
@@ -33,6 +33,7 @@ class AcceptanceItemAcceptedNotification extends Notification
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->note = $params['note'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
@@ -76,6 +77,7 @@ class AcceptanceItemAcceptedNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'custom_fields' => $this->custom_fields,
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
])
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
$this->settings = Setting::getSettings();
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
/**
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'custom_fields' => $this->custom_fields,
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
])
->attach($pdf_path)
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Presenters;
use App\Models\CustomField;
final class CustomFieldPresenter
{
/**
* @return string[]
*/
public static function visibilityIconsArray(CustomField $field): array
{
$icons = [];
if ($field->display_checkout) {
$label = e(trans('admin/custom_fields/general.display_checkout'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_checkin) {
$label = e(trans('admin/custom_fields/general.display_checkin'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_audit) {
$label = e(trans('admin/custom_fields/general.display_audit'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->display_in_user_view) {
$label = e(trans('admin/custom_fields/general.display_in_user_view_table'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-user text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_listview) {
$label = e(trans('admin/custom_fields/general.show_in_listview_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-list text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_email) {
$label = e(trans('admin/custom_fields/general.show_in_email_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-envelope text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
if ($field->show_in_requestable_list) {
$label = e(trans('admin/custom_fields/general.show_in_requestable_list_short'));
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
}
return $icons;
}
public static function visibilityIcons(CustomField $field): string
{
return implode(' ', self::visibilityIconsArray($field));
}
}
+1 -1
View File
@@ -60,7 +60,7 @@
"paragonie/constant_time_encoding": "^2.3",
"paragonie/sodium_compat": "^1.19",
"phpdocumentor/reflection-docblock": "^5.1",
"phpspec/prophecy": "^1.10",
"phpspec/prophecy": "^1.26",
"pragmarx/google2fa-laravel": "^1.3",
"rollbar/rollbar-laravel": "^8.0",
"spatie/laravel-backup": "^9.0",
Generated
+15 -15
View File
@@ -4,7 +4,6 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "010e4f225586a6b3e7bc98f2f71cc0c6",
"packages": [
{
"name": "alek13/slack",
@@ -6071,30 +6070,31 @@
},
{
"name": "phpspec/prophecy",
"version": "v1.22.0",
"version": "v1.26.1",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
"reference": "35f1adb388946d92e6edab2aa2cb2b60e132ebd5"
"reference": "09c2e5949d676286358a62af818f8407167a9dd6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/35f1adb388946d92e6edab2aa2cb2b60e132ebd5",
"reference": "35f1adb388946d92e6edab2aa2cb2b60e132ebd5",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/09c2e5949d676286358a62af818f8407167a9dd6",
"reference": "09c2e5949d676286358a62af818f8407167a9dd6",
"shasum": ""
},
"require": {
"doctrine/instantiator": "^1.2 || ^2.0",
"php": "^7.4 || 8.0.* || 8.1.* || 8.2.* || 8.3.* || 8.4.*",
"phpdocumentor/reflection-docblock": "^5.2",
"sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0",
"sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0"
"php": "8.2.* || 8.3.* || 8.4.* || 8.5.*",
"phpdocumentor/reflection-docblock": "^5.2 || ^6.0",
"sebastian/comparator": "^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"sebastian/recursion-context": "^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.40",
"phpspec/phpspec": "^6.0 || ^7.0",
"phpstan/phpstan": "^2.1.13",
"phpunit/phpunit": "^8.0 || ^9.0 || ^10.0"
"php-cs-fixer/shim": "^3.93.1",
"phpspec/phpspec": "^6.0 || ^7.0 || ^8.0",
"phpstan/phpstan": "^2.1.13, <2.1.34 || ^2.1.39",
"phpunit/phpunit": "^11.0 || ^12.0 || ^13.0"
},
"type": "library",
"extra": {
@@ -6135,9 +6135,9 @@
],
"support": {
"issues": "https://github.com/phpspec/prophecy/issues",
"source": "https://github.com/phpspec/prophecy/tree/v1.22.0"
"source": "https://github.com/phpspec/prophecy/tree/v1.26.1"
},
"time": "2025-04-29T14:58:06+00:00"
"time": "2026-04-13T14:35:16+00:00"
},
{
"name": "phpstan/phpdoc-parser",
+5 -5
View File
@@ -1,11 +1,11 @@
<?php
return [
'app_version' => 'v8.4.1',
'full_app_version' => 'v8.4.1 - build 22183-g5898205480',
'build_version' => '22183',
'app_version' => 'v8.5.0-pre',
'full_app_version' => 'v8.5.0-pre - build 22392-g5014b1c459',
'build_version' => '22392',
'prerelease_version' => '',
'hash_version' => 'g5898205480',
'full_hash' => 'v8.4.1-901-g5898205480',
'hash_version' => 'g5014b1c459',
'full_hash' => 'v8.5.0-pre-207-g5014b1c459',
'branch' => 'develop',
];
@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Backfill action_logs.company_id only for legacy asset audits where the
* value is currently NULL.
*
* Audits are only recorded on assets, so this migration intentionally scopes
* to action_type='audit' and item_type=App\Models\Asset.
*
* Rows whose asset genuinely has no company (assets.company_id IS NULL) are
* left as NULL.
*/
return new class extends Migration
{
private const ASSET_CLASS = 'App\\Models\\Asset';
private const AUDIT_ACTION = 'audit';
public function up(): void
{
$this->updateAssetAuditLogs(DB::getDriverName());
}
public function down(): void
{
// This backfill is intentionally non-reversible — we cannot know which
// rows were NULL before the migration ran vs which were backfilled.
}
/**
* Stamp company_id for legacy audit rows tied to assets.
*/
private function updateAssetAuditLogs(string $driver): void
{
if ($driver === 'mysql' || $driver === 'mariadb') {
// MySQL/MariaDB supports UPDATE ... JOIN directly
DB::statement('
UPDATE action_logs al
INNER JOIN assets src
ON src.id = al.item_id
AND src.company_id IS NOT NULL
SET al.company_id = src.company_id
WHERE al.action_type = ?
AND al.item_type = ?
AND al.company_id IS NULL
AND al.deleted_at IS NULL
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
} else {
// SQLite / PostgreSQL: use a correlated subquery update
DB::statement('
UPDATE action_logs
SET company_id = (
SELECT src.company_id
FROM assets src
WHERE src.id = action_logs.item_id
AND src.company_id IS NOT NULL
LIMIT 1
)
WHERE action_type = ?
AND item_type = ?
AND company_id IS NULL
AND deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM assets src2
WHERE src2.id = action_logs.item_id
AND src2.company_id IS NOT NULL
)
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
}
}
};
+1 -1
View File
@@ -13,7 +13,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
@@ -479,6 +479,9 @@
<th data-switchable="true" data-visible="true">
{{ trans('admin/hardware/table.serial') }}
</th>
<th data-switchable="true" data-visible="true">
{{ trans('general.manufacturer') }}
</th>
<th data-switchable="true" data-visible="false">
{{ trans('admin/hardware/form.default_location') }}
</th>
@@ -552,6 +555,11 @@
<td>
{{ $asset->serial }}
</td>
<td>
@if (($asset->model) && ($asset->model->manufacturer))
{!! $asset->model->manufacturer->present()->formattedNameLink !!}
@endif
</td>
<td>
{!! ($asset->defaultLoc) ? $asset->defaultLoc->present()->formattedNameLink : '' !!}
+15 -4
View File
@@ -57,21 +57,32 @@
<fieldset name="login" aria-label="login">
<div class="form-group{{ $errors->has('username') ? ' has-error' : '' }}">
<label for="username">
<label for="username" class="control-label">
<x-icon type="user" />
{{ trans('admin/users/table.username') }}
</label>
<input class="form-control" placeholder="{{ trans('admin/users/table.username') }}" name="username" type="text" id="username" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autofocus>
<input class="form-control" placeholder="{{ trans('admin/users/table.username') }}" name="username" type="text" id="username" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autocapitalize="off" spellcheck="false" autofocus>
{!! $errors->first('username', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
<div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
<label for="password">
<label for="password" class="control-label">
<x-icon type="password" />
{{ trans('admin/users/table.password') }}
</label>
<input class="form-control" placeholder="{{ trans('admin/users/table.password') }}" name="password" type="password" id="password" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}">
<div class="input-group">
<input class="form-control" placeholder="{{ trans('admin/users/table.password') }}" name="password" type="password" id="password-field" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autocorrect="off" autocapitalize="off" spellcheck="false">
<span class="input-group-addon">
<i data-toggle="#password-field" class="fa fa-fw fa-eye toggle-password" aria-hidden="true"></i>
<span class="sr-only">Toggle password visibility</span>
</span>
</div>
{!! $errors->first('password', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
<div class="form-group">
<label class="form-control">
<input name="remember" type="checkbox" value="1" id="remember"> {{ trans('auth/general.remember_me') }}
@@ -1 +1,19 @@
<i class="fa-regular fa-2x fa-square-caret-right pull-right hidden-xs" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
@props([
'hideOnXs' => false,
])
<button
type="button"
id="expand-info-panel-button"
data-tooltip="true"
title="{{ trans('button.show_hide_info') }}"
aria-label="{{ trans('button.show_hide_info') }}"
style="background: none; border: 0; padding: 0;"
{{ $attributes->class([
'fa-regular',
'fa-2x',
'fa-square-caret-right',
'pull-right',
'hidden-xs' => $hideOnXs,
]) }}
></button>
+1 -1
View File
@@ -9,7 +9,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -7,7 +7,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -8,7 +8,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
@section('content')
+1 -1
View File
@@ -9,7 +9,7 @@
@endsection
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -9,7 +9,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -9,7 +9,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -7,7 +7,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+4 -3
View File
@@ -30,7 +30,7 @@
<div class="form-group {{ $errors->has('audit_by_field') ? 'error' : '' }}">
<label for="audit_by_field" class="col-md-3 control-label" id="audit_by_field">{{ trans('general.audit_by_field') }}</label>
<div class="col-md-8">
<select name="audit_by_field" data-minimum-results-for-search="Infinity" id="audit_by_field" class="form-control select2" aria-label="audit_by_field" required>
<select name="audit_by_field" data-minimum-results-for-search="Infinity" id="audit_by_field" style="width: 100% !important" class="form-control select2" aria-label="audit_by_field" required>
<option value="asset_tag">{{ trans('general.asset_tag') }}</option>
<option value="serial" {{ (($settings->unique_serial != '1') ? 'disabled' : '') }}>{{ trans('general.serial_number') }}</option>
</select>
@@ -45,10 +45,11 @@
</div>
<!-- Tag/Serial -->
<div class="form-group {{ $errors->has('asset_tag') ? 'error' : '' }}">
<div class="form-group {{ $errors->has('audit_key') ? 'error' : '' }}">
<label for="audit_key" class="col-md-3 control-label" id="audit_key_label">{{ trans('general.asset_tag') }}</label>
<div class="col-md-8">
<input type="text" class="form-control" name="audit_key" required value="{{ old('audit_key') }}">
<input type="text" class="form-control" name="audit_key" id="audit_key" required
value="{{ old('audit_key') }}">
{!! $errors->first('audit_key', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
+2 -2
View File
@@ -7,7 +7,7 @@
@stop
@section('header_right')
<x-button.info-panel-toggle/>
<x-button.info-panel-toggle hide-on-xs/>
@endsection
{{-- Page content --}}
@@ -240,7 +240,7 @@
@endif
@if($asset->purchase_date || $asset->asset_eol_date || $asset->depreciated_date() || $asset->warranty_expires)
@if(($asset->purchase_date && $asset->asset_eol_date) || $asset->depreciated_date() || $asset->warranty_expires)
<x-well class="well-sm">
@if($asset->purchase_date && $asset->asset_eol_date)
<x-progressbar use_well="false" columns="12" text="{{ trans('general.device_eol') }}" :percent="$asset->eolProgressPercent()">
+12 -1
View File
@@ -64,7 +64,18 @@
{{-- Javascript files --}}
<script src="{{ url(mix('js/dist/all.js')) }}" nonce="{{ csrf_token() }}"></script>
<script>
$(".toggle-password").click(function () {
$(this).toggleClass("fa-eye fa-eye-slash");
var input = $($(this).attr("data-toggle"));
if (input.attr("type") === "password") {
input.attr("type", "text");
}
else {
input.attr("type", "password");
}
});
</script>
@stack('js')
</body>
+1 -1
View File
@@ -8,7 +8,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -10,7 +10,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
@@ -37,11 +37,13 @@
@if (isset($status))
| **{{ trans('general.status') }}** | {{ $status }} |
@endif
@foreach($fields as $field)
@if (($item->{ $field->db_column_name() }!='') && ($field->show_in_email) && ($field->field_encrypted=='0'))
| **{{ $field->name }}** | {{ $item->{ $field->db_column_name() } }} |
@if (!empty($custom_fields))
@foreach($custom_fields as $customField)
@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '')
| **{{ $customField['label'] }}** | {{ $customField['value'] }} |
@endif
@endforeach
@endif
@if ($admin)
| **{{ trans('general.administrator') }}** | {{ $admin->display_name }} |
@endif
@@ -40,11 +40,13 @@
@if ((isset($expected_checkin)) && ($expected_checkin!=''))
| **{{ trans('mail.expecting_checkin_date') }}** | {{ $expected_checkin }} |
@endif
@foreach($fields as $field)
@if (($item->{ $field->db_column_name() }!='') && ($field->show_in_email) && ($field->field_encrypted=='0'))
| **{{ $field->name }}** | {{ $item->{ $field->db_column_name() } }} |
@if (!empty($custom_fields))
@foreach($custom_fields as $customField)
@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '')
| **{{ $customField['label'] }}** | {{ $customField['value'] }} |
@endif
@endforeach
@endif
@if ($admin)
| **{{ trans('general.administrator') }}** | {{ $admin->display_name }} |
@endif
+1 -1
View File
@@ -10,7 +10,7 @@ use Carbon\Carbon;
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -9,7 +9,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
@section('content')
@@ -84,8 +84,19 @@
@endif
@if ($field->help_text!='')
<p class="help-block">{{ $field->help_text }}</p>
@if (count(\App\Presenters\CustomFieldPresenter::visibilityIconsArray($field)) > 0)
@if ($field->help_text != '')
<p class="help-block">
{{ $field->help_text }}
<br>{!! \App\Presenters\CustomFieldPresenter::visibilityIcons($field) !!}
</p>
@else
<div class="help-block">
{!! \App\Presenters\CustomFieldPresenter::visibilityIcons($field) !!}
</div>
@endif
@elseif ($field->help_text != '')
<p class="help-block">{{ $field->help_text }}</p>
@endif
<?php
+1 -1
View File
@@ -8,7 +8,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
@@ -43,6 +43,13 @@
@if (isset($qty))
| **{{ trans('general.qty') }}** | {{ $qty }} |
@endif
@if (!empty($custom_fields))
@foreach($custom_fields as $customField)
@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '')
| **{{ $customField['label'] }}** | {{ $customField['value'] }} |
@endif
@endforeach
@endif
@endcomponent
{{ trans('mail.best_regards') }}
+1 -1
View File
@@ -6,7 +6,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -10,7 +10,7 @@
@stop
@section('header_right')
<i class="fa-regular fa-2x fa-square-caret-right pull-right" id="expand-info-panel-button" data-tooltip="true" title="{{ trans('button.show_hide_info') }}"></i>
<x-button.info-panel-toggle/>
@endsection
{{-- Page content --}}
+1 -1
View File
@@ -612,7 +612,7 @@
@foreach ($groups as $id => $group)
<option value="{{ $id }}"
{{ ($userGroups->keys()->contains($id) ? ' selected="selected"' : '') }}>
{{ ($userGroups->keys()->contains($id) ? ' selected' : '') }}>
{{ $group }}
</option>
@endforeach
+1 -1
View File
@@ -7,7 +7,7 @@
@stop
@section('header_right')
<x-button.info-panel-toggle/>
<x-button.info-panel-toggle hide-on-xs/>
@endsection
{{-- Page content --}}
+18
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Api;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Client;
/*
|--------------------------------------------------------------------------
@@ -25,6 +26,23 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
], 404);
});
Route::withoutMiddleware(['api'])->get('/client', function () {
$client = Client::firstOrCreate(
['redirect' => 'com.grokability.snipeitmobile://home'],
[
'name' => 'Snipe-IT Mobile App',
'user_id' => null,
'secret' => '',
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
]);
return response()->json([
'client_id' => $client->id,
]);
});
/**
* Account routes
*/
@@ -0,0 +1,147 @@
<?php
namespace Tests\Feature\ActionLogs;
use App\Models\Asset;
use App\Models\Company;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Verifies the backfill migration logic that stamps action_logs.company_id
* for legacy asset audit rows where the company is currently NULL.
*
* Rather than running the migration class directly (which would conflict with
* LazilyRefreshDatabase), we replicate the exact UPDATE SQL used by the
* migration and assert on the resulting rows.
*/
class ActionlogCompanyIdBackfillTest extends TestCase
{
private const ASSET_CLASS = 'App\\Models\\Asset';
private const AUDIT_ACTION = 'audit';
/**
* Insert an action_log row bypassing Eloquent so that company_id stays NULL,
* simulating a historical record written before FMCS stamping was added.
*/
private function insertLegacyLog(array $attributes): int
{
return DB::table('action_logs')->insertGetId(array_merge([
'action_type' => self::AUDIT_ACTION,
'item_type' => self::ASSET_CLASS,
'item_id' => null,
'company_id' => null,
'created_at' => now(),
'updated_at' => now(),
], $attributes));
}
/**
* Run the same UPDATE logic the migration uses.
*/
private function runBackfill(): void
{
$driver = DB::getDriverName();
if ($driver === 'mysql' || $driver === 'mariadb') {
DB::statement('
UPDATE action_logs al
INNER JOIN assets src ON src.id = al.item_id AND src.company_id IS NOT NULL
SET al.company_id = src.company_id
WHERE al.action_type = ?
AND al.item_type = ?
AND al.company_id IS NULL
AND al.deleted_at IS NULL
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
} else {
DB::statement('
UPDATE action_logs
SET company_id = (
SELECT src.company_id FROM assets src
WHERE src.id = action_logs.item_id AND src.company_id IS NOT NULL
LIMIT 1
)
WHERE action_type = ?
AND item_type = ?
AND company_id IS NULL
AND deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM assets src2
WHERE src2.id = action_logs.item_id AND src2.company_id IS NOT NULL
)
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
}
}
// ──────────────────────────────────────────────────────────────────────────
public function test_backfill_populates_company_id_for_asset_audit(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $company->id]);
$logId = $this->insertLegacyLog(['item_type' => self::ASSET_CLASS, 'item_id' => $asset->id]);
$this->runBackfill();
$this->assertDatabaseHas('action_logs', [
'id' => $logId,
'company_id' => $company->id,
]);
}
public function test_backfill_does_not_overwrite_existing_company_id(): void
{
$company = Company::factory()->create();
$otherCompany = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $otherCompany->id]);
// Row already has a company_id — the backfill must leave it alone
$logId = $this->insertLegacyLog([
'item_type' => self::ASSET_CLASS,
'item_id' => $asset->id,
'company_id' => $company->id,
]);
$this->runBackfill();
$this->assertDatabaseHas('action_logs', [
'id' => $logId,
'company_id' => $company->id, // unchanged
]);
}
public function test_backfill_leaves_null_when_item_has_no_company(): void
{
$asset = Asset::factory()->create(['company_id' => null]);
$logId = $this->insertLegacyLog(['item_type' => self::ASSET_CLASS, 'item_id' => $asset->id]);
$this->runBackfill();
$this->assertDatabaseHas('action_logs', [
'id' => $logId,
'company_id' => null, // item has no company, so log stays null
]);
}
public function test_backfill_ignores_non_audit_action_logs(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $company->id]);
$logId = $this->insertLegacyLog([
'action_type' => 'checkout',
'item_type' => self::ASSET_CLASS,
'item_id' => $asset->id,
]);
$this->runBackfill();
$this->assertDatabaseHas('action_logs', [
'id' => $logId,
'company_id' => null,
]);
}
}
@@ -0,0 +1,364 @@
<?php
namespace Tests\Feature\ActionLogs;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Location;
use App\Models\Statuslabel;
use App\Models\User;
use Tests\TestCase;
/**
* Confirms that action_logs.company_id is correctly stamped for every
* logged event so that FMCS scoping works correctly.
*
* Each test creates an item belonging to a specific company and triggers the
* relevant action, then asserts that the resulting action log row carries the
* same company_id as the item.
*/
class ActionlogCompanyIdTest extends TestCase
{
// -------------------------------------------------------------------------
// Asset events
// -------------------------------------------------------------------------
public function test_asset_audit_log_stores_the_assets_company_id(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $company->id]);
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.audit', $asset), ['note' => 'audit test'])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'audit',
'company_id' => $company->id,
]);
}
public function test_asset_checkout_to_user_log_stores_the_assets_company_id(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $company->id]);
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.checkout', $asset), [
'checkout_to_type' => 'user',
'assigned_user' => $user->id,
'status_id' => $asset->status_id,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
public function test_asset_checkout_to_location_log_stores_the_assets_company_id(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->create(['company_id' => $company->id]);
$location = Location::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.checkout', $asset), [
'checkout_to_type' => 'location',
'assigned_location' => $location->id,
'status_id' => $asset->status_id,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
public function test_asset_checkin_log_stores_the_assets_company_id(): void
{
$company = Company::factory()->create();
$asset = Asset::factory()->assignedToUser()->create(['company_id' => $company->id]);
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.checkin', $asset))
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'checkin from',
'company_id' => $company->id,
]);
}
public function test_asset_create_log_stores_the_assets_company_id(): void
{
$company = Company::factory()->create();
$admin = User::factory()->superuser()->create();
$model = AssetModel::factory()->create();
$status = Statuslabel::factory()->readyToDeploy()->create();
$tag = 'COMPANY-ID-TEST-'.uniqid();
$this->actingAsForApi($admin)
->postJson(route('api.assets.store'), [
'asset_tag' => $tag,
'model_id' => $model->id,
'status_id' => $status->id,
'company_id' => $company->id,
])
->assertStatusMessageIs('success');
$asset = Asset::where('asset_tag', $tag)->firstOrFail();
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'create',
'company_id' => $company->id,
]);
}
// -------------------------------------------------------------------------
// Accessory events
// -------------------------------------------------------------------------
public function test_accessory_checkout_log_stores_the_accessorys_company_id(): void
{
$company = Company::factory()->create();
$accessory = Accessory::factory()->create(['company_id' => $company->id]);
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.accessories.checkout', $accessory), [
'assigned_user' => $user->id,
'checkout_to_type' => 'user',
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Accessory::class,
'item_id' => $accessory->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
public function test_accessory_checkin_log_stores_the_accessorys_company_id(): void
{
$company = Company::factory()->create();
$accessory = Accessory::factory()->checkedOutToUser()->create(['company_id' => $company->id]);
$admin = User::factory()->superuser()->create();
$checkoutRecord = $accessory->checkouts->first();
$this->actingAsForApi($admin)
->postJson(route('api.accessories.checkin', $checkoutRecord))
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Accessory::class,
'item_id' => $accessory->id,
'action_type' => 'checkin from',
'company_id' => $company->id,
]);
}
// -------------------------------------------------------------------------
// Consumable events
// -------------------------------------------------------------------------
public function test_consumable_checkout_log_stores_the_consumables_company_id(): void
{
$company = Company::factory()->create();
$consumable = Consumable::factory()->create(['company_id' => $company->id]);
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.consumables.checkout', $consumable), [
'assigned_to' => $user->id,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Consumable::class,
'item_id' => $consumable->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
// -------------------------------------------------------------------------
// Component events
// -------------------------------------------------------------------------
public function test_component_checkout_log_stores_the_components_company_id(): void
{
$company = Company::factory()->create();
$component = Component::factory()->create(['company_id' => $company->id]);
$asset = Asset::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.components.checkout', $component->id), [
'assigned_to' => $asset->id,
'assigned_qty' => 1,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Component::class,
'item_id' => $component->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
public function test_component_checkin_log_stores_the_components_company_id(): void
{
$company = Company::factory()->create();
$component = Component::factory()->create(['company_id' => $company->id]);
$asset = Asset::factory()->create();
$admin = User::factory()->superuser()->create();
// Check out first
$this->actingAsForApi($admin)
->postJson(route('api.components.checkout', $component->id), [
'assigned_to' => $asset->id,
'assigned_qty' => 1,
])
->assertStatusMessageIs('success');
$pivotId = $component->assets()->first()->pivot->id;
$this->actingAsForApi($admin)
->postJson(route('api.components.checkin', $pivotId), [
'checkin_qty' => 1,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Component::class,
'item_id' => $component->id,
'action_type' => 'checkin from',
'company_id' => $company->id,
]);
}
// -------------------------------------------------------------------------
// License events
// -------------------------------------------------------------------------
public function test_license_checkout_log_stores_the_licenses_company_id(): void
{
$company = Company::factory()->create();
$license = License::factory()->create(['company_id' => $company->id]);
$seat = $license->freeSeats()->first();
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->patchJson(route('api.licenses.seats.update', [$license->id, $seat->id]), [
'assigned_to' => $user->id,
])
->assertStatusMessageIs('success');
// The log is stored against the License (item_type), not the LicenseSeat
$this->assertDatabaseHas('action_logs', [
'item_type' => License::class,
'item_id' => $license->id,
'action_type' => 'checkout',
'company_id' => $company->id,
]);
}
public function test_license_checkin_log_stores_the_licenses_company_id(): void
{
$company = Company::factory()->create();
$license = License::factory()->create(['company_id' => $company->id]);
$seat = $license->freeSeats()->first();
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
// Check out first
$seat->assigned_to = $user->id;
$seat->save();
$this->actingAsForApi($admin)
->patchJson(route('api.licenses.seats.update', [$license->id, $seat->id]), [
'assigned_to' => null,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => License::class,
'item_id' => $license->id,
'action_type' => 'checkin from',
'company_id' => $company->id,
]);
}
// -------------------------------------------------------------------------
// Null company_id — items without a company should log null, not an error
// -------------------------------------------------------------------------
public function test_asset_audit_log_company_id_is_null_when_asset_has_no_company(): void
{
$asset = Asset::factory()->create(['company_id' => null]);
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.audit', $asset), ['note' => 'no company'])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'audit',
'company_id' => null,
]);
}
public function test_asset_checkout_log_company_id_is_null_when_asset_has_no_company(): void
{
$asset = Asset::factory()->create(['company_id' => null]);
$user = User::factory()->create();
$admin = User::factory()->superuser()->create();
$this->actingAsForApi($admin)
->postJson(route('api.asset.checkout', $asset), [
'checkout_to_type' => 'user',
'assigned_user' => $user->id,
'status_id' => $asset->status_id,
])
->assertStatusMessageIs('success');
$this->assertDatabaseHas('action_logs', [
'item_type' => Asset::class,
'item_id' => $asset->id,
'action_type' => 'checkout',
'company_id' => null,
]);
}
}
@@ -6,9 +6,12 @@ use App\Events\CheckoutAccepted;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\CustomField;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class AssetAcceptanceTest extends TestCase
@@ -204,6 +207,45 @@ class AssetAcceptanceTest extends TestCase
);
}
public function test_acceptance_email_includes_custom_fields_marked_show_in_email_and_not_encrypted(): void
{
Event::fake([CheckoutAccepted::class]);
Notification::fake();
$this->settings->enableAlertEmail();
$customField = CustomField::factory()->create([
'name' => 'Cost Center',
'show_in_email' => '1',
'field_encrypted' => '0',
])->fresh();
$asset = Asset::factory()->hasMultipleCustomFields([$customField])->create();
$asset->{$customField->db_column} = 'ENG-42';
$asset->save();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($asset, 'checkoutable')
->create();
$this->actingAs($checkoutAcceptance->assignedTo)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertRedirectToRoute('account.accept')
->assertSessionHas('success');
Notification::assertSentTo(
$checkoutAcceptance,
function (AcceptanceItemAcceptedNotification $notification) {
$rendered = $notification->toMail()->render();
return str_contains($rendered, 'Cost Center')
&& str_contains($rendered, 'ENG-42');
}
);
}
public function test_admin_can_complete_sign_in_place_acceptance_and_is_redirected_to_selected_destination()
{
Event::fake([CheckoutAccepted::class]);
@@ -7,6 +7,7 @@ use App\Mail\CheckinAssetMail;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\Consumable;
use App\Models\CustomField;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
@@ -99,6 +100,32 @@ class EmailNotificationsToUserUponCheckinTest extends TestCase
Mail::assertNothingSent();
}
public function test_checkin_email_includes_custom_fields_marked_show_in_email_and_not_encrypted()
{
$customField = CustomField::factory()->create([
'name' => 'Cost Center',
'show_in_email' => '1',
'field_encrypted' => '0',
])->fresh();
$user = User::factory()->create();
$asset = Asset::factory()->hasMultipleCustomFields([$customField])->assignedToUser($user)->create();
$asset->{$customField->db_column} = 'ENG-42';
$asset->save();
$asset->model->category->update(['checkin_email' => true]);
$this->fireCheckInEvent($asset, $user);
Mail::assertSent(CheckinAssetMail::class, function (CheckinAssetMail $mail) use ($user) {
$rendered = $mail->render();
return $mail->hasTo($user->email)
&& str_contains($rendered, 'Cost Center')
&& str_contains($rendered, 'ENG-42');
});
}
private function fireCheckInEvent($asset, $user): void
{
event(new CheckoutableCheckedIn(
@@ -8,6 +8,7 @@ use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\CheckoutAcceptance;
use App\Models\CustomField;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Group;
@@ -106,6 +107,35 @@ class EmailNotificationsToUserUponCheckoutTest extends TestCase
$this->assertUserSentEmail();
}
public function test_email_includes_custom_fields_marked_show_in_email_and_not_encrypted()
{
$customField = CustomField::factory()->create([
'name' => 'Cost Center',
'show_in_email' => '1',
'field_encrypted' => '0',
])->fresh();
$asset = Asset::factory()->hasMultipleCustomFields([$customField])->create();
$asset->{$customField->db_column} = 'ENG-42';
$asset->save();
$this->category = $asset->model->category;
$this->asset = $asset;
$this->user = User::factory()->create();
$this->category->update(['checkin_email' => true]);
$this->fireCheckoutEvent();
Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) {
$rendered = $mail->render();
return $mail->hasTo($this->user->email)
&& str_contains($rendered, 'Cost Center')
&& str_contains($rendered, 'ENG-42');
});
}
public function test_handles_user_not_having_email_address_set()
{
$this->category->update(['checkin_email' => true]);
@@ -90,8 +90,10 @@ class ActivityReportTest extends TestCase
// I don't love this, since it doesn't test that we're actually storing the company ID appropriately
// but it's better than what we had
$response = $this->actingAsForApi($userInCompanyA)
->getJson(route('api.activity.index'))
$this->actingAsForApi($userInCompanyA)
->getJson(route('api.activity.index', [
'action_type' => 'update',
]))
->assertOk()
->assertJsonStructure([
'rows',
@@ -100,7 +102,9 @@ class ActivityReportTest extends TestCase
$this->actingAsForApi($userInCompanyB)
->getJson(
route('api.activity.index'))
route('api.activity.index', [
'action_type' => 'update',
]))
->assertOk()
->assertJsonStructure([
'rows',
+11 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Department;
use App\Models\User;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
@@ -64,7 +65,7 @@ class CreateUserTest extends TestCase
{
Notification::fake();
$this->actingAsForApi(User::factory()->createUsers()->create())
$response = $this->actingAsForApi(User::factory()->createUsers()->create())
->postJson(route('api.users.store'), [
'first_name' => 'Test First Name',
'last_name' => 'Test Last Name',
@@ -74,6 +75,7 @@ class CreateUserTest extends TestCase
'activated' => '1',
'email' => 'foo@example.org',
'notes' => 'Test Note',
'avatar' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsAQMAAADXeXeBAAAABlBMVEX+AAD///+KQee0AAAACXBIWXMAAAsSAAALEgHS3X78AAAAB3RJTUUH5QQbCAoNcoiTQAAAACZJREFUaN7twTEBAAAAwqD1T20JT6AAAAAAAAAAAAAAAAAAAICnATvEAAEnf54JAAAAAElFTkSuQmCC',
])
->assertStatusMessageIs('success')
->assertOk();
@@ -85,10 +87,17 @@ class CreateUserTest extends TestCase
'activated' => '1',
'email' => 'foo@example.org',
'notes' => 'Test Note',
]);
Notification::assertNothingSent();
$user = User::findOrFail($response['payload']['id']);
// assert against resized hash
$this->assertEquals(
'db2e13ba04318c99058ca429d67777322f48566b',
sha1(Storage::disk('public')->get(app('users_upload_path').$user->avatar))
);
}
public function test_can_create_and_notify_user()
@@ -9,6 +9,7 @@ use App\Models\Group;
use App\Models\Location;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class UpdateUserTest extends TestCase
@@ -51,6 +52,7 @@ class UpdateUserTest extends TestCase
'vip' => true,
'start_date' => '2021-08-01',
'end_date' => '2025-12-31',
'avatar' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsAQMAAADXeXeBAAAABlBMVEX+AAD///+KQee0AAAACXBIWXMAAAsSAAALEgHS3X78AAAAB3RJTUUH5QQbCAoNcoiTQAAAACZJREFUaN7twTEBAAAAwqD1T20JT6AAAAAAAAAAAAAAAAAAAICnATvEAAEnf54JAAAAAElFTkSuQmCC',
])
->assertOk()
->assertStatus(200)
@@ -80,6 +82,12 @@ class UpdateUserTest extends TestCase
$this->assertEquals('2021-08-01', $user->start_date, 'Start date was not updated');
$this->assertEquals('2025-12-31', $user->end_date, 'End date was not updated');
// assert against resized hash
$this->assertEquals(
'db2e13ba04318c99058ca429d67777322f48566b',
sha1(Storage::disk('public')->get(app('users_upload_path').$user->avatar))
);
// `groups` can be an id or array or ids
$this->patch(route('api.users.update', $user), ['groups' => [$groupA->id, $groupB->id]]);
@@ -127,6 +135,7 @@ class UpdateUserTest extends TestCase
'vip' => true,
'start_date' => '2021-08-01',
'end_date' => '2025-12-31',
'avatar' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsAQMAAADXeXeBAAAABlBMVEX+AAD///+KQee0AAAACXBIWXMAAAsSAAALEgHS3X78AAAAB3RJTUUH5QQbCAoNcoiTQAAAACZJREFUaN7twTEBAAAAwqD1T20JT6AAAAAAAAAAAAAAAAAAAICnATvEAAEnf54JAAAAAElFTkSuQmCC',
])
->assertOk()
->assertStatus(200)
@@ -156,6 +165,12 @@ class UpdateUserTest extends TestCase
$this->assertEquals('2021-08-01', $user->start_date, 'Start date was not updated');
$this->assertEquals('2025-12-31', $user->end_date, 'End date was not updated');
// assert against resized hash
$this->assertEquals(
'db2e13ba04318c99058ca429d67777322f48566b',
sha1(Storage::disk('public')->get(app('users_upload_path').$user->avatar))
);
// `groups` can be an id or array or ids
$this->patch(route('api.users.update', $user), ['groups' => [$groupA->id, $groupB->id]]);