From 61dd05384fb6ac39d4e2f30c3e5a7296b8c41639 Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:50:13 +0100 Subject: [PATCH] Use Parcel2Go public API scope by default --- .env.example | 1 + .gitea/workflows/deploy.yml | 7 +++++ app/Http/Controllers/DashboardController.php | 9 +++++-- .../Controllers/EtsySettingsController.php | 1 + .../IntegrationSettingsController.php | 1 + app/Http/Controllers/ShippingController.php | 26 ++++++++++++++++--- app/Services/Parcel2Go.php | 12 ++++++++- config/services.php | 1 + resources/views/settings/index.blade.php | 8 ++++++ resources/views/shipping.blade.php | 5 ++-- tests/Feature/DashboardAccessTest.php | 4 +++ tests/Feature/IntegrationSettingsTest.php | 15 +++++++++++ tests/Feature/IntegrationsTest.php | 21 +++++++++++++++ 13 files changed, 102 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 59a44b9..e07ab29 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,7 @@ P2G_CLIENT_ID= P2G_CLIENT_SECRET= P2G_API_AUTH_URL= P2G_API_BASE_URL=https://www.parcel2go.com/api +P2G_API_SCOPE=public-api ETSY_KEYSTRING= ETSY_SHARED_SECRET= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 50cf8a2..e40c2c8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -205,7 +205,14 @@ jobs: - name: Install production PHP dependencies working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} run: | + rm -f \ + /tmp/TechOdyssey_Designs_Dashboard.packages.php \ + /tmp/TechOdyssey_Designs_Dashboard.services.php "$COMPOSER_CMD" install --no-dev --no-interaction --prefer-dist --optimize-autoloader + rm -f \ + /tmp/TechOdyssey_Designs_Dashboard.packages.php \ + /tmp/TechOdyssey_Designs_Dashboard.services.php + "$PHP_CMD" artisan package:discover --ansi --no-interaction - name: Clear compiled Laravel files working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 94f0564..86828cc 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -46,8 +46,13 @@ class DashboardController extends Controller $shippingConfig = $settings->for('parcel2go'); if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) { try { - $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); - $shippingStatus = 'Live account'; + if ($parcel2Go->hasScope('my-profile')) { + $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); + $shippingStatus = 'Live account'; + } else { + $parcel2Go->verifyCredentials(); + $shippingStatus = 'Quotes available'; + } } catch (Throwable $exception) { report($exception); $shippingStatus = 'Temporarily unavailable'; diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index ee6a297..173ce8d 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -35,6 +35,7 @@ class EtsySettingsController extends Controller 'configured' => filled($parcel2go['client_id'] ?? null) && filled($parcel2go['client_secret'] ?? null) && filled($parcel2go['auth_url'] ?? null), 'auth_url' => $parcel2go['auth_url'] ?? '', 'base_url' => $parcel2go['base_url'] ?? '', + 'scope' => $parcel2go['scope'] ?? 'public-api', ], 'bambuddy' => [ 'stored' => in_array('bambuddy', $storedIntegrations, true), diff --git a/app/Http/Controllers/IntegrationSettingsController.php b/app/Http/Controllers/IntegrationSettingsController.php index 4d42bea..788b053 100644 --- a/app/Http/Controllers/IntegrationSettingsController.php +++ b/app/Http/Controllers/IntegrationSettingsController.php @@ -20,6 +20,7 @@ class IntegrationSettingsController extends Controller 'client_secret' => ['nullable', 'string', 'max:255'], 'auth_url' => ['nullable', 'url:https', 'max:2048'], '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'], ], default => [ 'url' => ['nullable', 'url:https', 'max:2048'], diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index 85dc2ef..cc22ca1 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -15,11 +15,27 @@ class ShippingController extends Controller { abort_unless($request->user()->role === 'admin', 403); try { - $account = $parcel2Go->fetch('me/detail'); - $balance = $parcel2Go->fetch('me/prepay/balance'); - $orders = $parcel2Go->fetch('me/orders/detail'); + $account = []; + $balance = []; + $orders = []; + $profileAvailable = $parcel2Go->hasScope('my-profile'); + $profileNotice = null; - return view('shipping', compact('account', 'balance', 'orders') + [ + if ($profileAvailable) { + try { + $account = $parcel2Go->fetch('me/detail'); + $balance = $parcel2Go->fetch('me/prepay/balance'); + $orders = $parcel2Go->fetch('me/orders/detail'); + } catch (Throwable $exception) { + report($exception); + $profileNotice = 'Parcel2Go connected, but account history is unavailable. Check that this client has my-profile access.'; + } + } else { + $parcel2Go->verifyCredentials(); + $profileNotice = 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.'; + } + + return view('shipping', compact('account', 'balance', 'orders', 'profileAvailable', 'profileNotice') + [ 'quotes' => $request->session()->get('shipping_quotes', []), 'collectionDates' => $request->session()->get('shipping_collection_dates', []), 'selectedService' => $request->session()->get('shipping_selected_service'), @@ -31,6 +47,8 @@ class ShippingController extends Controller return view('shipping', [ 'account' => [], 'balance' => [], 'orders' => [], + 'profileAvailable' => false, + 'profileNotice' => null, 'quotes' => $request->session()->get('shipping_quotes', []), 'collectionDates' => $request->session()->get('shipping_collection_dates', []), 'selectedService' => $request->session()->get('shipping_selected_service'), diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index b900057..6acc594 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -10,6 +10,16 @@ class Parcel2Go { public function __construct(private IntegrationSettings $settings) {} + public function hasScope(string $scope): bool + { + return in_array($scope, explode(' ', (string) ($this->settings->for('parcel2go')['scope'] ?? 'public-api')), true); + } + + public function verifyCredentials(): void + { + $this->accessToken(); + } + public function fetch(string $path): array { return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15) @@ -104,7 +114,7 @@ class Parcel2Go $token = Http::asForm()->timeout(10)->post($settings['auth_url'], [ 'grant_type' => 'client_credentials', - 'scope' => 'public-api my-profile', + 'scope' => $settings['scope'] ?? 'public-api', 'client_id' => $settings['client_id'], 'client_secret' => $settings['client_secret'], ])->throw()->json('access_token'); diff --git a/config/services.php b/config/services.php index 51e3f07..f49b9e4 100644 --- a/config/services.php +++ b/config/services.php @@ -40,6 +40,7 @@ return [ 'client_secret' => env('P2G_CLIENT_SECRET'), 'auth_url' => env('P2G_API_AUTH_URL'), 'base_url' => env('P2G_API_BASE_URL', 'https://www.parcel2go.com/api'), + 'scope' => env('P2G_API_SCOPE', 'public-api'), ], 'etsy' => [ 'keystring' => env('ETSY_KEYSTRING'), diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index acb7b09..0c7bc13 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -55,6 +55,14 @@ + +

Choose account history only if Parcel2Go granted the my-profile scope to this client. An ungranted scope prevents sign in.

diff --git a/resources/views/shipping.blade.php b/resources/views/shipping.blade.php index 9ec11d5..9e07de1 100644 --- a/resources/views/shipping.blade.php +++ b/resources/views/shipping.blade.php @@ -3,12 +3,13 @@ @section('content')

Dispatch

Parcel2Go

Compare services, arrange a collection and download labels.

@isset($integrationError)@endisset +@if($profileNotice)
{{ $profileNotice }}
@endif @if($bookingPending)@endif @if($bookingResult) @php $paymentUrl = $bookingResult['Links']['payment'] ?? null; $paymentHost = is_string($paymentUrl) ? parse_url($paymentUrl, PHP_URL_HOST) : null; @endphp

Collection order #{{ $bookingResult['OrderId'] ?? '—' }} created

Total: £{{ number_format((float) ($bookingResult['TotalPrice'] ?? 0), 2) }}. Payment and collection confirmation are completed with Parcel2Go.

@if($paymentHost && ($paymentHost === 'parcel2go.com' || str_ends_with($paymentHost, '.parcel2go.com')))Complete payment ↗@endif
@endif -

Account

{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}

{{ $account['Email'] ?? '' }}

Prepay balance

£{{ number_format((float) ($balance['Balance'] ?? 0), 2) }}

+@if($profileAvailable)

Account

{{ trim(($account['Forename'] ?? '').' '.($account['Surname'] ?? '')) ?: 'Unavailable' }}

{{ $account['Email'] ?? '' }}

Prepay balance

{{ isset($balance['Balance']) ? '£'.number_format((float) $balance['Balance'], 2) : 'Unavailable' }}

@endif

1. Compare services

Enter a UK parcel. Dimensions are centimetres and weight is kilograms.

@csrf @@ -18,5 +19,5 @@ @if(count($quotes))

2. Choose a collection service

@foreach($quotes as $quote)@php $service = $quote['Service'] ?? []; @endphp

{{ $service['Name'] ?? 'Courier service' }}

{{ $service['CourierName'] ?? '' }} · {{ $service['CollectionType'] ?? 'Unknown' }}

{{ isset($quote['TotalPrice']) ? '£'.number_format((float) $quote['TotalPrice'], 2) : 'See details' }}

@if(($service['CollectionType'] ?? null) === 'Collection' && ! empty($service['Slug']))@csrf@endif
@endforeach
@endif @if(count($collectionDates) && $selectedService)

3. Arrange collection

Creating the order does not take payment. Parcel2Go will provide a payment link.

@csrf
Sender and collection@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])@endforeach
Recipient and delivery@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])@endforeach
@endif -

Recent shipments and labels

@forelse(($orders['Orders'] ?? []) as $order)@empty@endforelse
OrderCourierServiceStatusRecipientLabel
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }}{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
No shipments available.
+

Recent shipments and labels

@forelse(($orders['Orders'] ?? []) as $order)@empty@endforelse
OrderCourierServiceStatusRecipientLabel
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }}{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
{{ $profileAvailable ? 'No shipments available.' : 'Account history requires my-profile access from Parcel2Go.' }}
@endsection diff --git a/tests/Feature/DashboardAccessTest.php b/tests/Feature/DashboardAccessTest.php index d387fd8..9bcb0cc 100644 --- a/tests/Feature/DashboardAccessTest.php +++ b/tests/Feature/DashboardAccessTest.php @@ -47,6 +47,7 @@ class DashboardAccessTest extends TestCase config()->set('services.parcel2go', [ 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api my-profile', ]); config()->set('services.dryer', ['url' => 'https://dryer.example.test/api/v1', 'api_key' => 'key']); EtsyConnection::create(['shop_id' => 42, 'shop_name' => 'HickLab3D', 'access_token' => '123.token', 'refresh_token' => '123.refresh', 'expires_at' => now()->addHour()]); @@ -191,10 +192,13 @@ class DashboardAccessTest extends TestCase 'client_id' => 'id', 'client_secret' => 'secret', 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api my-profile', ]); $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Lee Hickman')->assertSee('12.50'); Http::assertSentCount(6); + Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/token' + && $request['scope'] === 'public-api my-profile'); } } diff --git a/tests/Feature/IntegrationSettingsTest.php b/tests/Feature/IntegrationSettingsTest.php index 0764c26..9c859c2 100644 --- a/tests/Feature/IntegrationSettingsTest.php +++ b/tests/Feature/IntegrationSettingsTest.php @@ -97,4 +97,19 @@ class IntegrationSettingsTest extends TestCase ])->assertSessionHasErrors('url'); $this->assertDatabaseCount('integration_settings', 0); } + + public function test_parcel2go_scope_is_validated_and_saved(): void + { + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ + 'scope' => 'admin', + ])->assertSessionHasErrors('scope'); + $this->assertDatabaseCount('integration_settings', 0); + + $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ + 'scope' => 'public-api my-profile', + ])->assertRedirect(route('settings.index')); + $this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']); + } } diff --git a/tests/Feature/IntegrationsTest.php b/tests/Feature/IntegrationsTest.php index b3c64a8..20dd054 100644 --- a/tests/Feature/IntegrationsTest.php +++ b/tests/Feature/IntegrationsTest.php @@ -82,6 +82,27 @@ class IntegrationsTest extends TestCase Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/quotes' && $request['Parcels'][0]['Weight'] === 2); } + public function test_shipping_page_works_with_public_api_scope_without_profile_access(): void + { + Http::preventStrayRequests(); + Http::fake(['https://auth.example.test/token' => function ($request) { + return $request['scope'] === 'public-api' + ? Http::response(['access_token' => 'test-token']) + : Http::response(['error' => 'invalid_scope'], 400); + }]); + config()->set('services.parcel2go', [ + 'client_id' => 'id', 'client_secret' => 'secret', + 'auth_url' => 'https://auth.example.test/token', + 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api', + ]); + + $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) + ->assertOk()->assertSee('Parcel2Go connected for quotes and collections') + ->assertDontSee('Parcel2Go is unavailable'); + Http::assertSentCount(1); + } + public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void { Http::preventStrayRequests();