From 8c3e9d3c26b348cb8dbfd86215badd2710f29e3d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 7 Jun 2026 06:16:13 +0000 Subject: [PATCH] Add team, developers, PDF type, and QR rendering dependency. Enables account collaboration, API tokens, hosted PDF codes, and fixes QR detail 500s by declaring chillerlan/php-qrcode. Co-authored-by: Cursor --- .../Controllers/Auth/SsoLoginController.php | 3 + app/Http/Controllers/Qr/AccountController.php | 6 +- .../Controllers/Qr/DeveloperController.php | 45 +++++ .../Controllers/Qr/OverviewController.php | 14 +- app/Http/Controllers/Qr/QrCodeController.php | 34 ++-- app/Http/Controllers/Qr/TeamController.php | 108 +++++++++++ app/Http/Middleware/SetActingAccount.php | 32 ++++ app/Models/QrDocument.php | 28 ++- app/Models/QrTeamMember.php | 46 +++++ app/Models/User.php | 21 +++ app/Policies/QrCodePolicy.php | 6 +- app/Support/Qr/QrTypeCatalog.php | 7 + bootstrap/app.php | 4 +- composer.json | 1 + composer.lock | 164 +++++++++++++++- ...06_210000_create_qr_team_members_table.php | 30 +++ public/images/qr-icons/terms.svg | 4 + resources/views/partials/sidebar.blade.php | 4 + resources/views/partials/topbar-qr.blade.php | 22 +++ .../views/public/qr/document-viewer.blade.php | 177 ++++++++++++++++++ .../views/qr/account/developers.blade.php | 66 +++++++ resources/views/qr/account/team.blade.php | 84 +++++++++ routes/web.php | 14 ++ 23 files changed, 892 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Qr/DeveloperController.php create mode 100644 app/Http/Controllers/Qr/TeamController.php create mode 100644 app/Http/Middleware/SetActingAccount.php create mode 100644 app/Models/QrTeamMember.php create mode 100644 database/migrations/2026_06_06_210000_create_qr_team_members_table.php create mode 100644 public/images/qr-icons/terms.svg create mode 100644 resources/views/public/qr/document-viewer.blade.php create mode 100644 resources/views/qr/account/developers.blade.php create mode 100644 resources/views/qr/account/team.blade.php diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 488ed83..0cf2074 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; +use App\Models\QrTeamMember; use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -81,6 +82,8 @@ class SsoLoginController extends Controller ], ); + QrTeamMember::linkPendingInvitesFor($user); + Auth::login($user, remember: true); $request->session()->regenerate(); diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index 7a3e2bb..cdb9044 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -30,7 +30,7 @@ class AccountController extends Controller public function wallet(): View { - $user = auth()->user(); + $user = ladill_account(); [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); return view('qr.account.wallet', [ @@ -43,7 +43,7 @@ class AccountController extends Controller public function billing(): View { - $user = auth()->user(); + $user = ladill_account(); [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); return view('qr.account.billing', [ @@ -56,6 +56,6 @@ class AccountController extends Controller public function settings(): View { - return view('qr.account.settings', ['account' => auth()->user()]); + return view('qr.account.settings', ['account' => ladill_account()]); } } diff --git a/app/Http/Controllers/Qr/DeveloperController.php b/app/Http/Controllers/Qr/DeveloperController.php new file mode 100644 index 0000000..bea5659 --- /dev/null +++ b/app/Http/Controllers/Qr/DeveloperController.php @@ -0,0 +1,45 @@ + $request->user()->tokens()->latest()->get(), + 'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1', + 'newToken' => session('new_token'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:60'], + ]); + + $token = $request->user()->createToken($data['name'], ['qr:read']); + + return redirect()->route('account.developers') + ->with('new_token', $token->plainTextToken) + ->with('success', 'Token created — copy it now, it won’t be shown again.'); + } + + public function destroy(Request $request, int $token): RedirectResponse + { + $request->user()->tokens()->whereKey($token)->delete(); + + return redirect()->route('account.developers')->with('success', 'Token revoked.'); + } +} diff --git a/app/Http/Controllers/Qr/OverviewController.php b/app/Http/Controllers/Qr/OverviewController.php index c967b05..2a2a557 100644 --- a/app/Http/Controllers/Qr/OverviewController.php +++ b/app/Http/Controllers/Qr/OverviewController.php @@ -21,20 +21,20 @@ class OverviewController extends Controller public function index(Request $request): View { - $user = $request->user(); - $wallet = $this->manager->walletFor($user); - $codes = $user->qrCodes() + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); + $codes = $account->qrCodes() ->whereIn('type', QrTypeCatalog::plusTypes()) ->latest() ->limit(5) ->get(); - $activeCount = $user->qrCodes() + $activeCount = $account->qrCodes() ->whereIn('type', QrTypeCatalog::plusTypes()) ->where('is_active', true) ->count(); - $scans30d = $user->qrCodes() + $scans30d = $account->qrCodes() ->whereIn('type', QrTypeCatalog::plusTypes()) ->withSum(['scanEvents as scans_30d' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))], 'id') ->get() @@ -42,10 +42,10 @@ class OverviewController extends Controller $balanceMinor = 0; try { - $balanceMinor = $this->billing->balanceMinor($user->public_id); + $balanceMinor = $this->billing->balanceMinor($account->public_id); } catch (Throwable $e) { Log::warning('QR Plus dashboard could not load wallet balance', [ - 'user' => $user->public_id, + 'user' => $account->public_id, 'error' => $e->getMessage(), ]); } diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php index b395a6a..6fe5ec4 100644 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -40,19 +40,19 @@ class QrCodeController extends Controller public function index(Request $request): View { - $user = $request->user(); - $wallet = $this->manager->walletFor($user); - $qrCodes = $user->qrCodes() + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); + $qrCodes = $account->qrCodes() ->whereIn('type', QrTypeCatalog::plusTypes()) ->latest() ->get(); $ladillWalletBalance = 0.0; try { - $ladillWalletBalance = $this->platformBilling->balanceMinor($user->public_id) / 100; + $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; } catch (Throwable $e) { Log::warning('QR Plus index could not load Ladill wallet balance', [ - 'user' => $user->public_id, + 'user' => $account->public_id, 'error' => $e->getMessage(), ]); } @@ -69,7 +69,8 @@ class QrCodeController extends Controller public function create(Request $request): View { - $wallet = $this->manager->walletFor($request->user()); + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); return view('qr-codes.create', [ 'wallet' => $wallet, @@ -80,7 +81,7 @@ class QrCodeController extends Controller 'frameStyles' => QrFrameStyleCatalog::visible(), 'pricePerQr' => QrWallet::pricePerQr(), 'minTopup' => QrWallet::minTopupGhs(), - 'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100, + 'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', ]); } @@ -134,7 +135,7 @@ class QrCodeController extends Controller } try { - $qrCode = $this->manager->create($request->user(), array_merge( + $qrCode = $this->manager->create(ladill_account(), array_merge( $request->all(), [ 'style' => $validated['style'] ?? [], @@ -175,13 +176,24 @@ class QrCodeController extends Controller $logoDataUri = ! empty($qrStyle['logo_path']) ? $this->imageGenerator->logoDataUri($qrStyle['logo_path']) : null; - $wallet = $this->manager->walletFor($request->user()); + $account = ladill_account(); + $wallet = $this->manager->walletFor($account); $summary = $this->analytics->summaryFor($qrCode); $dailyScans = $this->analytics->dailyScans($qrCode, 30); $devices = $this->analytics->breakdown($qrCode, 'device_type'); $browsers = $this->analytics->breakdown($qrCode, 'browser'); $recentScans = $this->analytics->recentScans($qrCode); + $ladillWalletBalance = 0.0; + try { + $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100; + } catch (Throwable $e) { + Log::warning('QR Plus show could not load Ladill wallet balance', [ + 'user' => $account->public_id, + 'error' => $e->getMessage(), + ]); + } + return view('qr-codes.show', [ 'qrCode' => $qrCode, 'previewDataUri' => $previewDataUri, @@ -199,7 +211,7 @@ class QrCodeController extends Controller 'recentScans' => $recentScans, 'pricePerQr' => QrWallet::pricePerQr(), 'minTopup' => QrWallet::minTopupGhs(), - 'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100, + 'ladillWalletBalance' => $ladillWalletBalance, 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', ]); } @@ -281,7 +293,7 @@ class QrCodeController extends Controller $style['logo_path'] = $tempLogoPath; } elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) { $existingPath = $validated['existing_logo_path']; - $userPrefix = $request->user()->id . '/'; + $userPrefix = ladill_account()->id . '/'; if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) { $style['logo_path'] = $existingPath; } diff --git a/app/Http/Controllers/Qr/TeamController.php b/app/Http/Controllers/Qr/TeamController.php new file mode 100644 index 0000000..691ff16 --- /dev/null +++ b/app/Http/Controllers/Qr/TeamController.php @@ -0,0 +1,108 @@ +where('account_id', $account->id) + ->with('member') + ->orderBy('status')->orderBy('email') + ->get(); + + return view('qr.account.team', [ + 'account' => $account, + 'members' => $members, + 'canManage' => $this->canManage($request), + 'isOwner' => $request->user()->id === $account->id, + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManage($request), 403); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:admin,member'], + ]); + + $account = ladill_account(); + $email = strtolower($validated['email']); + + if ($email === strtolower($account->email)) { + return back()->withErrors(['email' => 'The owner is already on the account.']); + } + + $member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]); + $member->role = $validated['role']; + if (! $member->exists) { + $member->status = QrTeamMember::STATUS_INVITED; + $member->token = Str::random(40); + } + $member->save(); + + if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) { + QrTeamMember::linkPendingInvitesFor($existing); + } + + return back()->with('success', $email.' invited.'); + } + + public function updateRole(Request $request, QrTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $validated = $request->validate(['role' => ['required', 'in:admin,member']]); + $member->update(['role' => $validated['role']]); + + return back()->with('success', 'Role updated.'); + } + + public function destroy(Request $request, QrTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $member->delete(); + + return back()->with('success', 'Member removed.'); + } + + public function switchAccount(Request $request): RedirectResponse + { + $validated = $request->validate(['account' => ['required', 'integer']]); + + abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403); + + $request->session()->put('ladill_account', (int) $validated['account']); + + return redirect()->route('qr.dashboard'); + } + + private function canManage(Request $request): bool + { + $user = $request->user(); + $account = ladill_account(); + + if ($user->id === $account->id) { + return true; + } + + return $user->memberships() + ->where('account_id', $account->id) + ->where('role', QrTeamMember::ROLE_ADMIN) + ->exists(); + } +} diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php new file mode 100644 index 0000000..f1c5732 --- /dev/null +++ b/app/Http/Middleware/SetActingAccount.php @@ -0,0 +1,32 @@ +user()) { + $accountId = (int) $request->session()->get('ladill_account', $user->id); + + if (! $user->canAccessAccount($accountId)) { + $accountId = $user->id; + $request->session()->put('ladill_account', $accountId); + } + + $account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user); + + $request->attributes->set('actingAccount', $account); + View::share('actingAccount', $account); + View::share('accessibleAccounts', $user->accessibleAccounts()); + } + + return $next($request); + } +} diff --git a/app/Models/QrDocument.php b/app/Models/QrDocument.php index a20845e..1ce2d16 100644 --- a/app/Models/QrDocument.php +++ b/app/Models/QrDocument.php @@ -3,9 +3,33 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; -/** Stub — document/file QRs belong to Ladill Transfer, not QR Plus. */ class QrDocument extends Model { - protected $guarded = []; + protected $fillable = [ + 'user_id', + 'title', + 'disk', + 'path', + 'mime_type', + 'size_bytes', + 'page_count', + ]; + + protected $casts = [ + 'size_bytes' => 'integer', + 'page_count' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function qrCodes(): HasMany + { + return $this->hasMany(QrCode::class); + } } diff --git a/app/Models/QrTeamMember.php b/app/Models/QrTeamMember.php new file mode 100644 index 0000000..c8d350c --- /dev/null +++ b/app/Models/QrTeamMember.php @@ -0,0 +1,46 @@ + 'datetime']; + + public function account(): BelongsTo + { + return $this->belongsTo(User::class, 'account_id'); + } + + public function member(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public static function linkPendingInvitesFor(User $user): void + { + static::query() + ->whereNull('user_id') + ->where('status', self::STATUS_INVITED) + ->whereRaw('LOWER(email) = ?', [strtolower($user->email)]) + ->update([ + 'user_id' => $user->id, + 'status' => self::STATUS_ACTIVE, + 'accepted_at' => now(), + 'token' => null, + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1a15490..0d81bdc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Laravel\Sanctum\HasApiTokens; /** @@ -27,6 +28,26 @@ class User extends Authenticatable return ['email_verified_at' => 'datetime', 'password' => 'hashed']; } + public function memberships(): HasMany + { + return $this->hasMany(QrTeamMember::class, 'user_id') + ->where('status', QrTeamMember::STATUS_ACTIVE); + } + + public function canAccessAccount(int $accountId): bool + { + return $accountId === $this->id + || $this->memberships()->where('account_id', $accountId)->exists(); + } + + /** @return Collection */ + public function accessibleAccounts(): Collection + { + $ids = $this->memberships()->pluck('account_id')->all(); + + return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values(); + } + public function qrWallet(): HasOne { return $this->hasOne(QrWallet::class); diff --git a/app/Policies/QrCodePolicy.php b/app/Policies/QrCodePolicy.php index f7d2156..d98f4f2 100644 --- a/app/Policies/QrCodePolicy.php +++ b/app/Policies/QrCodePolicy.php @@ -9,16 +9,16 @@ class QrCodePolicy { public function view(User $user, QrCode $qrCode): bool { - return $user->id === $qrCode->user_id; + return $user->canAccessAccount($qrCode->user_id); } public function update(User $user, QrCode $qrCode): bool { - return $user->id === $qrCode->user_id; + return $user->canAccessAccount($qrCode->user_id); } public function delete(User $user, QrCode $qrCode): bool { - return $user->id === $qrCode->user_id; + return $user->canAccessAccount($qrCode->user_id); } } diff --git a/app/Support/Qr/QrTypeCatalog.php b/app/Support/Qr/QrTypeCatalog.php index 5f883a4..1d3492d 100644 --- a/app/Support/Qr/QrTypeCatalog.php +++ b/app/Support/Qr/QrTypeCatalog.php @@ -14,6 +14,7 @@ class QrTypeCatalog { return [ QrCode::TYPE_URL, + QrCode::TYPE_DOCUMENT, QrCode::TYPE_LINK_LIST, QrCode::TYPE_BUSINESS, QrCode::TYPE_WIFI, @@ -31,6 +32,12 @@ class QrTypeCatalog 'category' => 'basic', 'icon' => 'website.svg', ], + QrCode::TYPE_DOCUMENT => [ + 'label' => 'PDF', + 'description' => 'Hosted PDF reader', + 'category' => 'basic', + 'icon' => 'terms.svg', + ], QrCode::TYPE_LINK_LIST => [ 'label' => 'List of Links', 'description' => 'Landing page with multiple links', diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..9c29590 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [ + \App\Http\Middleware\SetActingAccount::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index f05cb5c..0bbdef9 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "chillerlan/php-qrcode": "^5.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", diff --git a/composer.lock b/composer.lock index 67a5013..1f2b027 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8560d2f6b958be4f0b7f6cb29c85c5fc", + "content-hash": "529339feb28ae432869697327a78cb16", "packages": [ { "name": "brick/math", @@ -135,6 +135,168 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "chillerlan/php-qrcode", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-qrcode.git", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-qrcode/zipball/7b66282572fc14075c0507d74d9837dab25b38d6", + "reference": "7b66282572fc14075c0507d74d9837dab25b38d6", + "shasum": "" + }, + "require": { + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "chillerlan/php-authenticator": "^4.3.1 || ^5.2.1", + "ext-fileinfo": "*", + "phan/phan": "^5.5.2", + "phpcompatibility/php-compatibility": "10.x-dev", + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^9.6", + "setasign/fpdf": "^1.8.2", + "slevomat/coding-standard": "^8.23.0", + "squizlabs/php_codesniffer": "^4.0.0" + }, + "suggest": { + "chillerlan/php-authenticator": "Yet another Google authenticator! Also creates URIs for mobile apps.", + "setasign/fpdf": "Required to use the QR FPDF output.", + "simple-icons/simple-icons": "SVG icons that you can use to embed as logos in the QR Code" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\QRCode\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "Apache-2.0" + ], + "authors": [ + { + "name": "Kazuhiko Arase", + "homepage": "https://github.com/kazuhikoarase/qrcode-generator" + }, + { + "name": "ZXing Authors", + "homepage": "https://github.com/zxing/zxing" + }, + { + "name": "Ashot Khanamiryan", + "homepage": "https://github.com/khanamiryan/php-qrcode-detector-decoder" + }, + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage": "https://github.com/chillerlan/php-qrcode/graphs/contributors" + } + ], + "description": "A QR Code generator and reader with a user-friendly API. PHP 7.4+", + "homepage": "https://github.com/chillerlan/php-qrcode", + "keywords": [ + "phpqrcode", + "qr", + "qr code", + "qr-reader", + "qrcode", + "qrcode-generator", + "qrcode-reader" + ], + "support": { + "docs": "https://php-qrcode.readthedocs.io", + "issues": "https://github.com/chillerlan/php-qrcode/issues", + "source": "https://github.com/chillerlan/php-qrcode" + }, + "funding": [ + { + "url": "https://ko-fi.com/codemasher", + "type": "Ko-Fi" + } + ], + "time": "2025-11-23T23:51:44+00:00" + }, + { + "name": "chillerlan/php-settings-container", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/chillerlan/php-settings-container.git", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/chillerlan/php-settings-container/zipball/a0a487cbf5344f721eb504bf0f59bada40c381b7", + "reference": "a0a487cbf5344f721eb504bf0f59bada40c381b7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^8.1" + }, + "require-dev": { + "phan/phan": "^5.5.2", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^10.5", + "slevomat/coding-standard": "^8.22", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "chillerlan\\Settings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Smiley", + "email": "smiley@chillerlan.net", + "homepage": "https://github.com/codemasher" + } + ], + "description": "A container class for immutable settings objects. Not a DI container.", + "homepage": "https://github.com/chillerlan/php-settings-container", + "keywords": [ + "Settings", + "configuration", + "container", + "helper", + "property hook" + ], + "support": { + "issues": "https://github.com/chillerlan/php-settings-container/issues", + "source": "https://github.com/chillerlan/php-settings-container" + }, + "funding": [ + { + "url": "https://www.paypal.com/donate?hosted_button_id=WLYUNAT9ZTJZ4", + "type": "custom" + }, + { + "url": "https://ko-fi.com/codemasher", + "type": "ko_fi" + } + ], + "time": "2026-03-20T21:10:52+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", diff --git a/database/migrations/2026_06_06_210000_create_qr_team_members_table.php b/database/migrations/2026_06_06_210000_create_qr_team_members_table.php new file mode 100644 index 0000000..b8f9aa4 --- /dev/null +++ b/database/migrations/2026_06_06_210000_create_qr_team_members_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('account_id')->index(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('email'); + $table->string('role', 20)->default('member'); + $table->string('status', 20)->default('invited'); + $table->string('token', 64)->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['account_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('qr_team_members'); + } +}; diff --git a/public/images/qr-icons/terms.svg b/public/images/qr-icons/terms.svg new file mode 100644 index 0000000..6b352e0 --- /dev/null +++ b/public/images/qr-icons/terms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 0ca8c89..06d5cbe 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -16,6 +16,10 @@ 'icon' => ''], ['name' => 'Billing', 'route' => route('account.billing'), 'active' => request()->routeIs('account.billing'), 'icon' => ''], + ['name' => 'Team', 'route' => route('account.team'), 'active' => request()->routeIs('account.team*'), + 'icon' => ''], + ['name' => 'Developers', 'route' => route('account.developers'), 'active' => request()->routeIs('account.developers*'), + 'icon' => ''], ['name' => 'Settings', 'route' => route('account.settings'), 'active' => request()->routeIs('account.settings'), 'icon' => ''], ]; diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php index 350392d..2630f42 100644 --- a/resources/views/partials/topbar-qr.blade.php +++ b/resources/views/partials/topbar-qr.blade.php @@ -16,6 +16,28 @@
+ @if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1) + + @endif +