diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index 96655d8..a51da80 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -7,13 +7,14 @@ use App\Models\EtsyConnection; use App\Models\IntegrationSetting; use App\Services\Etsy; use App\Services\IntegrationSettings; +use App\Services\Parcel2Go; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class EtsySettingsController extends Controller { - public function index(Request $request, Etsy $etsy, IntegrationSettings $settings): View + public function index(Request $request, Etsy $etsy, IntegrationSettings $settings, Parcel2Go $parcel2Go): View { abort_unless($request->user()->role === 'admin', 403); @@ -37,6 +38,9 @@ class EtsySettingsController extends Controller 'base_url' => $parcel2go['base_url'] ?? '', 'scope' => $parcel2go['scope'] ?? 'public-api', 'collection_postcode' => $parcel2go['collection_postcode'] ?? '', + 'profile_client_configured' => $parcel2Go->profileClientConfigured(), + 'profile_connected' => $parcel2Go->profileConnected(), + 'callback_url' => route('parcel2go.callback'), ], 'bambuddy' => [ 'stored' => in_array('bambuddy', $storedIntegrations, true), diff --git a/app/Http/Controllers/IntegrationSettingsController.php b/app/Http/Controllers/IntegrationSettingsController.php index 063788e..e1ede92 100644 --- a/app/Http/Controllers/IntegrationSettingsController.php +++ b/app/Http/Controllers/IntegrationSettingsController.php @@ -22,6 +22,7 @@ class IntegrationSettingsController extends Controller 'base_url' => ['nullable', 'url:https', 'max:2048'], 'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'], 'collection_postcode' => ['nullable', 'string', 'max:12'], + 'profile_client_id' => ['nullable', 'string', 'max:255'], ], default => [ 'url' => ['nullable', 'url:https', 'max:2048'], diff --git a/app/Http/Controllers/Parcel2GoConnectionController.php b/app/Http/Controllers/Parcel2GoConnectionController.php new file mode 100644 index 0000000..715661b --- /dev/null +++ b/app/Http/Controllers/Parcel2GoConnectionController.php @@ -0,0 +1,47 @@ +user()->role === 'admin', 403); + if (! $parcel2Go->profileClientConfigured()) { + return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Save the Parcel2Go Authorization Code Client ID first.']); + } + + $state = bin2hex(random_bytes(24)); + $verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '='); + $request->session()->put('parcel2go_oauth', ['state' => $state, 'verifier' => $verifier, 'started' => time()]); + + return redirect()->away($parcel2Go->authorizationUrl($state, $verifier)); + } + + public function callback(Request $request, Parcel2Go $parcel2Go): RedirectResponse + { + abort_unless($request->user()->role === 'admin', 403); + $pending = $request->session()->pull('parcel2go_oauth'); + if (! is_array($pending) || time() - ($pending['started'] ?? 0) > 600 + || ! is_string($request->query('state')) + || ! hash_equals($pending['state'] ?? '', $request->query('state')) + || ! is_string($request->query('code'))) { + return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go consent expired or was not verified. Please try again.']); + } + + try { + $parcel2Go->connectProfile($request->query('code'), $pending['verifier']); + + return redirect()->route('settings.index')->with('status', 'Parcel2Go account connected. Shipment history is available in Shipping.'); + } catch (Throwable $exception) { + report($exception); + + return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go rejected the account connection. Check the Client ID, scopes and callback URL.']); + } + } +} diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index c470f08..0b452e9 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -42,11 +42,13 @@ class ShippingController extends Controller $orders = $parcel2Go->fetch('me/orders/detail'); $historyAvailable = true; } catch (Throwable $exception) { - $profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true) - ? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.' - : 'Parcel2Go shipment history is temporarily unavailable.'; + $profileNotice = $parcel2Go->profileConnected() + ? 'Parcel2Go account access needs attention. Reconnect the account in Settings.' + : ($exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true) + ? 'Parcel2Go denied shipment history for this client. Connect an Authorization Code client in Settings.' + : 'Parcel2Go shipment history is temporarily unavailable.'); } - if ($parcel2Go->hasScope('my-profile')) { + if ($parcel2Go->profileConnected() || $parcel2Go->hasScope('my-profile')) { try { $account = $parcel2Go->fetch('me/detail'); $balance = $parcel2Go->fetch('me/prepay/balance'); diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index a04814b..a770cd2 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -2,7 +2,9 @@ namespace App\Services; +use App\Models\IntegrationSetting; use Illuminate\Http\Client\Response; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use RuntimeException; @@ -15,6 +17,8 @@ class Parcel2Go private bool $reducedScope = false; + private ?string $profileToken = null; + public function __construct(private IntegrationSettings $settings) {} public function hasScope(string $scope): bool @@ -32,9 +36,70 @@ class Parcel2Go $this->accessToken(); } + public function profileConnected(): bool + { + $connection = IntegrationSetting::find('parcel2go_oauth')?->settings; + + return filled($connection['access_token'] ?? null) + && ($connection['client_id'] ?? null) === ($this->settings->for('parcel2go')['profile_client_id'] ?? null); + } + + public function profileClientConfigured(): bool + { + return filled($this->settings->for('parcel2go')['profile_client_id'] ?? null); + } + + public function authorizationUrl(string $state, string $verifier): string + { + $settings = $this->settings->for('parcel2go'); + if (blank($settings['profile_client_id'] ?? null)) { + throw new RuntimeException('Parcel2Go Authorization Code Client ID is not configured.'); + } + + return $this->authorizationEndpoint().'?'.http_build_query([ + 'client_id' => $settings['profile_client_id'], + 'redirect_uri' => route('parcel2go.callback'), + 'response_type' => 'code', + 'scope' => 'public-api my-profile offline_access', + 'state' => $state, + 'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='), + 'code_challenge_method' => 'S256', + ], '', '&', PHP_QUERY_RFC3986); + } + + public function connectProfile(string $code, string $verifier): void + { + $settings = $this->settings->for('parcel2go'); + $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) + ->post($settings['auth_url'], [ + 'grant_type' => 'authorization_code', + 'client_id' => $settings['profile_client_id'], + 'redirect_uri' => route('parcel2go.callback'), + 'code' => $code, + 'code_verifier' => $verifier, + ])->throw()->json(); + if (empty($tokens['access_token'])) { + throw new RuntimeException('Parcel2Go did not return a profile access token.'); + } + Http::withToken($tokens['access_token'])->acceptJson()->connectTimeout(3)->timeout(15) + ->get($this->url('me/orders/detail'))->throw(); + + IntegrationSetting::updateOrCreate(['provider' => 'parcel2go_oauth'], ['settings' => [ + 'client_id' => $settings['profile_client_id'], + 'access_token' => $tokens['access_token'], + 'refresh_token' => $tokens['refresh_token'] ?? null, + 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(), + ]]); + $this->profileToken = $tokens['access_token']; + } + public function fetch(string $path): array { - return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15) + $token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected() + ? $this->profileAccessToken() + : $this->accessToken(); + + return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15) ->get($this->url($path))->throw()->json() ?? []; } @@ -98,7 +163,8 @@ class Parcel2Go public function labelPdf(string $reference, string $hash): string { - $data = Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20) + $token = $this->profileConnected() ? $this->profileAccessToken() : $this->accessToken(); + $data = Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(20) ->get($this->url('labels/'.$reference), [ 'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels', 'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash, @@ -117,6 +183,52 @@ class Parcel2Go return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/'); } + private function authorizationEndpoint(): string + { + $tokenUrl = $this->settings->for('parcel2go')['auth_url'] ?? ''; + if (! str_ends_with($tokenUrl, '/token')) { + throw new RuntimeException('Parcel2Go token URL must end in /token.'); + } + + return substr($tokenUrl, 0, -strlen('/token')).'/authorize'; + } + + private function profileAccessToken(): string + { + if ($this->profileToken !== null) { + return $this->profileToken; + } + $record = IntegrationSetting::find('parcel2go_oauth'); + $connection = $record?->settings ?? []; + if (empty($connection['access_token']) || ($connection['client_id'] ?? null) !== ($this->settings->for('parcel2go')['profile_client_id'] ?? null)) { + throw new RuntimeException('Parcel2Go account connection is missing or belongs to another API client.'); + } + if (Carbon::parse($connection['expires_at'] ?? null)->isFuture()) { + return $this->profileToken = $connection['access_token']; + } + if (empty($connection['refresh_token'])) { + throw new RuntimeException('Parcel2Go account access expired. Reconnect the account.'); + } + + $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) + ->post($this->settings->for('parcel2go')['auth_url'], [ + 'grant_type' => 'refresh_token', + 'client_id' => $connection['client_id'], + 'refresh_token' => $connection['refresh_token'], + ])->throw()->json(); + if (empty($tokens['access_token'])) { + throw new RuntimeException('Parcel2Go did not refresh account access.'); + } + $record->settings = array_replace($connection, [ + 'access_token' => $tokens['access_token'], + 'refresh_token' => $tokens['refresh_token'] ?? $connection['refresh_token'], + 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(), + ]); + $record->save(); + + return $this->profileToken = $tokens['access_token']; + } + private function accessToken(): string { if ($this->token !== null) { diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 131bb0a..318b1bb 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -56,6 +56,7 @@ + -
To request account history, sign in to Parcel2Go My Account → Settings → API and check this client's permissions. If my-profile is unavailable, email apihelp@parcel2go.com with your account email and Client ID. Ask them to enable my-profile for your live client. Never email the Client secret. After confirmation, select “Shipping and account history” here.
Keep basic public-api access for quotes and bookings. The separate Authorization Code client connects your own account for shipment history and labels.
Parcel2Go account consent
$integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}Create an Authorization Code client in Parcel2Go. Set Application URL to {{ url('/') }} and Redirect URI to this exact address:
{{ $integrations['parcel2go']['callback_url'] }}
+ Save its Client ID above, then sign in and approve my-profile access. The account tokens are encrypted in the database. If consent rejects this scope, contact apihelp@parcel2go.com.