Add team, developers, PDF type, and QR rendering dependency.
Deploy Ladill QR Plus / deploy (push) Successful in 42s

Enables account collaboration, API tokens, hosted PDF codes, and fixes QR detail 500s by declaring chillerlan/php-qrcode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 06:16:13 +00:00
co-authored by Cursor
parent bcd1cf5d28
commit 8c3e9d3c26
23 changed files with 892 additions and 28 deletions
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -81,6 +82,8 @@ class SsoLoginController extends Controller
], ],
); );
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true); Auth::login($user, remember: true);
$request->session()->regenerate(); $request->session()->regenerate();
@@ -30,7 +30,7 @@ class AccountController extends Controller
public function wallet(): View public function wallet(): View
{ {
$user = auth()->user(); $user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.wallet', [ return view('qr.account.wallet', [
@@ -43,7 +43,7 @@ class AccountController extends Controller
public function billing(): View public function billing(): View
{ {
$user = auth()->user(); $user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id); [$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.billing', [ return view('qr.account.billing', [
@@ -56,6 +56,6 @@ class AccountController extends Controller
public function settings(): View public function settings(): View
{ {
return view('qr.account.settings', ['account' => auth()->user()]); return view('qr.account.settings', ['account' => ladill_account()]);
} }
} }
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
* token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('qr.account.developers', [
'tokens' => $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 wont 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.');
}
}
@@ -21,20 +21,20 @@ class OverviewController extends Controller
public function index(Request $request): View public function index(Request $request): View
{ {
$user = $request->user(); $account = ladill_account();
$wallet = $this->manager->walletFor($user); $wallet = $this->manager->walletFor($account);
$codes = $user->qrCodes() $codes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes()) ->whereIn('type', QrTypeCatalog::plusTypes())
->latest() ->latest()
->limit(5) ->limit(5)
->get(); ->get();
$activeCount = $user->qrCodes() $activeCount = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes()) ->whereIn('type', QrTypeCatalog::plusTypes())
->where('is_active', true) ->where('is_active', true)
->count(); ->count();
$scans30d = $user->qrCodes() $scans30d = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes()) ->whereIn('type', QrTypeCatalog::plusTypes())
->withSum(['scanEvents as scans_30d' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))], 'id') ->withSum(['scanEvents as scans_30d' => fn ($q) => $q->where('created_at', '>=', now()->subDays(30))], 'id')
->get() ->get()
@@ -42,10 +42,10 @@ class OverviewController extends Controller
$balanceMinor = 0; $balanceMinor = 0;
try { try {
$balanceMinor = $this->billing->balanceMinor($user->public_id); $balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::warning('QR Plus dashboard could not load wallet balance', [ Log::warning('QR Plus dashboard could not load wallet balance', [
'user' => $user->public_id, 'user' => $account->public_id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
+23 -11
View File
@@ -40,19 +40,19 @@ class QrCodeController extends Controller
public function index(Request $request): View public function index(Request $request): View
{ {
$user = $request->user(); $account = ladill_account();
$wallet = $this->manager->walletFor($user); $wallet = $this->manager->walletFor($account);
$qrCodes = $user->qrCodes() $qrCodes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes()) ->whereIn('type', QrTypeCatalog::plusTypes())
->latest() ->latest()
->get(); ->get();
$ladillWalletBalance = 0.0; $ladillWalletBalance = 0.0;
try { try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($user->public_id) / 100; $ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) { } catch (Throwable $e) {
Log::warning('QR Plus index could not load Ladill wallet balance', [ Log::warning('QR Plus index could not load Ladill wallet balance', [
'user' => $user->public_id, 'user' => $account->public_id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
@@ -69,7 +69,8 @@ class QrCodeController extends Controller
public function create(Request $request): View 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', [ return view('qr-codes.create', [
'wallet' => $wallet, 'wallet' => $wallet,
@@ -80,7 +81,7 @@ class QrCodeController extends Controller
'frameStyles' => QrFrameStyleCatalog::visible(), 'frameStyles' => QrFrameStyleCatalog::visible(),
'pricePerQr' => QrWallet::pricePerQr(), 'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(), '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', 'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]); ]);
} }
@@ -134,7 +135,7 @@ class QrCodeController extends Controller
} }
try { try {
$qrCode = $this->manager->create($request->user(), array_merge( $qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(), $request->all(),
[ [
'style' => $validated['style'] ?? [], 'style' => $validated['style'] ?? [],
@@ -175,13 +176,24 @@ class QrCodeController extends Controller
$logoDataUri = ! empty($qrStyle['logo_path']) $logoDataUri = ! empty($qrStyle['logo_path'])
? $this->imageGenerator->logoDataUri($qrStyle['logo_path']) ? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
: null; : null;
$wallet = $this->manager->walletFor($request->user()); $account = ladill_account();
$wallet = $this->manager->walletFor($account);
$summary = $this->analytics->summaryFor($qrCode); $summary = $this->analytics->summaryFor($qrCode);
$dailyScans = $this->analytics->dailyScans($qrCode, 30); $dailyScans = $this->analytics->dailyScans($qrCode, 30);
$devices = $this->analytics->breakdown($qrCode, 'device_type'); $devices = $this->analytics->breakdown($qrCode, 'device_type');
$browsers = $this->analytics->breakdown($qrCode, 'browser'); $browsers = $this->analytics->breakdown($qrCode, 'browser');
$recentScans = $this->analytics->recentScans($qrCode); $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', [ return view('qr-codes.show', [
'qrCode' => $qrCode, 'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri, 'previewDataUri' => $previewDataUri,
@@ -199,7 +211,7 @@ class QrCodeController extends Controller
'recentScans' => $recentScans, 'recentScans' => $recentScans,
'pricePerQr' => QrWallet::pricePerQr(), 'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(), 'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($request->user()->public_id) / 100, 'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet', 'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]); ]);
} }
@@ -281,7 +293,7 @@ class QrCodeController extends Controller
$style['logo_path'] = $tempLogoPath; $style['logo_path'] = $tempLogoPath;
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) { } elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
$existingPath = $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)) { if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
$style['logo_path'] = $existingPath; $style['logo_path'] = $existingPath;
} }
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = QrTeamMember::query()
->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();
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->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);
}
}
+26 -2
View File
@@ -3,9 +3,33 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; 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 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);
}
} }
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Membership linking a user to an account (owner) whose QR codes they can manage. */
class QrTeamMember extends Model
{
public const ROLE_ADMIN = 'admin';
public const ROLE_MEMBER = 'member';
public const STATUS_INVITED = 'invited';
public const STATUS_ACTIVE = 'active';
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
protected $casts = ['accepted_at' => '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,
]);
}
}
+21
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
/** /**
@@ -27,6 +28,26 @@ class User extends Authenticatable
return ['email_verified_at' => 'datetime', 'password' => 'hashed']; 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<int, User> */
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 public function qrWallet(): HasOne
{ {
return $this->hasOne(QrWallet::class); return $this->hasOne(QrWallet::class);
+3 -3
View File
@@ -9,16 +9,16 @@ class QrCodePolicy
{ {
public function view(User $user, QrCode $qrCode): bool 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 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 public function delete(User $user, QrCode $qrCode): bool
{ {
return $user->id === $qrCode->user_id; return $user->canAccessAccount($qrCode->user_id);
} }
} }
+7
View File
@@ -14,6 +14,7 @@ class QrTypeCatalog
{ {
return [ return [
QrCode::TYPE_URL, QrCode::TYPE_URL,
QrCode::TYPE_DOCUMENT,
QrCode::TYPE_LINK_LIST, QrCode::TYPE_LINK_LIST,
QrCode::TYPE_BUSINESS, QrCode::TYPE_BUSINESS,
QrCode::TYPE_WIFI, QrCode::TYPE_WIFI,
@@ -31,6 +32,12 @@ class QrTypeCatalog
'category' => 'basic', 'category' => 'basic',
'icon' => 'website.svg', 'icon' => 'website.svg',
], ],
QrCode::TYPE_DOCUMENT => [
'label' => 'PDF',
'description' => 'Hosted PDF reader',
'category' => 'basic',
'icon' => 'terms.svg',
],
QrCode::TYPE_LINK_LIST => [ QrCode::TYPE_LINK_LIST => [
'label' => 'List of Links', 'label' => 'List of Links',
'description' => 'Landing page with multiple links', 'description' => 'Landing page with multiple links',
+3 -1
View File
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->web(append: [
\App\Http\Middleware\SetActingAccount::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
+1
View File
@@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"chillerlan/php-qrcode": "^5.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
Generated
+163 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "8560d2f6b958be4f0b7f6cb29c85c5fc", "content-hash": "529339feb28ae432869697327a78cb16",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -135,6 +135,168 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "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", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@@ -0,0 +1,30 @@
<?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('qr_team_members', function (Blueprint $table) {
$table->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');
}
};
+4
View File
@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 12.5C12.5 12.7652 12.3946 13.0196 12.2071 13.2071C12.0196 13.3946 11.7652 13.5 11.5 13.5H2.5C2.23478 13.5 1.98043 13.3946 1.79289 13.2071C1.60536 13.0196 1.5 12.7652 1.5 12.5V1.5C1.5 1.23478 1.60536 0.98043 1.79289 0.792893C1.98043 0.605357 2.23478 0.5 2.5 0.5H7.5L12.5 5.5V12.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 8.5L6 9.5L8.5 5.5" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 564 B

@@ -16,6 +16,10 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 12m18 0v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 9m18 0V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v3" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 12m18 0v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 9m18 0V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v3" />'],
['name' => 'Billing', 'route' => route('account.billing'), 'active' => request()->routeIs('account.billing'), ['name' => 'Billing', 'route' => route('account.billing'), 'active' => request()->routeIs('account.billing'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />'],
['name' => 'Team', 'route' => route('account.team'), 'active' => request()->routeIs('account.team*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
['name' => 'Developers', 'route' => route('account.developers'), 'active' => request()->routeIs('account.developers*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />'],
['name' => 'Settings', 'route' => route('account.settings'), 'active' => request()->routeIs('account.settings'), ['name' => 'Settings', 'route' => route('account.settings'), 'active' => request()->routeIs('account.settings'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />'], 'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.241.437-.613.43-.992a6.932 6.932 0 0 1 0-.255c.007-.378-.138-.75-.43-.991l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />'],
]; ];
@@ -16,6 +16,28 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1)
<div x-data="{ open: false }" class="relative hidden lg:block">
<button @click="open = !open" class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
<span class="max-w-[120px] truncate">{{ $actingAccount->name ?? $actingAccount->email }}</span>
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19 9-7 7-7-7"/></svg>
</button>
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 z-30 mt-2 w-60 rounded-xl border border-slate-200 bg-white p-1 shadow-lg">
<p class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">Switch account</p>
@foreach ($accessibleAccounts as $acctOption)
<form method="POST" action="{{ route('account.switch') }}">
@csrf
<input type="hidden" name="account" value="{{ $acctOption->id }}">
<button type="submit" class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-50 {{ $acctOption->id === $actingAccount->id ? 'font-semibold text-indigo-700' : 'text-slate-700' }}">
<span class="truncate">{{ $acctOption->id === auth()->id() ? 'My account' : ($acctOption->name ?? $acctOption->email) }}</span>
@if ($acctOption->id === $actingAccount->id)<span class="text-indigo-600"></span>@endif
</button>
</form>
@endforeach
</div>
</div>
@endif
<div class="relative hidden lg:block" <div class="relative hidden lg:block"
x-data="notificationDropdown({ x-data="notificationDropdown({
unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }}, unreadUrl: {{ \Illuminate\Support\Js::from(route('notifications.unread')) }},
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $qrCode->label }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: #f1f5f9;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
height: 100dvh;
color: #1e293b;
}
#toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
height: 3.25rem;
background: #fff;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
z-index: 20;
}
.tb-label {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
max-width: 65%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-download {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.9rem;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #334155;
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.tb-download:hover { background: #e2e8f0; }
.tb-download svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; }
#scroll {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem 0.75rem;
gap: 0.75rem;
}
#loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.875rem;
color: #94a3b8;
font-size: 0.8125rem;
padding: 4rem 0;
width: 100%;
}
.spinner {
width: 2rem; height: 2rem;
border: 2px solid #e2e8f0;
border-top-color: #94a3b8;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pdf-page {
display: block;
max-width: 100%;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
background: #fff;
}
#bottom {
flex-shrink: 0;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
color: #94a3b8;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="tb-label">{{ $qrCode->label }}</span>
@if($allowDownload)
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
Download
</a>
@else
<span></span>
@endif
</div>
<div id="scroll">
<div id="loading">
<div class="spinner"></div>
Loading document…
</div>
</div>
<div id="bottom"><span id="page-info"></span></div>
<script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.worker.min.mjs';
const fileUrl = @json($fileUrl);
const scroll = document.getElementById('scroll');
const loading = document.getElementById('loading');
const pageInfo = document.getElementById('page-info');
async function main() {
const pdf = await pdfjsLib.getDocument(fileUrl).promise;
const n = pdf.numPages;
pageInfo.textContent = n + ' page' + (n !== 1 ? 's' : '');
loading.remove();
const maxW = scroll.clientWidth - 24;
for (let i = 1; i <= n; i++) {
const pg = await pdf.getPage(i);
const vp0 = pg.getViewport({ scale: 1 });
const scale = Math.min(maxW / vp0.width, 2);
const vp = pg.getViewport({ scale });
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = vp.width * dpr;
canvas.height = vp.height * dpr;
canvas.style.width = vp.width + 'px';
canvas.style.height = vp.height + 'px';
scroll.appendChild(canvas);
await pg.render({ canvasContext: canvas.getContext('2d'), viewport: pg.getViewport({ scale: scale * dpr }) }).promise;
}
}
main().catch(err => {
loading.innerHTML = '<p style="color:#ef4444;text-align:center;padding:1rem">Failed to load document.</p>';
console.error(err);
});
</script>
</body>
</html>
@@ -0,0 +1,66 @@
<x-user-layout>
<x-slot name="title">Developers</x-slot>
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your QR codes programmatically.</p>
@if($newToken)
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
<p class="text-sm font-semibold text-emerald-900">Your new token copy it now</p>
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied </span>
</button>
</div>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
<input type="text" name="name" required placeholder="e.g. CI server"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Generate token</button>
</form>
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
@forelse($tokens as $token)
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
<div>
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
<p class="text-xs text-slate-400">
Created {{ $token->created_at->diffForHumans() }} ·
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
</p>
</div>
<form method="POST" action="{{ route('account.developers.destroy', $token->id) }}" onsubmit="return confirm('Revoke {{ $token->name }}?')">
@csrf @method('DELETE')
<button class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
</form>
</div>
@empty
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
@endforelse
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
<h2 class="text-sm font-semibold text-white">Quick start</h2>
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/me \
-H "Authorization: Bearer &lt;your-token&gt;" \
-H "Accept: application/json"</code></pre>
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>. QR code management endpoints coming soon.</p>
</div>
</div>
</x-user-layout>
+84
View File
@@ -0,0 +1,84 @@
<x-user-layout>
<x-slot name="title">Team</x-slot>
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this accounts QR codes.</p>
@if($canManage)
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Email</label>
<input type="email" name="email" required placeholder="teammate@example.com"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">Role</label>
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Send invite</button>
</form>
<p class="mt-2 text-[11px] text-slate-400">Admins can manage QR codes and the team. Members can manage QR codes. Invitees join by signing in with that email.</p>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
<ul class="divide-y divide-slate-50">
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">
{{ $account->name ?? $account->email }}
@if($isOwner)<span class="text-xs font-normal text-slate-400">(you)</span>@endif
</p>
<p class="text-xs text-slate-400">{{ $account->email }}</p>
</div>
</div>
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
</li>
@forelse($members as $member)
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
<p class="text-xs text-slate-400">{{ $member->email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if($member->status === 'invited')
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
@endif
@if($canManage)
<form method="POST" action="{{ route('account.team.role', $member) }}">
@csrf @method('PATCH')
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
<option value="member" @selected($member->role === 'member')>Member</option>
<option value="admin" @selected($member->role === 'admin')>Admin</option>
</select>
</form>
<form method="POST" action="{{ route('account.team.destroy', $member) }}" onsubmit="return confirm('Remove {{ $member->email }}?')">
@csrf @method('DELETE')
<button class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
</form>
@else
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
@endif
</div>
</li>
@empty
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
@endforelse
</ul>
</div>
</div>
</x-user-layout>
+14
View File
@@ -4,7 +4,9 @@ use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Qr\OverviewController; use App\Http\Controllers\Qr\OverviewController;
use App\Http\Controllers\Qr\TeamController;
use App\Http\Controllers\Qr\QrCodeController; use App\Http\Controllers\Qr\QrCodeController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -20,6 +22,8 @@ Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannel
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qr.dashboard') : view('qr.signed-out'))->name('qr.signed-out'); Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qr.dashboard') : view('qr.signed-out'))->name('qr.signed-out');
Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve'); Route::get('/q/{shortCode}', [QrScanController::class, 'resolve'])->name('qr.public.resolve');
Route::get('/q/{shortCode}/view', [QrScanController::class, 'view'])->name('qr.public.view');
Route::get('/q/{shortCode}/file', [QrScanController::class, 'file'])->name('qr.public.file');
Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo'); Route::get('/q/{shortCode}/business-logo', [QrScanController::class, 'businessLogo'])->name('qr.public.business.logo');
Route::get('/q/{shortCode}/business-cover', [QrScanController::class, 'businessCover'])->name('qr.public.business.cover'); Route::get('/q/{shortCode}/business-cover', [QrScanController::class, 'businessCover'])->name('qr.public.business.cover');
Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->name('qr.public.app.icon'); Route::get('/q/{shortCode}/app-icon', [QrScanController::class, 'appIcon'])->name('qr.public.app.icon');
@@ -46,4 +50,14 @@ Route::middleware(['auth'])->group(function () {
Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet'); Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet');
Route::get('/billing', [AccountController::class, 'billing'])->name('account.billing'); Route::get('/billing', [AccountController::class, 'billing'])->name('account.billing');
Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings'); Route::get('/settings', [AccountController::class, 'settings'])->name('account.settings');
Route::get('/team', [TeamController::class, 'index'])->name('account.team');
Route::post('/team', [TeamController::class, 'store'])->name('account.team.store');
Route::patch('/team/{member}', [TeamController::class, 'updateRole'])->name('account.team.role');
Route::delete('/team/{member}', [TeamController::class, 'destroy'])->name('account.team.destroy');
Route::post('/switch-account', [TeamController::class, 'switchAccount'])->name('account.switch');
Route::get('/developers', [DeveloperController::class, 'index'])->name('account.developers');
Route::post('/developers', [DeveloperController::class, 'store'])->name('account.developers.store');
Route::delete('/developers/{token}', [DeveloperController::class, 'destroy'])->whereNumber('token')->name('account.developers.destroy');
}); });