Bootstrap Ladill Link from QR Plus extract template.
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function connect(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->query('redirect', route('qr.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||
if ($loginHint !== '') {
|
||||
$query['login_hint'] = $loginHint;
|
||||
}
|
||||
|
||||
if (! $request->boolean('interactive')) {
|
||||
$query['prompt'] = 'none';
|
||||
}
|
||||
|
||||
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||
|
||||
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
|
||||
$request->session()->put('sso.popup', true);
|
||||
|
||||
return view('auth.sso-signing-in', [
|
||||
'authorizeUrl' => $authorizeUrl,
|
||||
'intended' => $intended,
|
||||
'fallbackUrl' => route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($authorizeUrl);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
|
||||
|
||||
$popup = (bool) $request->session()->get('sso.popup');
|
||||
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
||||
}
|
||||
|
||||
if (! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
if (! $user) {
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return $this->finishCallback($request, $intended, null, $popup);
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Per-app sign-out: end only this app's session and keep the platform
|
||||
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently
|
||||
// without a password. Full "sign out of all Ladill apps" lives on account.
|
||||
return redirect()->away($this->defaultSignedOutUrl());
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||
'embedded' => 1,
|
||||
'return' => $return,
|
||||
]);
|
||||
|
||||
return view('auth.sso-logout-bridge', [
|
||||
'hubUrl' => $hubUrl,
|
||||
'return' => $return,
|
||||
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if ($this->shouldLogoutForMailbox($request)) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||
{
|
||||
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||
if ($refreshToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
|
||||
if ($tokenRes->failed()) {
|
||||
$request->session()->forget('sso.refresh_token');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||
{
|
||||
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||
if ($refreshToken !== '') {
|
||||
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = (string) ($claims->json('email') ?: '');
|
||||
if ($email !== '') {
|
||||
$request->session()->put('sso.login_hint', $email);
|
||||
}
|
||||
|
||||
return User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function shouldLogoutForMailbox(Request $request): bool
|
||||
{
|
||||
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
|
||||
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower((string) $user->email) !== $mailbox;
|
||||
}
|
||||
|
||||
private function finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
|
||||
{
|
||||
if ($popup) {
|
||||
return view('auth.sso-popup-done', [
|
||||
'intended' => $intended,
|
||||
'error' => $error,
|
||||
'appOrigin' => rtrim((string) config('app.url'), '/'),
|
||||
'fallbackUrl' => route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->safeRedirect($intended, route('qr.dashboard'));
|
||||
}
|
||||
|
||||
private function defaultSignedOutUrl(): string
|
||||
{
|
||||
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
|
||||
if (str_ends_with($name, '.signed-out')) {
|
||||
return route($name);
|
||||
}
|
||||
}
|
||||
|
||||
return 'https://'.config('app.platform_domain');
|
||||
}
|
||||
|
||||
private function safeReturnUrl(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $this->defaultSignedOutUrl();
|
||||
}
|
||||
|
||||
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()->away($fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function unread(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->unreadNotifications()
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => class_basename($n->type),
|
||||
'title' => $n->data['title'] ?? 'Notification',
|
||||
'message' => $n->data['message'] ?? '',
|
||||
'icon' => $n->data['icon'] ?? 'bell',
|
||||
'url' => $n->data['url'] ?? null,
|
||||
'created_at' => $n->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notification = $request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($notification) {
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Support\Qr\QrTypeCatalog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse|View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
|
||||
|
||||
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
return view('search.index', [
|
||||
'query' => $q,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
private function results(string $q): array
|
||||
{
|
||||
$like = '%'.$q.'%';
|
||||
|
||||
return ladill_account()->qrCodes()
|
||||
->whereIn('type', QrTypeCatalog::plusTypes())
|
||||
->where(function ($query) use ($like): void {
|
||||
$query->where('label', 'like', $like)
|
||||
->orWhere('short_code', 'like', $like)
|
||||
->orWhere('destination_url', 'like', $like);
|
||||
})
|
||||
->orderByDesc('updated_at')
|
||||
->limit(15)
|
||||
->get()
|
||||
->map(fn (QrCode $code): array => [
|
||||
'type' => 'qr',
|
||||
'title' => $code->label,
|
||||
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(),
|
||||
'url' => route('user.qr-codes.show', $code),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Lightweight wallet balance for the avatar dropdown widget.
|
||||
*/
|
||||
class WalletBalanceController extends Controller
|
||||
{
|
||||
public function balance(Request $request, BillingClient $billing): JsonResponse
|
||||
{
|
||||
$publicId = (string) $request->user()->public_id;
|
||||
|
||||
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
|
||||
try {
|
||||
return $billing->balanceMinor($publicId);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if ($minor === null) {
|
||||
return response()->json(['available' => false]);
|
||||
}
|
||||
|
||||
$currency = (string) config('billing.currency', 'GHS');
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'balance_minor' => $minor,
|
||||
'currency' => $currency,
|
||||
'formatted' => $currency.' '.number_format($minor / 100, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* In-app wallet top-up. Posts to the central identity API to start a Paystack
|
||||
* checkout against the one Ladill wallet, then hands the user to checkout. This
|
||||
* backs the two-step "Add funds" modal so users never leave for the wallet page
|
||||
* when they hit an insufficient-balance action.
|
||||
*/
|
||||
class WalletTopupController extends Controller
|
||||
{
|
||||
public function store(Request $request, BillingClient $billing): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'amount' => ['required', 'numeric', 'min:5', 'max:5000'],
|
||||
'return_url' => ['nullable', 'url', 'max:2048'],
|
||||
]);
|
||||
|
||||
$returnUrl = $validated['return_url'] ?? (string) (url()->previous() ?: url('/'));
|
||||
|
||||
try {
|
||||
$checkoutUrl = $billing->topup(
|
||||
(string) $request->user()->public_id,
|
||||
(float) $validated['amount'],
|
||||
$returnUrl,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$checkoutUrl = '';
|
||||
}
|
||||
|
||||
if ($checkoutUrl === '') {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['error' => 'Unable to start payment. Please try again.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Unable to start payment. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['checkout_url' => $checkoutUrl]);
|
||||
}
|
||||
|
||||
return redirect()->away($checkoutUrl);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user