Initial commit
Some checks failed
linter / quality (push) Failing after 13s
tests / ci (8.3) (push) Failing after 2s
tests / ci (8.4) (push) Failing after 2s
tests / ci (8.5) (push) Failing after 3s

Certificate manager built on Laravel 13, Livewire 4, and Flux.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Joël van de Wouw 2026-07-12 14:44:58 +02:00
commit ac340d125c
185 changed files with 23569 additions and 0 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(vendor/bin/phpstan analyse *)"
]
}
}

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

65
.env.example Normal file
View file

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View file

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore

12
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"

51
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: linter
on:
push:
branches:
- develop
- main
- master
- workos
pull_request:
branches:
- develop
- main
- master
- workos
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.4'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: composer lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v7
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# file_pattern: |
# **/*
# !.github/workflows/*

67
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,67 @@
name: tests
on:
push:
branches:
- develop
- main
- master
- workos
pull_request:
branches:
- develop
- main
- master
- workos
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ matrix.php-version }}
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '22'
- name: Install Node Dependencies
run: npm i
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Build Assets
run: npm run build
- name: Run Type Analysis
run: composer types:check
- name: Run Tests
run: php artisan test

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
/.phpunit.cache
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# Certified
Certified is a self-hosted certificate manager built on Laravel 13, Livewire 4, and Flux. It helps you keep track of certificates (diplomas, licenses, compliance documents, etc.), organize them into categories, and get notified before they expire.
## Features
- **Certificate management** — upload, edit, and download certificates, organized by category
- **OCR extraction** — automatically read certificate details on upload via a pluggable OCR backend (Klippa, Mistral, or none)
- **Expiry notifications** — email alerts when a certificate is expiring soon or has expired
- **Secure sharing** — share a certificate with an external recipient via a signed URL, without requiring them to log in
- **Multi-language support**
## Stack
- Laravel 13 (Octane-ready)
- Livewire 4 + Flux UI
- Tailwind CSS 4 / Vite
- Laravel Fortify for authentication
## Getting started
```bash
composer install
npm install
cp .env.example .env
php artisan key:generate
php artisan migrate
npm run dev
# in another terminal
php artisan serve
```
## Testing
```bash
composer test
```
## OCR providers
The OCR backend is configurable via `app/Ocr`. Available providers: `NoneProvider` (disabled), `KlippaProvider`, and `MistralProvider`. Configure credentials in `.env`.

View file

@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use App\Models\Certificate;
use App\Models\User;
use App\Notifications\CertificateExpiredNotification;
use App\Notifications\CertificateExpiringNotification;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class SendCertificateReminders extends Command
{
protected $signature = 'app:send-certificate-reminders';
protected $description = 'Notify users about certificates that are approaching or reaching their expiry date';
public function handle(): int
{
$today = Carbon::today();
$advanceCount = $this->sendAdvanceReminders($today);
$urgentCount = $this->sendExpiryReminders($today);
$this->info("Sent {$advanceCount} advance reminder(s) and {$urgentCount} expiry reminder(s).");
return self::SUCCESS;
}
/**
* Part 1: for each user, notify about certificates expiring exactly on
* their personal "months before" threshold.
*/
private function sendAdvanceReminders(Carbon $today): int
{
$sent = 0;
User::query()
->whereHas('certificates')
->with('certificates')
->chunkById(100, function ($users) use ($today, &$sent) {
foreach ($users as $user) {
$targetDate = $today->copy()->addMonths($user->reminder_months_before);
$certificates = $user->certificates()
->whereDate('expires_at', $targetDate)
->tap($this->notRemindedToday($today))
->get();
foreach ($certificates as $certificate) {
$user->notify(new CertificateExpiringNotification($certificate));
$certificate->forceFill(['last_reminded_at' => now()])->save();
$sent++;
}
}
});
return $sent;
}
/**
* Part 2: notify about certificates expiring today (across all users).
*/
private function sendExpiryReminders(Carbon $today): int
{
$sent = 0;
Certificate::query()
->whereDate('expires_at', $today)
->tap($this->notRemindedToday($today))
->with('user')
->chunkById(100, function ($certificates) use (&$sent) {
foreach ($certificates as $certificate) {
$certificate->user?->notify(new CertificateExpiredNotification($certificate));
$certificate->forceFill(['last_reminded_at' => now()])->save();
$sent++;
}
});
return $sent;
}
/**
* Skip certificates already reminded today, so a re-run (or a missed day
* followed by a catch-up run) never double-sends.
*/
private function notRemindedToday(Carbon $today): callable
{
return function (Builder $query) use ($today) {
$query->where(function (Builder $query) use ($today) {
$query->whereNull('last_reminded_at')
->orWhereDate('last_reminded_at', '<', $today);
});
};
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ExpiryStatus: string
{
case Expired = 'expired';
case ExpiringSoon = 'expiring_soon';
case Valid = 'valid';
public function color(): string
{
return match ($this) {
self::Expired => 'red',
self::ExpiringSoon => 'amber',
self::Valid => 'green',
};
}
public function label(): string
{
return match ($this) {
self::Expired => __('Expired'),
self::ExpiringSoon => __('Expiring soon'),
self::Valid => __('Valid'),
};
}
}

25
app/Enums/Locale.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum Locale: string
{
case English = 'en';
case Dutch = 'nl';
public function label(): string
{
return match ($this) {
self::English => __('English'),
self::Dutch => __('Dutch'),
};
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(fn (self $locale) => $locale->value, self::cases());
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Models\Certificate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CertificateDownloadController extends Controller
{
/**
* Stream a certificate the authenticated user owns. The global scope on
* the model ensures a foreign certificate resolves to a 404.
*/
public function __invoke(Certificate $certificate): StreamedResponse
{
abort_unless(filled($certificate->file_path) && Storage::disk('local')->exists($certificate->file_path), 404);
return Storage::disk('local')->download(
$certificate->file_path,
$certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION),
);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Enums\Locale;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class LanguageController extends Controller
{
/**
* Switch the active language. Persisted to the user's account when
* authenticated, otherwise kept in the session for the duration of the
* guest visit (e.g. while browsing the login/register pages).
*/
public function __invoke(Request $request, string $locale): RedirectResponse
{
abort_unless(in_array($locale, Locale::values(), true), 404);
session(['locale' => $locale]);
if ($request->user()) {
$request->user()->locale = $locale;
$request->user()->save();
}
return back();
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use Illuminate\View\View;
class MarketingController extends Controller
{
public function home(): View
{
return view('marketing.home');
}
public function security(): View
{
return view('marketing.security');
}
public function pricing(): View
{
return view('marketing.pricing');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\Certificate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SharedCertificateController extends Controller
{
/**
* Stream a certificate to an external recipient via a temporary signed
* URL. The 'signed' middleware is the authorization here, so this runs
* unauthenticated (the global scope no-ops without a logged-in user).
*/
public function __invoke(Certificate $certificate): StreamedResponse
{
abort_unless(filled($certificate->file_path) && Storage::disk('local')->exists($certificate->file_path), 404);
return Storage::disk('local')->download(
$certificate->file_path,
$certificate->title.'.'.pathinfo($certificate->file_path, PATHINFO_EXTENSION),
);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Locale;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
/**
* Resolve the locale to use for this request, preferring (in order) the
* authenticated user's saved preference, a locale switched to earlier in
* the session, and finally the browser's Accept-Language header.
*/
public function handle(Request $request, Closure $next): Response
{
$locale = Auth::check()
? Auth::user()->locale
: session('locale') ?? $request->getPreferredLanguage(Locale::values());
if (in_array($locale, Locale::values(), true)) {
App::setLocale($locale);
}
return $next($request);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Features\SupportRedirects\Redirector;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke(): Redirector|RedirectResponse
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SharedCertificateLinksMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param array<int, array{title: string, url: string}> $links
*/
public function __construct(
public string $senderName,
public array $links,
public string $expiresAt,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __(':name shared certificates with you', ['name' => $this->senderName]),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.shared-certificate-links',
with: [
'senderName' => $this->senderName,
'links' => $this->links,
'expiresAt' => $this->expiresAt,
],
);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class SharedCertificatesMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param string $zipPath Absolute path to the password-protected archive.
*/
public function __construct(
public string $senderName,
public string $zipPath,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __(':name shared certificates with you', ['name' => $this->senderName]),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.shared-certificates',
with: ['senderName' => $this->senderName],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [
Attachment::fromPath($this->zipPath)
->as('certificates.zip')
->withMime('application/zip'),
];
}
}

36
app/Models/Category.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use App\Models\Concerns\OwnedByUser;
use App\Models\Scopes\OwnedByUserScope;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property string $name
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['name'])]
#[ScopedBy(OwnedByUserScope::class)]
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory, OwnedByUser;
/**
* @return HasMany<Certificate, $this>
*/
public function certificates(): HasMany
{
return $this->hasMany(Certificate::class);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Models;
use App\Enums\ExpiryStatus;
use App\Models\Concerns\OwnedByUser;
use App\Models\Scopes\OwnedByUserScope;
use Database\Factories\CertificateFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property int|null $category_id
* @property string $title
* @property string|null $issuer
* @property string|null $certificate_number
* @property Carbon|null $issued_at
* @property string|null $file_path
* @property Carbon $expires_at
* @property string|null $notes
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['title', 'category_id', 'issuer', 'certificate_number', 'issued_at', 'file_path', 'expires_at', 'notes'])]
#[ScopedBy(OwnedByUserScope::class)]
class Certificate extends Model
{
/** @use HasFactory<CertificateFactory> */
use HasFactory, OwnedByUser;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'issued_at' => 'date',
'expires_at' => 'date',
];
}
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function expiryStatus(int $monthsBefore = 1): ExpiryStatus
{
return match (true) {
$this->expires_at->isPast() && ! $this->expires_at->isToday() => ExpiryStatus::Expired,
$this->expires_at->lte(today()->addMonths($monthsBefore)) => ExpiryStatus::ExpiringSoon,
default => ExpiryStatus::Valid,
};
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Models\Concerns;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Auth;
trait OwnedByUser
{
protected static function bootOwnedByUser(): void
{
static::creating(function (self $model) {
if (! isset($model->user_id) && ($user = Auth::user()) !== null) {
$model->user_id = $user->id;
}
});
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Auth;
/**
* @implements Scope<Model>
*/
class OwnedByUserScope implements Scope
{
/**
* Restrict queries to the authenticated user's rows. Deliberately a
* no-op without an authenticated user so console commands and queued
* jobs can operate across all users.
*/
public function apply(Builder $builder, Model $model): void
{
if (Auth::hasUser()) {
$builder->where($model->qualifyColumn('user_id'), Auth::id());
}
}
}

94
app/Models/User.php Normal file
View file

@ -0,0 +1,94 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property int $reminder_months_before
* @property string $ocr_provider
* @property string|null $ocr_api_key
* @property string $locale
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', 'ocr_api_key'])]
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
/**
* @var array<string, mixed>
*/
protected $attributes = [
'reminder_months_before' => 1,
'ocr_provider' => 'none',
'locale' => 'en',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'reminder_months_before' => 'integer',
'ocr_api_key' => 'encrypted',
];
}
/**
* @return HasMany<Certificate, $this>
*/
public function certificates(): HasMany
{
return $this->hasMany(Certificate::class);
}
/**
* @return HasMany<Category, $this>
*/
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
/**
* Get the user's initials
*/
public function initials(): string
{
$initials = Str::initials($this->name, true);
return Str::length($initials) > 1
? Str::substr($initials, 0, 1).Str::substr($initials, -1)
: $initials;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Notifications;
use App\Models\Certificate;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class CertificateExpiredNotification extends Notification
{
use Queueable;
public function __construct(public Certificate $certificate) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(User $notifiable): MailMessage
{
return (new MailMessage)
->error()
->subject(__('Certificate expires today: :title', ['title' => $this->certificate->title]))
->greeting(__('Hello :name,', ['name' => $notifiable->name]))
->line(__('Your certificate ":title" expires today.', ['title' => $this->certificate->title]))
->action(__('View your certificates'), route('certificates.index'))
->line(__('Renew it as soon as possible to avoid a lapse in compliance.'));
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Models\Certificate;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class CertificateExpiringNotification extends Notification
{
use Queueable;
public function __construct(public Certificate $certificate) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(User $notifiable): MailMessage
{
$date = $this->certificate->expires_at->toFormattedDateString();
return (new MailMessage)
->subject(__('Certificate expiring soon: :title', ['title' => $this->certificate->title]))
->greeting(__('Hello :name,', ['name' => $notifiable->name]))
->line(__('Your certificate ":title" will expire on :date.', [
'title' => $this->certificate->title,
'date' => $date,
]))
->action(__('View your certificates'), route('certificates.index'))
->line(__('Please renew it in good time to stay compliant.'));
}
}

46
app/Ocr/OcrManager.php Normal file
View file

@ -0,0 +1,46 @@
<?php
namespace App\Ocr;
use App\Ocr\Providers\KlippaProvider;
use App\Ocr\Providers\MistralProvider;
use App\Ocr\Providers\NoneProvider;
use Illuminate\Support\Manager;
/**
* Resolves OCR providers by name (driver). The active provider is chosen
* per-user from their `ocr_provider` setting, and the API key is supplied
* per call, so the resolved drivers are stateless and safely cached.
*
* @method OcrProviderInterface driver(string|null $driver = null)
*/
class OcrManager extends Manager
{
public function getDefaultDriver(): string
{
return 'none';
}
public function createNoneDriver(): OcrProviderInterface
{
return new NoneProvider;
}
public function createKlippaDriver(): OcrProviderInterface
{
return new KlippaProvider;
}
public function createMistralDriver(): OcrProviderInterface
{
return new MistralProvider;
}
/**
* @return array<int, string>
*/
public static function availableProviders(): array
{
return ['none', 'klippa', 'mistral'];
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Ocr;
interface OcrProviderInterface
{
/**
* Analyze a stored document and return structured field suggestions.
*
* @param string $filePath Absolute path to the file on the local filesystem.
* @param string|null $apiKey The user's API key for the provider, if any.
* @return array{title?: string|null, expires_at?: string|null}
*/
public function analyze(string $filePath, ?string $apiKey): array;
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Ocr\Providers;
use App\Ocr\OcrProviderInterface;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Klippa OCR a European (NL) document parsing API.
*
* @see https://custom-ocr.klippa.com/docs
*/
class KlippaProvider implements OcrProviderInterface
{
private const ENDPOINT = 'https://custom-ocr.klippa.com/api/v1/parseDocument';
/**
* @return array{title?: string|null, expires_at?: string|null}
*/
public function analyze(string $filePath, ?string $apiKey): array
{
if (blank($apiKey) || ! is_readable($filePath)) {
return [];
}
$contents = file_get_contents($filePath);
if ($contents === false) {
Log::warning('Klippa OCR could not read the document.', ['path' => $filePath]);
return [];
}
try {
$response = Http::withHeaders(['X-Auth-Key' => $apiKey])
->timeout(30)
->attach('document', $contents, basename($filePath))
->post(self::ENDPOINT, ['template' => 'financial_full']);
if ($response->failed()) {
Log::warning('Klippa OCR request failed.', ['status' => $response->status()]);
return [];
}
$parsed = $response->json('data.parsed', []);
return array_filter([
'title' => $this->extractTitle($parsed),
'expires_at' => $this->extractDate($parsed),
], fn ($value) => $value !== null);
} catch (Throwable $e) {
Log::warning('Klippa OCR analysis threw an exception.', ['message' => $e->getMessage()]);
return [];
}
}
/**
* @param array<string, mixed> $parsed
*/
private function extractTitle(array $parsed): ?string
{
foreach (['document_subject', 'merchant_name', 'title'] as $key) {
$value = data_get($parsed, $key);
if (filled($value) && is_string($value)) {
return trim($value);
}
}
return null;
}
/**
* @param array<string, mixed> $parsed
*/
private function extractDate(array $parsed): ?string
{
foreach (['expiry_date', 'valid_until', 'date'] as $key) {
$value = data_get($parsed, $key);
if (filled($value) && is_string($value)) {
try {
return CarbonImmutable::parse($value)->toDateString();
} catch (Throwable) {
continue;
}
}
}
return null;
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Ocr\Providers;
use App\Ocr\OcrProviderInterface;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Mistral (FR) Pixtral vision API extracts structured fields from an
* image of a certificate and returns them as JSON.
*
* @see https://docs.mistral.ai/capabilities/vision/
*/
class MistralProvider implements OcrProviderInterface
{
private const ENDPOINT = 'https://api.mistral.ai/v1/chat/completions';
private const MODEL = 'pixtral-12b-2409';
private const PROMPT = 'You are analyzing an image of a certificate or diploma. '
.'Return ONLY a JSON object with two keys: "title" (a concise name for the certificate) '
.'and "expires_at" (the expiry or valid-until date in YYYY-MM-DD format, or null if none is visible).';
/**
* @return array{title?: string|null, expires_at?: string|null}
*/
public function analyze(string $filePath, ?string $apiKey): array
{
if (blank($apiKey) || ! is_readable($filePath)) {
return [];
}
$dataUri = $this->toDataUri($filePath);
if ($dataUri === null) {
return [];
}
try {
$response = Http::withToken($apiKey)
->timeout(45)
->post(self::ENDPOINT, [
'model' => self::MODEL,
'response_format' => ['type' => 'json_object'],
'messages' => [[
'role' => 'user',
'content' => [
['type' => 'text', 'text' => self::PROMPT],
['type' => 'image_url', 'image_url' => $dataUri],
],
]],
]);
if ($response->failed()) {
Log::warning('Mistral OCR request failed.', ['status' => $response->status()]);
return [];
}
$content = $response->json('choices.0.message.content');
return $this->parseContent(is_string($content) ? $content : '');
} catch (Throwable $e) {
Log::warning('Mistral OCR analysis threw an exception.', ['message' => $e->getMessage()]);
return [];
}
}
private function toDataUri(string $filePath): ?string
{
$mime = mime_content_type($filePath) ?: 'image/jpeg';
// Pixtral is a vision model and only accepts images, not PDFs.
if (! str_starts_with($mime, 'image/')) {
return null;
}
return 'data:'.$mime.';base64,'.base64_encode((string) file_get_contents($filePath));
}
/**
* @return array{title?: string|null, expires_at?: string|null}
*/
private function parseContent(string $content): array
{
$decoded = json_decode($content, true);
if (! is_array($decoded)) {
return [];
}
$title = data_get($decoded, 'title');
$expiresAt = data_get($decoded, 'expires_at');
return array_filter([
'title' => filled($title) && is_string($title) ? trim($title) : null,
'expires_at' => $this->normalizeDate($expiresAt),
], fn ($value) => $value !== null);
}
private function normalizeDate(mixed $value): ?string
{
if (! filled($value) || ! is_string($value)) {
return null;
}
try {
return CarbonImmutable::parse($value)->toDateString();
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Ocr\Providers;
use App\Ocr\OcrProviderInterface;
class NoneProvider implements OcrProviderInterface
{
/**
* OCR is disabled: return no suggestions.
*
* @return array{}
*/
public function analyze(string $filePath, ?string $apiKey): array
{
return [];
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Providers;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureDefaults();
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()).'|'.$request->ip(),
);
});
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Services;
use App\Models\Certificate;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
class CertificateZipper
{
/**
* Build a password-protected (AES-256) ZIP archive containing the given
* certificates' files. Returns the absolute path to the created archive;
* the caller is responsible for deleting it once sent.
*
* @param Collection<int, Certificate> $certificates
*/
public function create(Collection $certificates, string $password): string
{
$archivePath = tempnam(sys_get_temp_dir(), 'certs_').'.zip';
$zip = new ZipArchive;
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Unable to create ZIP archive.');
}
$zip->setPassword($password);
$usedNames = [];
foreach ($certificates as $certificate) {
if (blank($certificate->file_path) || ! Storage::disk('local')->exists($certificate->file_path)) {
continue;
}
$entryName = $this->uniqueEntryName($certificate, $usedNames);
$usedNames[] = $entryName;
$zip->addFromString($entryName, Storage::disk('local')->get($certificate->file_path));
$zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
}
if ($zip->count() === 0) {
$zip->close();
@unlink($archivePath);
throw new RuntimeException('No files were available to add to the archive.');
}
$zip->close();
return $archivePath;
}
/**
* @param array<int, string> $usedNames
*/
private function uniqueEntryName(Certificate $certificate, array $usedNames): string
{
$extension = pathinfo($certificate->file_path, PATHINFO_EXTENSION);
$base = Str::slug($certificate->title) ?: 'certificate';
$name = $base.'.'.$extension;
$counter = 1;
while (in_array($name, $usedNames, true)) {
$name = $base.'-'.(++$counter).'.'.$extension;
}
return $name;
}
}

18
artisan Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

15
boost.json Normal file
View file

@ -0,0 +1,15 @@
{
"cloud": false,
"guidelines": true,
"mcp": true,
"nightwatch": false,
"sail": false,
"skills": [
"fortify-development",
"laravel-best-practices",
"fluxui-development",
"livewire-development",
"pest-testing",
"tailwindcss-development"
]
}

22
bootstrap/app.php Normal file
View file

@ -0,0 +1,22 @@
<?php
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [SetLocale::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

9
bootstrap/providers.php Normal file
View file

@ -0,0 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];

117
composer.json Normal file
View file

@ -0,0 +1,117 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/livewire-starter-kit",
"type": "project",
"description": "The official Laravel starter kit for Livewire.",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"livewire/blaze": "^1.0",
"livewire/flux": "^2.13.1",
"livewire/livewire": "^4.1"
},
"require-dev": {
"fakerphp/faker": "^1.24",
"larastan/larastan": "^3.9",
"laravel/boost": "^2.2",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"laravel/sail": "^1.53",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.9.3",
"pestphp/pest": "^4.7",
"pestphp/pest-plugin-laravel": "^4.1"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"lint": [
"pint --parallel"
],
"lint:check": [
"pint --parallel --test"
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
"@test"
],
"types:check": [
"phpstan analyse"
],
"test": [
"@php artisan config:clear --ansi",
"@lint:check",
"@types:check",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": [],
"installer": {
"post-create-project": []
}
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

11342
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

126
config/app.php Normal file
View file

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', '')),
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache", "array"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View file

@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

136
config/cache.php Normal file
View file

@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View file

@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View file

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim((string) env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

177
config/fortify.php Normal file
View file

@ -0,0 +1,177 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Passkeys
|--------------------------------------------------------------------------
|
| These settings configure Fortify's passkey (WebAuthn) support.
|
*/
'passkeys' => [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
'timeout' => 60000,
],
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features, or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0
]),
Features::passkeys([
'confirmPassword' => true,
]),
],
];

132
config/logging.php Normal file
View file

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View file

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
config/queue.php Normal file
View file

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View file

@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Resend, Postmark, AWS, and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
config/session.php Normal file
View file

@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session',
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

1
database/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.sqlite*

View file

@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Category>
*/
class CategoryFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'name' => fake()->unique()->words(2, true),
];
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\Certificate;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Certificate>
*/
class CertificateFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'category_id' => null,
'title' => fake()->sentence(3),
'issuer' => fake()->company(),
'certificate_number' => fake()->bothify('CERT-####-????'),
'issued_at' => fake()->dateTimeBetween('-2 years', '-1 month'),
'file_path' => null,
'expires_at' => fake()->dateTimeBetween('+2 months', '+2 years'),
'notes' => null,
];
}
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => fake()->dateTimeBetween('-2 years', '-1 day'),
]);
}
public function expiringSoon(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => today()->addWeeks(2),
]);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
/**
* Indicate that the model has two-factor authentication configured.
*/
public function withTwoFactor(): static
{
return $this->state(fn (array $attributes) => [
'two_factor_secret' => encrypt('secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
'two_factor_confirmed_at' => now(),
]);
}
}

View file

@ -0,0 +1,49 @@
<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View file

@ -0,0 +1,35 @@
<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View file

@ -0,0 +1,59 @@
<?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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View file

@ -0,0 +1,34 @@
<?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('passkeys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('credential_id')->unique();
$table->json('credential');
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('passkeys');
}
};

View file

@ -0,0 +1,34 @@
<?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::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->after('password')->nullable();
$table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable();
$table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View file

@ -0,0 +1,25 @@
<?php
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('categories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
$table->unique(['user_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View file

@ -0,0 +1,28 @@
<?php
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('certificates', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->string('file_path')->nullable();
$table->date('expires_at');
$table->timestamps();
$table->index(['user_id', 'expires_at']);
});
}
public function down(): void
{
Schema::dropIfExists('certificates');
}
};

View file

@ -0,0 +1,24 @@
<?php
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::table('users', function (Blueprint $table) {
$table->integer('reminder_months_before')->default(1)->after('remember_token');
$table->string('ocr_provider')->default('none')->after('reminder_months_before');
$table->text('ocr_api_key')->nullable()->after('ocr_provider');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['reminder_months_before', 'ocr_provider', 'ocr_api_key']);
});
}
};

View file

@ -0,0 +1,22 @@
<?php
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::table('certificates', function (Blueprint $table) {
$table->timestamp('last_reminded_at')->nullable()->after('expires_at');
});
}
public function down(): void
{
Schema::table('certificates', function (Blueprint $table) {
$table->dropColumn('last_reminded_at');
});
}
};

View file

@ -0,0 +1,25 @@
<?php
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::table('certificates', function (Blueprint $table) {
$table->string('issuer')->nullable()->after('title');
$table->string('certificate_number')->nullable()->after('issuer');
$table->date('issued_at')->nullable()->after('certificate_number');
$table->text('notes')->nullable()->after('expires_at');
});
}
public function down(): void
{
Schema::table('certificates', function (Blueprint $table) {
$table->dropColumn(['issuer', 'certificate_number', 'issued_at', 'notes']);
});
}
};

View file

@ -0,0 +1,22 @@
<?php
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::table('users', function (Blueprint $table) {
$table->string('locale', 5)->default('en')->after('ocr_api_key');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('locale');
});
}
};

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

224
lang/nl.json Normal file
View file

@ -0,0 +1,224 @@
{
"\":name\" will be deleted. Certificates in this category are kept and become uncategorized.": "\":name\" wordt verwijderd. Certificaten in deze categorie blijven behouden en worden ongecategoriseerd.",
"\":title\" and its stored file will be permanently deleted.": "\":title\" en het bijbehorende bestand worden permanent verwijderd.",
"2FA recovery codes": "2FA-herstelcodes",
":name has shared certificates with you as a password-protected ZIP archive attached to this email.": ":name heeft certificaten met je gedeeld als een met wachtwoord beveiligd ZIP-bestand, bijgevoegd bij deze e-mail.",
":name has shared the following certificates with you.": ":name heeft de volgende certificaten met je gedeeld.",
":name shared certificates with you": ":name heeft certificaten met je gedeeld",
"A new verification link has been sent to the email address you provided during registration.": "Er is een nieuwe verificatielink verstuurd naar het e-mailadres dat je bij registratie hebt opgegeven.",
"A new verification link has been sent to your email address.": "Er is een nieuwe verificatielink naar je e-mailadres verstuurd.",
"API key": "API-sleutel",
"Add": "Toevoegen",
"Add a passkey to sign in without a password": "Voeg een passkey toe om zonder wachtwoord in te loggen",
"Add certificate": "Certificaat toevoegen",
"Add passkey": "Passkey toevoegen",
"Add your first certificate": "Voeg je eerste certificaat toe",
"Add your first certificate to get started.": "Voeg je eerste certificaat toe om te beginnen.",
"Added :time": "Toegevoegd :time",
"All categories": "Alle categorieën",
"Already have an account?": "Heb je al een account?",
"An overview of your certificates and their expiry status.": "Een overzicht van je certificaten en hun vervalstatus.",
"Appearance": "Weergave",
"Appearance settings": "Weergave-instellingen",
"Archive password": "Archiefwachtwoord",
"Are you sure you want to delete your account?": "Weet je zeker dat je je account wilt verwijderen?",
"Are you sure you want to remove the passkey \":name\"? You will no longer be able to use it to sign in.": "Weet je zeker dat je de passkey \":name\" wilt verwijderen? Je kunt hem daarna niet meer gebruiken om in te loggen.",
"Authenticating...": "Bezig met authenticeren...",
"Authentication code": "Authenticatiecode",
"Back": "Terug",
"By category": "Per categorie",
"Cancel": "Annuleren",
"Categories": "Categorieën",
"Category": "Categorie",
"Category created.": "Categorie aangemaakt.",
"Category deleted. Its certificates are now uncategorized.": "Categorie verwijderd. De certificaten zijn nu ongecategoriseerd.",
"Certificate added.": "Certificaat toegevoegd.",
"Certificate deleted.": "Certificaat verwijderd.",
"Certificate expires today: :title": "Certificaat verloopt vandaag: :title",
"Certificate expiring soon: :title": "Certificaat verloopt binnenkort: :title",
"Certificate number": "Certificaatnummer",
"Certificate settings updated.": "Certificaatinstellingen bijgewerkt.",
"Certificate updated.": "Certificaat bijgewerkt.",
"Certificates": "Certificaten",
"Certificates shared with you": "Certificaten met je gedeeld",
"Click here to re-send the verification email.": "Klik hier om de verificatie-e-mail opnieuw te versturen.",
"Close": "Sluiten",
"Configure expiry reminders and document scanning": "Stel vervalherinneringen en documentscannen in",
"Confirm": "Bevestigen",
"Confirm password": "Wachtwoord bevestigen",
"Confirm with passkey": "Bevestig met passkey",
"Confirming...": "Bezig met bevestigen...",
"Continue": "Doorgaan",
"Create account": "Account aanmaken",
"Create an account": "Maak een account aan",
"Create your first category to organize your certificates.": "Maak je eerste categorie aan om je certificaten te organiseren.",
"Current password": "Huidig wachtwoord",
"Dark": "Donker",
"Delete": "Verwijderen",
"Delete account": "Account verwijderen",
"Delete category?": "Categorie verwijderen?",
"Delete certificate?": "Certificaat verwijderen?",
"Delete your account and all of its resources": "Verwijder je account en alle bijbehorende gegevens",
"Disable 2FA": "2FA uitschakelen",
"Document preview": "Documentvoorbeeld",
"Document scanning (OCR)": "Documentscannen (OCR)",
"Documentation": "Documentatie",
"Don't have an account?": "Nog geen account?",
"Done": "Klaar",
"Download": "Downloaden",
"Download current file": "Huidig bestand downloaden",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.": "Elke herstelcode kan één keer worden gebruikt om toegang te krijgen tot je account en wordt na gebruik verwijderd. Heb je meer nodig, klik dan hierboven op Codes opnieuw genereren.",
"Edit": "Bewerken",
"Edit certificate": "Certificaat bewerken",
"Email": "E-mail",
"Email address": "E-mailadres",
"Email password reset link": "Verstuur link om wachtwoord opnieuw in te stellen",
"Email verification": "E-mailverificatie",
"Enable 2FA": "2FA inschakelen",
"Enable two-factor authentication": "Tweestapsverificatie inschakelen",
"Ensure your account is using a long, random password to stay secure": "Zorg dat je account een lang, willekeurig wachtwoord gebruikt om veilig te blijven",
"Enter the 6-digit code from your authenticator app.": "Voer de 6-cijferige code uit je authenticator-app in.",
"Enter the authentication code provided by your authenticator application.": "Voer de authenticatiecode in die door je authenticator-app wordt gegeven.",
"Enter your API key": "Voer je API-sleutel in",
"Enter your details below to create your account": "Vul hieronder je gegevens in om je account aan te maken",
"Enter your email and password below to log in": "Vul hieronder je e-mailadres en wachtwoord in om in te loggen",
"Enter your email to receive a password reset link": "Vul je e-mailadres in om een link te ontvangen om je wachtwoord opnieuw in te stellen",
"Expired": "Verlopen",
"Expires": "Vervalt",
"Expiring soon": "Vervalt binnenkort",
"Expiry date": "Vervaldatum",
"For your security, the password is sent separately through another channel (for example SMS or WhatsApp). You will need it to open the archive.": "Voor je veiligheid wordt het wachtwoord apart via een ander kanaal verstuurd (bijvoorbeeld sms of WhatsApp). Je hebt het nodig om het archief te openen.",
"Forgot password": "Wachtwoord vergeten",
"Forgot your password?": "Wachtwoord vergeten?",
"Full name": "Volledige naam",
"Give this passkey a name to help you identify it later.": "Geef deze passkey een naam zodat je hem later herkent.",
"Hello :name,": "Hallo :name,",
"Here is the status of your certificates.": "Dit is de status van je certificaten.",
"Hide recovery codes": "Herstelcodes verbergen",
"If you did not expect this email, you can safely ignore it.": "Als je deze e-mail niet verwachtte, kun je hem veilig negeren.",
"Issue date": "Uitgiftedatum",
"Issuing authority": "Uitgevende instantie",
"Last used :time": "Laatst gebruikt :time",
"Leave blank to keep the current key": "Laat leeg om de huidige sleutel te behouden",
"Light": "Licht",
"Log in": "Inloggen",
"Log in to your account": "Log in op je account",
"Log out": "Uitloggen",
"Manage": "Beheren",
"Manage your passkeys for passwordless sign-in": "Beheer je passkeys voor inloggen zonder wachtwoord",
"Manage your profile and account settings": "Beheer je profiel- en accountinstellingen",
"Manage your two-factor authentication settings": "Beheer je instellingen voor tweestapsverificatie",
"Method": "Methode",
"Name": "Naam",
"Needs attention": "Actie vereist",
"New category name": "Naam nieuwe categorie",
"New password": "Nieuw wachtwoord",
"No categories yet": "Nog geen categorieën",
"No categories yet.": "Nog geen categorieën.",
"No certificates found": "Geen certificaten gevonden",
"No certificates in this category. Try another filter.": "Geen certificaten in deze categorie. Probeer een ander filter.",
"No certificates yet": "Nog geen certificaten",
"No passkeys yet": "Nog geen passkeys",
"None (manual entry)": "Geen (handmatig invoeren)",
"Notes": "Notities",
"Nothing needs attention. All your certificates are up to date.": "Niets vereist actie. Al je certificaten zijn up-to-date.",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Zodra je account is verwijderd, worden alle bijbehorende gegevens permanent verwijderd. Voer je wachtwoord in om te bevestigen dat je je account definitief wilt verwijderen.",
"Optional notes about this certificate": "Optionele notities over dit certificaat",
"Optional. Leave empty to keep the current file.": "Optioneel. Laat leeg om het huidige bestand te behouden.",
"Or confirm with password": "Of bevestig met wachtwoord",
"Or continue with email": "Of ga verder met e-mail",
"Or, return to": "Of ga terug naar",
"Organize your certificates with personal categories.": "Organiseer je certificaten met persoonlijke categorieën.",
"PDF or image, up to 10 MB.": "PDF of afbeelding, tot 10 MB.",
"Passkey name": "Naam passkey",
"Passkeys": "Passkeys",
"Passkeys are not supported in this browser.": "Passkeys worden niet ondersteund in deze browser.",
"Password": "Wachtwoord",
"Password updated.": "Wachtwoord bijgewerkt.",
"Password-protected ZIP attachment": "Met wachtwoord beveiligde ZIP-bijlage",
"Please confirm access to your account by entering one of your emergency recovery codes.": "Bevestig toegang tot je account door een van je noodherstelcodes in te voeren.",
"Please enter your new password below": "Voer hieronder je nieuwe wachtwoord in",
"Please renew it in good time to stay compliant.": "Verleng het op tijd om compliant te blijven.",
"Please verify your email address by clicking on the link we just emailed to you.": "Verifieer je e-mailadres door op de link te klikken die we je zojuist per e-mail hebben gestuurd.",
"Profile": "Profiel",
"Profile settings": "Profielinstellingen",
"Profile updated.": "Profiel bijgewerkt.",
"Recently added": "Recent toegevoegd",
"Recipient email": "E-mailadres ontvanger",
"Recovery code": "Herstelcode",
"Recovery codes": "Herstelcodes",
"Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Met herstelcodes krijg je weer toegang als je je 2FA-apparaat kwijtraakt. Bewaar ze in een veilige wachtwoordmanager.",
"Regenerate codes": "Codes opnieuw genereren",
"Register": "Registreren",
"Register passkey": "Passkey registreren",
"Registering...": "Bezig met registreren...",
"Remember me": "Onthoud mij",
"Remind me this many months before expiry": "Herinner mij dit aantal maanden voor vervaldatum",
"Remove passkey": "Passkey verwijderen",
"Renew it as soon as possible to avoid a lapse in compliance.": "Verleng het zo snel mogelijk om een onderbreking in compliance te voorkomen.",
"Replace document": "Document vervangen",
"Resend verification email": "Verificatie-e-mail opnieuw versturen",
"Reset password": "Wachtwoord opnieuw instellen",
"Save": "Opslaan",
"Save certificate": "Certificaat opslaan",
"Save changes": "Wijzigingen opslaan",
"Scanning document for suggestions…": "Document wordt gescand voor suggesties…",
"Search": "Zoeken",
"Secure link (valid 48 hours)": "Beveiligde link (48 uur geldig)",
"Secure links sent to :email.": "Beveiligde links verzonden naar :email.",
"Security": "Beveiliging",
"Security settings": "Beveiligingsinstellingen",
"Select a document to preview it here.": "Selecteer een document om het hier te bekijken.",
"Send": "Versturen",
"Send the selected certificates to an external recipient.": "Verstuur de geselecteerde certificaten naar een externe ontvanger.",
"Settings": "Instellingen",
"Share": "Delen",
"Share certificates": "Certificaten delen",
"Sign in with a passkey": "Inloggen met een passkey",
"Sign up": "Registreren",
"System": "Systeem",
"Thanks": "Met vriendelijke groet",
"The ZIP was emailed. Share this password with the recipient through a different channel (SMS or WhatsApp). It will not be shown again.": "De ZIP is verstuurd per e-mail. Deel dit wachtwoord met de ontvanger via een ander kanaal (sms of WhatsApp). Het wordt niet opnieuw getoond.",
"The selected certificates have no files to share.": "De geselecteerde certificaten hebben geen bestanden om te delen.",
"These links are valid until :date, after which they will stop working.": "Deze links zijn geldig tot :date, daarna werken ze niet meer.",
"This is a secure area of the application. Please confirm your password before continuing.": "Dit is een beveiligd gedeelte van de applicatie. Bevestig je wachtwoord voordat je verdergaat.",
"Title": "Titel",
"To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.": "Scan de QR-code of voer de installatiesleutel in je authenticator-app in om tweestapsverificatie te voltooien.",
"Too many share attempts. Please try again later.": "Te veel deelpogingen. Probeer het later opnieuw.",
"Total": "Totaal",
"Two-factor authentication": "Tweestapsverificatie",
"Two-factor authentication enabled": "Tweestapsverificatie ingeschakeld",
"Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.": "Tweestapsverificatie is nu ingeschakeld. Scan de QR-code of voer de installatiesleutel in je authenticator-app in.",
"Uncategorized": "Ongecategoriseerd",
"Update password": "Wachtwoord bijwerken",
"Update the appearance settings for your account": "Werk de weergave-instellingen van je account bij",
"Update the details or replace the stored document.": "Werk de gegevens bij of vervang het opgeslagen document.",
"Update your name and email address": "Werk je naam en e-mailadres bij",
"Upload a document to preview it, then confirm the details — pre-filled automatically if scanning is enabled.": "Upload een document om het te bekijken en bevestig daarna de gegevens — automatisch ingevuld als scannen is ingeschakeld.",
"Upload your first certificate to start tracking issue dates, expiry, and reminders.": "Upload je eerste certificaat om uitgiftedatums, vervaldatums en herinneringen bij te houden.",
"Uploading…": "Uploaden…",
"Valid": "Geldig",
"Verify authentication code": "Authenticatiecode verifiëren",
"View all": "Alles bekijken",
"View recovery codes": "Herstelcodes bekijken",
"View your certificates": "Bekijk je certificaten",
"Welcome": "Welkom",
"Welcome back, :name": "Welkom terug, :name",
"When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.": "Wanneer je tweestapsverificatie inschakelt, wordt tijdens het inloggen om een beveiligde pincode gevraagd. Deze pincode kun je ophalen uit een TOTP-ondersteunende app op je telefoon.",
"You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Tijdens het inloggen wordt om een beveiligde, willekeurige pincode gevraagd, die je kunt ophalen uit de TOTP-ondersteunende app op je telefoon.",
"Your certificate \":title\" expires today.": "Je certificaat \":title\" verloopt vandaag.",
"Your certificate \":title\" will expire on :date.": "Je certificaat \":title\" verloopt op :date.",
"Your email address is unverified.": "Je e-mailadres is niet geverifieerd.",
"e.g. VCA, Red Cross": "bijv. VCA, Rode Kruis",
"e.g., MacBook Pro, iPhone": "bijv. MacBook Pro, iPhone",
"log in": "inloggen",
"login using a recovery code": "inloggen met een herstelcode",
"login using an authentication code": "inloggen met een authenticatiecode",
"or you can": "of je kunt",
"or, enter the code manually": "of voer de code handmatig in",
":count certificate selected|:count certificates selected": ":count certificaat geselecteerd|:count certificaten geselecteerd",
"Language": "Taal",
"Choose the language used throughout the application": "Kies de taal die in de hele applicatie wordt gebruikt",
"English": "Engels",
"Dutch": "Nederlands"
}

1600
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"@alpinejs/collapse": "^3.15.12",
"@laravel/passkeys": "^0.2.0",
"@tailwindcss/vite": "^4.1.11",
"alpinejs": "^3.15.12",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.1",
"tailwindcss": "^4.0.7",
"vite": "^8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
"lightningcss-linux-x64-gnu": "^1.29.1"
}
}

13
phpstan.neon Normal file
View file

@ -0,0 +1,13 @@
includes:
- vendor/larastan/larastan/extension.neon
- vendor/nesbot/carbon/extension.neon
parameters:
paths:
- app/
- bootstrap/app.php
- config/
- database/
- routes/
level: 7

36
phpunit.xml Normal file
View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

3
pint.json Normal file
View file

@ -0,0 +1,3 @@
{
"preset": "laravel"
}

25
public/.htaccess Normal file
View file

@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

3
public/favicon.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

20
public/index.php Normal file
View file

@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

128
resources/css/app.css Normal file
View file

@ -0,0 +1,128 @@
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-zinc-50: #fafafa;
--color-zinc-100: #f5f5f5;
--color-zinc-200: #e5e5e5;
--color-zinc-300: #d4d4d4;
--color-zinc-400: #a3a3a3;
--color-zinc-500: #737373;
--color-zinc-600: #525252;
--color-zinc-700: #404040;
--color-zinc-800: #262626;
--color-zinc-900: #171717;
--color-zinc-950: #0a0a0a;
--color-accent: var(--color-neutral-800);
--color-accent-content: var(--color-neutral-800);
--color-accent-foreground: var(--color-white);
/* Marketing pages */
--animate-fade-in-up: fade-in-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
--animate-scan-beam: scan-beam 1.6s ease-in-out infinite;
--animate-caret-blink: caret-blink 1s steps(2) infinite;
--animate-float-slow: float-slow 7s ease-in-out infinite;
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(1.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scan-beam {
0%,
100% {
top: 0%;
}
50% {
top: calc(100% - 3px);
}
}
@keyframes caret-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes float-slow {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-0.75rem);
}
}
}
[x-cloak] {
display: none !important;
}
@utility bg-grid-slate {
background-image:
linear-gradient(to right, --alpha(var(--color-slate-400) / 14%) 1px, transparent 1px),
linear-gradient(to bottom, --alpha(var(--color-slate-400) / 14%) 1px, transparent 1px);
background-size: 2.5rem 2.5rem;
}
@utility bg-noise {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
}
@layer theme {
.dark {
--color-accent: var(--color-white);
--color-accent-content: var(--color-white);
--color-accent-foreground: var(--color-neutral-800);
}
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
[data-flux-field]:not(ui-radio, ui-checkbox) {
@apply grid gap-2;
}
[data-flux-label] {
@apply !mb-0 !leading-tight;
}
input:focus[data-flux-control],
textarea:focus[data-flux-control],
select:focus[data-flux-control] {
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
}
/* \[:where(&)\]:size-4 {
@apply size-4;
} */

0
resources/js/app.js Normal file
View file

42
resources/js/marketing.js Normal file
View file

@ -0,0 +1,42 @@
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Interactive OCR scan demo in the features section.
Alpine.data('ocrDemo', () => ({
state: 'idle', // idle | scanning | typing | done
progress: 0,
fields: { title: '', issuer: '', expires: '' },
result: { title: 'VCA Basisveiligheid (B-VCA)', issuer: 'SSVV — VCA Infra', expires: '12-03-2029' },
async scan() {
if (this.state !== 'idle' && this.state !== 'done') return;
this.state = 'scanning';
this.progress = 0;
this.fields = { title: '', issuer: '', expires: '' };
while (this.progress < 100) {
this.progress = Math.min(100, this.progress + Math.random() * 14 + 4);
await sleep(120);
}
this.state = 'typing';
for (const key of Object.keys(this.result)) {
for (const char of this.result[key]) {
this.fields[key] += char;
await sleep(28);
}
await sleep(180);
}
this.state = 'done';
},
}));
window.Alpine = Alpine;
Alpine.start();

4
resources/js/passkeys.js Normal file
View file

@ -0,0 +1,4 @@
import { Passkeys } from '@laravel/passkeys';
window.Passkeys = Passkeys;
window.dispatchEvent(new CustomEvent('passkeys:ready'));

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 714 B

View file

@ -0,0 +1,17 @@
@props([
'sidebar' => false,
])
@if($sidebar)
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:sidebar.brand>
@else
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</x-slot>
</flux:brand>
@endif

View file

@ -0,0 +1,9 @@
@props([
'title',
'description',
])
<div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>

View file

@ -0,0 +1,9 @@
@props([
'status',
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
@endif

View file

@ -0,0 +1,39 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
/>
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log out') }}
</flux:menu.item>
</form>
</flux:menu.radio.group>
</flux:menu>
</flux:dropdown>

View file

@ -0,0 +1,24 @@
@props(['sidebar' => false])
<flux:dropdown position="top" align="start">
@if ($sidebar)
<flux:sidebar.item icon="language">
{{ \App\Enums\Locale::from(app()->getLocale())->label() }}
</flux:sidebar.item>
@else
<flux:button icon="language" variant="ghost" size="sm">
{{ strtoupper(app()->getLocale()) }}
</flux:button>
@endif
<flux:menu>
@foreach (\App\Enums\Locale::cases() as $locale)
<flux:menu.item
:href="route('language.switch', $locale->value)"
:icon="app()->getLocale() === $locale->value ? 'check' : null"
>
{{ $locale->label() }}
</flux:menu.item>
@endforeach
</flux:menu>
</flux:dropdown>

View file

@ -0,0 +1,116 @@
@assets
@vite('resources/js/passkeys.js')
@endassets
<div
x-data="{
supported: false,
showForm: false,
name: '',
loading: false,
error: null,
updateSupport() {
this.supported = Boolean(window.Passkeys?.isSupported());
},
getDefaultPasskeyName() {
const ua = navigator.userAgent;
const browser = [
{ pattern: /Edg|Edge/, name: 'Edge' },
{ pattern: /OPR|Opera|OPiOS/, name: 'Opera' },
{ pattern: /Firefox|FxiOS/, name: 'Firefox' },
{ pattern: /Chrome|CriOS/, name: 'Chrome' },
{ pattern: /Safari/, name: 'Safari' },
].find(({ pattern }) => pattern.test(ua))?.name;
const os = [
{ pattern: /iPhone/, name: 'iPhone' },
{ pattern: /iPad|Macintosh(?=.*Mobile)/, name: 'iPad' },
{ pattern: /Android/, name: 'Android' },
{ pattern: /Mac/, name: 'Mac' },
{ pattern: /Windows/, name: 'Windows' },
].find(({ pattern }) => pattern.test(ua))?.name;
return [browser, os].filter(Boolean).join(' on ') || '';
},
init() {
this.name = this.getDefaultPasskeyName();
this.updateSupport();
window.addEventListener('passkeys:ready', () => this.updateSupport(), { once: true });
},
async register() {
if (!this.name.trim()) return;
this.loading = true;
this.error = null;
try {
await window.Passkeys.register({ name: this.name });
this.name = '';
this.showForm = false;
await $wire.loadPasskeys();
} catch (e) {
if (e.constructor?.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
cancel() {
this.showForm = false;
this.name = '';
this.error = null;
},
}"
>
<template x-if="!supported">
<flux:text>{{ __('Passkeys are not supported in this browser.') }}</flux:text>
</template>
<template x-if="supported && !showForm">
<div>
<flux:button
variant="primary"
icon="plus"
x-on:click="showForm = true"
>
{{ __('Add passkey') }}
</flux:button>
</div>
</template>
<template x-if="supported && showForm">
<div class="space-y-4 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 p-4">
<flux:input
label="{{ __('Passkey name') }}"
x-model="name"
placeholder="{{ __('e.g., MacBook Pro, iPhone') }}"
x-on:keydown.enter.prevent="register()"
x-ref="passkeyNameInput"
x-init="$nextTick(() => $refs.passkeyNameInput?.focus())"
/>
<flux:text class="!mt-1">{{ __('Give this passkey a name to help you identify it later.') }}</flux:text>
<p x-show="error" x-text="error" x-cloak class="text-sm text-red-600 dark:text-red-400"></p>
<div class="flex gap-2">
<flux:button
variant="primary"
x-on:click="register()"
x-bind:disabled="loading || !name.trim()"
>
<span x-show="!loading">{{ __('Register passkey') }}</span>
<span x-show="loading" x-cloak>{{ __('Registering...') }}</span>
</flux:button>
<flux:button
variant="ghost"
x-on:click="cancel()"
>
{{ __('Cancel') }}
</flux:button>
</div>
</div>
</template>
</div>

View file

@ -0,0 +1,76 @@
@props([
'optionsRoute' => 'passkey.login-options',
'submitRoute' => 'passkey.login',
'label' => __('Sign in with a passkey'),
'loadingLabel' => __('Authenticating...'),
'separator' => __('Or continue with email'),
])
@assets
@vite('resources/js/passkeys.js')
@endassets
<div
x-data="{
supported: false,
loading: false,
error: null,
updateSupport() {
this.supported = Boolean(window.Passkeys?.isSupported());
},
init() {
this.updateSupport();
window.addEventListener('passkeys:ready', () => this.updateSupport(), { once: true });
},
async verify() {
this.loading = true;
this.error = null;
try {
const response = await window.Passkeys.verify({
routes: {
options: '{{ route($optionsRoute) }}',
submit: '{{ route($submitRoute) }}',
},
});
Livewire.navigate(response.redirect || '/dashboard');
} catch (e) {
if (e.constructor?.name !== 'UserCancelledError') {
this.error = e.message;
}
} finally {
this.loading = false;
}
},
}"
>
<template x-if="supported">
<div>
<div class="grid gap-2">
<flux:button
variant="outline"
icon="finger-print"
class="w-full"
x-on:click="verify()"
x-bind:disabled="loading"
>
<span x-show="!loading">{{ $label }}</span>
<span x-show="loading" x-cloak>{{ $loadingLabel }}</span>
</flux:button>
<p x-show="error" x-text="error" x-cloak
class="text-sm text-center text-red-600 dark:text-red-400"></p>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="px-2 text-zinc-500 dark:text-zinc-400 bg-white dark:bg-zinc-900">
{{ $separator }}
</span>
</div>
</div>
</div>
</template>
</div>

View file

@ -0,0 +1,12 @@
@props([
'id' => uniqid(),
])
<svg {{ $attributes }} fill="none">
<defs>
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
</svg>

Some files were not shown because too many files have changed in this diff Show more