Add audit logging functionality with database schema and event handling
Some checks failed
🧪✨ Tests Workflow / 🛡️ 🔒 License Check (push) Successful in 2m23s
🧪✨ Tests Workflow / 🛡️ 🔒 Library Audit (push) Successful in 2m35s
🧪✨ Tests Workflow / 📝 ✨ Code Lint (push) Successful in 2m25s
🧪✨ Tests Workflow / 🧪 ✨ Database Migrations (push) Successful in 2m39s
🧪✨ Tests Workflow / 🐙 🔍 Code Sniffer (push) Failing after 2m26s
🧪✨ Tests Workflow / 🧪 ✅ Unit Tests (push) Failing after 1m5s

This commit is contained in:
2026-01-10 09:51:35 -05:00
parent 7c70cb245d
commit a1d7512ebc
27 changed files with 428 additions and 65 deletions

View File

@@ -83,9 +83,9 @@ services:
- "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)" - "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)"
- "traefik.http.routers.api.tls=true" - "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.service=api" - "traefik.http.routers.api.service=api"
- "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz" # - "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz"
- "traefik.http.services.api.loadbalancer.healthcheck.interval=5s" # - "traefik.http.services.api.loadbalancer.healthcheck.interval=5s"
- "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s" # - "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s"
- "traefik.tcp.services.api.loadbalancer.server.port=9001" - "traefik.tcp.services.api.loadbalancer.server.port=9001"
- "traefik.http.services.api.loadbalancer.server.port=9501" - "traefik.http.services.api.loadbalancer.server.port=9501"
- "traefik.tcp.routers.grpc.entrypoints=grpc" - "traefik.tcp.routers.grpc.entrypoints=grpc"

View File

@@ -0,0 +1 @@
drop table if exists audit_logs;

View File

@@ -0,0 +1,14 @@
create table audit_logs
(
id VARCHAR(26) not null
constraint audit_logs_pkey
primary key,
user_id integer,
action varchar(255) not null,
timestamp timestamptz default current_timestamp,
details jsonb
);
create index idx_audit_logs_action on audit_logs (action);
create index idx_audit_logs_user_id on audit_logs (user_id);
create index idx_audit_logs_timestamp on audit_logs (timestamp);

View File

@@ -7,6 +7,7 @@ namespace Siteworxpro\App\Cli\Commands\OAuth;
use Siteworxpro\App\Cli\ClimateOutput; use Siteworxpro\App\Cli\ClimateOutput;
use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand; use Siteworxpro\App\CommandBus\Commands\CreateClient as CreateClientCommand;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\Models\Enums\ClientGrant as ClientGrantAlias;
use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\CommandBus; use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -37,8 +38,13 @@ class CreateClient extends \Siteworxpro\App\Cli\Commands\Command
$question->setMultiselect(true); $question->setMultiselect(true);
$grants = $this->helper->ask($input, $output, $question); $grants = $this->helper->ask($input, $output, $question);
$grantsEnum = [];
$command = new CreateClientCommand($clientName, $grants, $clientDescription); foreach ($grants as $grant) {
$grantsEnum[] = ClientGrantAlias::from($grant);
}
$command = new CreateClientCommand($clientName, $grantsEnum, $clientDescription);
try { try {
/** @var Client $client */ /** @var Client $client */
$client = CommandBus::handle($command); $client = CommandBus::handle($command);

View File

@@ -6,9 +6,11 @@ namespace Siteworxpro\App\Cli\Commands\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Siteworxpro\App\Cli\Commands\Command; use Siteworxpro\App\Cli\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\CreateUser;
use Siteworxpro\App\Models\ClientUser; use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User; use Siteworxpro\App\Models\User;
use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command as SCommand; use Symfony\Component\Console\Command\Command as SCommand;
use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Helper\QuestionHelper;
@@ -76,19 +78,16 @@ class Add extends Command
$lastNameQuestion = new QuestionInput('Enter the user\'s last name: '); $lastNameQuestion = new QuestionInput('Enter the user\'s last name: ');
$lastName = $helper->ask($input, $output, $lastNameQuestion); $lastName = $helper->ask($input, $output, $lastNameQuestion);
$user = new User(); $createUserCommand = new CreateUser($client, $email, $password, $firstName, $lastName);
$user->email = $email;
$user->password = $password;
$user->first_name = $firstName;
$user->last_name = $lastName;
$user->save();
$clientUser = new ClientUser(); /** @var User $user */
$clientUser->client_id = $client->id; $user = CommandBus::handle($createUserCommand);
$clientUser->user_id = $user->id;
$clientUser->save();
$output->green('User added and associated with the client successfully.'); $output->green('User added and associated with the client successfully.');
$output->info('User Details:');
$output->out("ID: $user->id");
$output->out("Email: $user->email");
$output->out("Name: $user->first_name $user->last_name");
return SCommand::SUCCESS; return SCommand::SUCCESS;
} }

View File

@@ -2,24 +2,32 @@
namespace Siteworxpro\App\CommandBus\Commands; namespace Siteworxpro\App\CommandBus\Commands;
use Siteworxpro\App\Models\Enums\ClientGrant;
readonly class CreateClient extends Command readonly class CreateClient extends Command
{ {
private const array VALID_GRANTS = [ /**
'authorization_code', * @param string $clientName
'password', * @param array<ClientGrant> $clientGrants
'client_credentials', * @param string $clientDescription
'refresh_token', */
'implicit',
];
public function __construct( public function __construct(
private string $clientName, private string $clientName,
private array $clientGrants = [], private array $clientGrants = [],
private string $clientDescription = '' private string $clientDescription = ''
) { ) {
foreach ($this->clientGrants as $grant) { foreach ($this->clientGrants as $grant) {
if (!in_array($grant, self::VALID_GRANTS, true)) { if ($grant instanceof ClientGrant === false) {
throw new \InvalidArgumentException("Invalid grant type: $grant"); throw new \InvalidArgumentException(
sprintf(
'Invalid client grant provided: %s. Valid grants are: %s',
is_string($grant) ? $grant : gettype($grant),
implode(', ', array_map(
fn(ClientGrant $validGrant) => $validGrant->value,
ClientGrant::getValidGrants()
))
)
);
} }
} }
} }

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
use Siteworxpro\App\OAuth\Entities\Client;
readonly class CreateUser extends Command
{
private string $email;
public function __construct(
private Client $client,
string $email,
private string $password,
private string $firstName,
private string $lastName,
) {
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address.');
}
$this->email = $email;
}
public function getEmail(): string
{
return $this->email;
}
public function getPassword(): string
{
return $this->password;
}
public function getFirstName(): string
{
return $this->firstName;
}
public function getLastName(): string
{
return $this->lastName;
}
public function getClient(): Client
{
return $this->client;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\CreateUser;
use Siteworxpro\App\Models\ClientUser;
use Siteworxpro\App\Models\User;
#[HandlesCommand(CreateUser::class)]
class CreateUserHandler
{
public function __invoke(CreateUser $command): User
{
$user = User::create([
'email' => $command->getEmail(),
'password' => password_hash($command->getPassword(), PASSWORD_BCRYPT),
'first_name' => $command->getFirstName(),
'last_name' => $command->getLastName(),
]);
$clientUser = new ClientUser();
$clientUser->client_id = $command->getClient()->id;
$clientUser->user_id = $user->id;
$clientUser->save();
return $user;
}
}

View File

@@ -10,6 +10,7 @@ use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException; use Siteworxpro\App\CommandBus\Exceptions\CommandHandlerException;
use Siteworxpro\App\CommandBus\Handlers\CommandHandler; use Siteworxpro\App\CommandBus\Handlers\CommandHandler;
use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\OAuth\Entities\ClientCapabilities;
#[HandlesCommand(\Siteworxpro\App\CommandBus\Commands\CreateClient::class)] #[HandlesCommand(\Siteworxpro\App\CommandBus\Commands\CreateClient::class)]
class CreateClient extends CommandHandler class CreateClient extends CommandHandler
@@ -24,6 +25,7 @@ class CreateClient extends CommandHandler
$client->name = $command->getClientName(); $client->name = $command->getClientName();
$client->description = $command->getClientDescription(); $client->description = $command->getClientDescription();
$client->grant_types = new Collection($command->getClientGrants()); // @phpstan-ignore-line assign.propertyType $client->grant_types = new Collection($command->getClientGrants()); // @phpstan-ignore-line assign.propertyType
$client->capabilities = new ClientCapabilities();
$client->save(); $client->save();

View File

@@ -7,11 +7,14 @@ namespace Siteworxpro\App\Controllers;
use Defuse\Crypto\Exception\BadFormatException; use Defuse\Crypto\Exception\BadFormatException;
use Defuse\Crypto\Exception\EnvironmentIsBrokenException; use Defuse\Crypto\Exception\EnvironmentIsBrokenException;
use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Events\AccessToken\Issued;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse; use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
final class AccessTokenController extends Controller final class AccessTokenController extends Controller
@@ -34,9 +37,14 @@ final class AccessTokenController extends Controller
); );
} }
return $client /** @var Response $response */
$response = $client
->getAuthorizationServer() ->getAuthorizationServer()
->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([])); ->respondToAccessTokenRequest($request, JsonResponseFactory::createJsonResponse([]));
Dispatcher::push(new Issued($response));
return $response;
} catch (OAuthServerException $e) { } catch (OAuthServerException $e) {
return JsonResponseFactory::createJsonResponse( return JsonResponseFactory::createJsonResponse(
$e->getPayload(), $e->getPayload(),

View File

@@ -12,10 +12,14 @@ use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use Nyholm\Psr7\Response; use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\ServerRequest;
use Nyholm\Psr7\Stream; use Nyholm\Psr7\Stream;
use Siteworxpro\App\Events\Login\LoginAttempt;
use Siteworxpro\App\Events\Login\LoginFailed;
use Siteworxpro\App\Events\Login\LoginSuccess;
use Siteworxpro\App\Helpers\Rand; use Siteworxpro\App\Helpers\Rand;
use Siteworxpro\App\Http\JsonResponseFactory; use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\ServerErrorResponse; use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\OAuth\Entities\Client; use Siteworxpro\App\OAuth\Entities\Client;
use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\App\Services\Facades\Logger; use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\App\Services\Facades\Redis; use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum; use Siteworxpro\HttpStatus\CodesEnum;
@@ -31,6 +35,8 @@ final class AuthorizeController extends Controller
*/ */
public function post(ServerRequest $request): Response public function post(ServerRequest $request): Response
{ {
Dispatcher::push(new LoginAttempt($request));
$s = $request->getCookieParams()['s'] ?? ''; $s = $request->getCookieParams()['s'] ?? '';
$password = $request->getParsedBody()['password'] ?? ''; $password = $request->getParsedBody()['password'] ?? '';
@@ -63,6 +69,8 @@ final class AuthorizeController extends Controller
$user = $client->loginUser($email, $password); $user = $client->loginUser($email, $password);
if (!$user) { if (!$user) {
Dispatcher::push(new LoginFailed($request));
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'success' => false, 'success' => false,
'reason' => 'login failed' 'reason' => 'login failed'
@@ -76,6 +84,8 @@ final class AuthorizeController extends Controller
Redis::del('session:' . $s); Redis::del('session:' . $s);
Dispatcher::push(new LoginSuccess($request, $user));
return JsonResponseFactory::createJsonResponse([ return JsonResponseFactory::createJsonResponse([
'success' => true, 'success' => true,
'location' => $response->getHeader('Location')[0] 'location' => $response->getHeader('Location')[0]

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners; namespace Siteworxpro\App\EventListeners;
/** /**
* Class Listener * Class Listener

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners; namespace Siteworxpro\App\EventListeners;
/** /**
* Interface ListenerInterface * Interface ListenerInterface

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\EventListeners;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Models\AuditLog;
use Siteworxpro\App\Models\Enums\AuditLogAction;
#[ListensFor(\Siteworxpro\App\Events\Login\LoginFailed::class)]
class LoginFailed extends Listener
{
/**
* Handle the event.
*
* @param \Siteworxpro\App\Events\Login\LoginFailed $event
* @param array $payload
* @return AuditLog|null
*/
public function __invoke(mixed $event, array $payload = []): ?AuditLog
{
if (is_string($event)) {
$event = $payload[0] ?? null;
}
if (!$event instanceof \Siteworxpro\App\Events\Login\LoginFailed) {
return null;
}
return AuditLog::create([
'user_id' => $event->getUser()?->id,
'action' => AuditLogAction::LOGIN_FAIL,
'details' => [
'username_attempted' => $event->getUsernameAttempted(),
'ip_address' => $event->getRequestIp(),
],
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\AccessToken;
use Nyholm\Psr7\Response;
readonly class Issued
{
public function __construct(private Response $response)
{
}
public function getResponse(): Response
{
return $this->response;
}
}

View File

@@ -37,7 +37,7 @@ class Dispatcher implements DispatcherContract, Arrayable
/** /**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located * @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/ */
private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\Events\\Listeners\\'; private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\EventListeners\\';
public function __construct() public function __construct()
{ {
@@ -63,7 +63,7 @@ class Dispatcher implements DispatcherContract, Arrayable
private function registerListeners(): void private function registerListeners(): void
{ {
// traverse the Listeners directory and register all listeners // traverse the Listeners directory and register all listeners
$listenersPath = __DIR__ . '/Listeners'; $listenersPath = __DIR__ . '/../EventListeners';
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($listenersPath)); $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($listenersPath));
foreach ($iterator as $file) { foreach ($iterator as $file) {
@@ -199,6 +199,11 @@ class Dispatcher implements DispatcherContract, Arrayable
*/ */
public function push($event, $payload = []): void public function push($event, $payload = []): void
{ {
if (!is_string($event)) {
$payload = [$event];
$event = get_class($event);
}
$this->pushed->put($event, $payload); $this->pushed->put($event, $payload);
} }

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Class Connected
* @package Siteworxpro\App\Events\Listeners\Database
*/
#[ListensFor(ConnectionEstablished::class)]
class Connected extends Listener
{
/**
* @param mixed $event
* @param array $payload
* @return null
*/
public function __invoke(mixed $event, array $payload = []): null
{
if (!($event instanceof ConnectionEvent)) {
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
readonly class LoginAttempt
{
public function __construct(
private ServerRequest $request,
) {
}
public function getEmail(): string
{
return $this->request->getParsedBody()['email'] ?? '';
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
readonly class LoginFailed
{
public function __construct(
private ServerRequest $request,
private ?User $user = null,
) {
}
public function getRequestIp(): string
{
if ($this->request->getHeader('X-Forwarded-For')) {
$ipAddresses = explode(',', $this->request->getHeaderLine('X-Forwarded-For'));
return trim($ipAddresses[0]);
}
if ($this->request->getHeader('X-Real-IP')) {
return $this->request->getHeaderLine('X-Real-IP');
}
if ($this->request->getServerParams()['HTTP_CLIENT_IP'] ?? false) {
return $this->request->getServerParams()['HTTP_CLIENT_IP'];
}
if ($this->request->getServerParams()['HTTP_X_FORWARDED_FOR'] ?? false) {
$ipAddresses = explode(',', $this->request->getServerParams()['HTTP_X_FORWARDED_FOR']);
return trim($ipAddresses[0]);
}
return $this->request->getServerParams()['REMOTE_ADDR'] ?? 'unknown';
}
public function getUsernameAttempted(): string
{
return $this->request->getParsedBody()['email'] ?? '';
}
public function getUser(): ?User
{
return $this->user;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Login;
use Nyholm\Psr7\ServerRequest;
use Siteworxpro\App\Models\User;
readonly class LoginSuccess
{
public function __construct(
private ServerRequest $serverRequest,
private User $user
) {
}
public function getRequest(): ServerRequest
{
return $this->serverRequest;
}
public function getUser(): User
{
return $this->user;
}
}

55
src/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use Siteworxpro\App\Models\Enums\AuditLogAction;
/**
* Class AuditLog
*
* @package Siteworxpro\App\Models
*
* @property int $id
* @property int|null $user_id
* @property AuditLogAction $action
* @property-read Carbon $timestamp
* @property array $details
*/
class AuditLog extends Model
{
public $timestamps = false;
protected $casts = [
'details' => 'array',
'timestamp' => 'datetime',
];
protected $fillable = [
'user_id',
'action',
'details',
];
public function getActionAttribute(string $value): AuditLogAction
{
return AuditLogAction::from($value);
}
public function setActionAttribute(AuditLogAction $value): void
{
$this->attributes['action'] = $value->value;
}
public function setDetailsAttribute(array $value): void
{
$this->attributes['details'] = json_encode($value);
}
public function getDetailsAttribute($value): array
{
return json_decode($value, true) ?? [];
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models\Enums;
enum AuditLogAction: string
{
case LOGIN_SUCCESS = 'login_success';
case LOGIN_FAIL = 'login_fail';
case LOGOUT = 'logout';
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models\Enums;
enum ClientGrant: string
{
case AUTHORIZATION_CODE = 'authorization_code';
case PASSWORD = 'password';
case CLIENT_CREDENTIALS = 'client_credentials';
case REFRESH_TOKEN = 'refresh_token';
case IMPLICIT = 'implicit';
public static function fromString(string $grant): ?ClientGrant
{
return match ($grant) {
'authorization_code' => self::AUTHORIZATION_CODE,
'password' => self::PASSWORD,
'client_credentials' => self::CLIENT_CREDENTIALS,
'refresh_token' => self::REFRESH_TOKEN,
'implicit' => self::IMPLICIT,
default => null,
};
}
public static function getValidGrants(): array
{
return [
self::AUTHORIZATION_CODE,
self::PASSWORD,
self::CLIENT_CREDENTIALS,
self::REFRESH_TOKEN,
self::IMPLICIT,
];
}
}

View File

@@ -15,6 +15,7 @@ use Siteworxpro\App\Helpers\Ulid;
* @method static static|null find(string $id, array $columns = ['*']) * @method static static|null find(string $id, array $columns = ['*'])
* @method static Builder where(string $column, string $operator = null, string $value = null, string $boolean = 'and') * @method static Builder where(string $column, string $operator = null, string $value = null, string $boolean = 'and')
* @method static Builder whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false) * @method static Builder whereJsonContains(string $column, mixed $value, string $boolean = 'and', bool $not = false)
* @method static static create(array $attributes = [])
*/ */
abstract class Model extends ORM abstract class Model extends ORM
{ {

View File

@@ -38,7 +38,7 @@ use Siteworxpro\App\OAuth\ScopeRepository;
* @property Collection $grant_types * @property Collection $grant_types
* @property bool $confidential * @property bool $confidential
* *
* @property-read ClientCapabilities $capabilities * @property ClientCapabilities $capabilities
* @property-read Collection<ClientRedirectUri> $clientRedirectUris * @property-read Collection<ClientRedirectUri> $clientRedirectUris
* @property-read Scope[]|Collection $scopes * @property-read Scope[]|Collection $scopes
*/ */

View File

@@ -19,7 +19,7 @@ class ClientCapabilities implements Arrayable
'logoUrl' => null, 'logoUrl' => null,
]; ];
public function __construct(array $capabilities) public function __construct(array $capabilities = [])
{ {
if (isset($capabilities['userPass'])) { if (isset($capabilities['userPass'])) {
$this->userPass = (bool)$capabilities['userPass']; $this->userPass = (bool)$capabilities['userPass'];

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Events\ConnectionEstablished;
use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface; use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use Siteworxpro\App\Events\Listeners\Database\Connected; use Siteworxpro\App\Events\EventListeners\Database\Connected;
use Siteworxpro\App\Log\Logger; use Siteworxpro\App\Log\Logger;
use Siteworxpro\Tests\Unit; use Siteworxpro\Tests\Unit;