Restore Parcel2Go account reads for client credentials
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m48s

This commit is contained in:
Hickmeister
2026-10-11 17:59:59 +01:00
parent 141a1f9379
commit c892b615b6
4 changed files with 32 additions and 17 deletions
+10 -8
View File
@@ -45,16 +45,18 @@ class ShippingController extends Controller
$profileNotice = $parcel2Go->profileConnected() $profileNotice = $parcel2Go->profileConnected()
? 'Parcel2Go account access needs attention. Reconnect the account in Settings.' ? 'Parcel2Go account access needs attention. Reconnect the account in Settings.'
: ($exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true) : ($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 denied shipment history for the saved Client Credentials client. Check that its Client ID and secret match the client used by your previous dashboard.'
: 'Parcel2Go shipment history is temporarily unavailable.'); : 'Parcel2Go shipment history is temporarily unavailable.');
} }
if ($parcel2Go->profileConnected() || $parcel2Go->hasScope('my-profile')) { try {
try { $account = $parcel2Go->fetch('me/detail');
$account = $parcel2Go->fetch('me/detail'); } catch (Throwable $exception) {
$balance = $parcel2Go->fetch('me/prepay/balance'); report($exception);
} catch (Throwable $exception) { }
report($exception); try {
} $balance = $parcel2Go->fetch('me/prepay/balance');
} catch (Throwable $exception) {
report($exception);
} }
} catch (Throwable $exception) { } catch (Throwable $exception) {
report($exception); report($exception);
+3 -3
View File
@@ -59,16 +59,16 @@
<label class="grid gap-2 text-sm text-slate-300">Authorization Code Client ID<input name="profile_client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['profile_client_configured'] ? 'Saved — leave blank to keep it' : 'Enter the new Authorization Code Client ID' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label> <label class="grid gap-2 text-sm text-slate-300">Authorization Code Client ID<input name="profile_client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['profile_client_configured'] ? 'Saved — leave blank to keep it' : 'Enter the new Authorization Code Client ID' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
<label class="grid gap-2 text-sm text-slate-300">API access <label class="grid gap-2 text-sm text-slate-300">API access
<select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"> <select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
@foreach(['public-api' => 'Shipping quotes and collections', 'public-api my-profile' => 'Shipping and account history', 'public-api payment' => 'Shipping and PrePay payments', 'public-api payment my-profile' => 'Shipping, account history and PrePay'] as $scope => $label) @foreach(['public-api' => 'Basic API access (also tries account history)', 'public-api my-profile' => 'Basic API and my-profile', 'public-api payment' => 'Basic API and payments', 'public-api payment my-profile' => 'Basic API, payments and my-profile'] as $scope => $label)
<option value="{{ $scope }}" @selected(old('scope', $integrations['parcel2go']['scope']) === $scope)>{{ $label }}</option> <option value="{{ $scope }}" @selected(old('scope', $integrations['parcel2go']['scope']) === $scope)>{{ $label }}</option>
@endforeach @endforeach
</select> </select>
</label> </label>
<p class="text-xs text-slate-500">Keep basic <code>public-api</code> access for quotes and bookings. The separate Authorization Code client connects your own account for shipment history and labels.</p> <p class="text-xs text-slate-500">The dashboard tries shipments, account details and balance with your Client Credentials connection, as the previous dashboard did. If Parcel2Go denies history, check that these credentials belong to the same client that worked before.</p>
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button> <button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button>
</form> </form>
<div class="mt-5 grid gap-3 rounded-xl border border-slate-700 bg-slate-950 p-4 text-sm"> <div class="mt-5 grid gap-3 rounded-xl border border-slate-700 bg-slate-950 p-4 text-sm">
<div class="flex flex-wrap items-center justify-between gap-2"><p class="font-semibold">Parcel2Go account consent</p><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}</span></div> <div class="flex flex-wrap items-center justify-between gap-2"><p class="font-semibold">Optional Parcel2Go account consent</p><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}</span></div>
<p class="text-slate-400">Create an <strong>Authorization Code</strong> client in Parcel2Go. Set Application URL to <code>{{ url('/') }}</code> and Redirect URI to this exact address:</p> <p class="text-slate-400">Create an <strong>Authorization Code</strong> client in Parcel2Go. Set Application URL to <code>{{ url('/') }}</code> and Redirect URI to this exact address:</p>
<code class="overflow-x-auto rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-slate-200">{{ $integrations['parcel2go']['callback_url'] }}</code> <code class="overflow-x-auto rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-slate-200">{{ $integrations['parcel2go']['callback_url'] }}</code>
<p class="text-slate-400">Save its Client ID above, then sign in and approve <code>my-profile</code> access. The account tokens are encrypted in the database. If Parcel2Go does not issue a refresh token, reconnect when access expires. If consent rejects this scope, contact <a class="text-brand hover:underline" href="mailto:apihelp@parcel2go.com">apihelp@parcel2go.com</a>.</p> <p class="text-slate-400">Save its Client ID above, then sign in and approve <code>my-profile</code> access. The account tokens are encrypted in the database. If Parcel2Go does not issue a refresh token, reconnect when access expires. If consent rejects this scope, contact <a class="text-brand hover:underline" href="mailto:apihelp@parcel2go.com">apihelp@parcel2go.com</a>.</p>
+1 -1
View File
@@ -36,6 +36,6 @@
<table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Progress</th><th class="p-4">Estimated delivery</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800"> <table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Progress</th><th class="p-4">Estimated delivery</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">
@forelse(($orders['Orders'] ?? []) as $order) @forelse(($orders['Orders'] ?? []) as $order)
<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></td><td class="p-4">{{ $order['EstimatedDeliveryDate'] ?? '—' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr> <tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></td><td class="p-4">{{ $order['EstimatedDeliveryDate'] ?? '—' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>
@empty<tr><td colspan="7" class="p-6 text-slate-400">{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable until Parcel2Go grants access to this client.' }}</td></tr>@endforelse @empty<tr><td colspan="7" class="p-6 text-slate-400">{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable. Check the Client Credentials in Settings.' }}</td></tr>@endforelse
</tbody></table> </tbody></table>
</section>@endsection </section>@endsection
+18 -5
View File
@@ -91,7 +91,11 @@ class IntegrationsTest extends TestCase
return $request['scope'] === 'public-api' return $request['scope'] === 'public-api'
? Http::response(['access_token' => 'test-token']) ? Http::response(['access_token' => 'test-token'])
: Http::response(['error' => 'invalid_scope'], 400); : Http::response(['error' => 'invalid_scope'], 400);
}, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]); },
'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403),
'https://shipping.example.test/api/me/detail' => Http::response([], 403),
'https://shipping.example.test/api/me/prepay/balance' => Http::response([], 403),
]);
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'auth_url' => 'https://auth.example.test/token',
@@ -102,7 +106,7 @@ class IntegrationsTest extends TestCase
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Pending Etsy orders')->assertSee('Parcel2Go denied shipment history') ->assertOk()->assertSee('Pending Etsy orders')->assertSee('Parcel2Go denied shipment history')
->assertDontSee('Parcel2Go is unavailable'); ->assertDontSee('Parcel2Go is unavailable');
Http::assertSentCount(2); Http::assertSentCount(4);
} }
public function test_shipping_shows_existing_shipments_when_basic_access_can_read_history(): void public function test_shipping_shows_existing_shipments_when_basic_access_can_read_history(): void
@@ -115,6 +119,8 @@ class IntegrationsTest extends TestCase
'Tracking' => ['Delivered' => true], 'DeliveryAddress' => ['ContactName' => 'Buyer'], 'Tracking' => ['Delivered' => true], 'DeliveryAddress' => ['ContactName' => 'Buyer'],
'EstimatedDeliveryDate' => '2026-10-15', 'Hash' => 'hash', 'EstimatedDeliveryDate' => '2026-10-15', 'Hash' => 'hash',
]]]), ]]]),
'https://shipping.example.test/api/me/detail' => Http::response(['Forename' => 'Lee', 'Surname' => 'Hickman']),
'https://shipping.example.test/api/me/prepay/balance' => Http::response(['Balance' => 12.5]),
]); ]);
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
@@ -125,8 +131,11 @@ class IntegrationsTest extends TestCase
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Existing shipments')->assertSee('Delivered')->assertSee('Buyer') ->assertOk()->assertSee('Existing shipments')->assertSee('Delivered')->assertSee('Buyer')
->assertSee('Lee Hickman')->assertSee('12.50')
->assertDontSee('Parcel2Go denied shipment history'); ->assertDontSee('Parcel2Go denied shipment history');
Http::assertSentCount(2); Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/detail' && $request->hasHeader('Authorization', 'Bearer test-token'));
Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/prepay/balance' && $request->hasHeader('Authorization', 'Bearer test-token'));
Http::assertSentCount(4);
} }
public function test_shipping_rejects_a_quote_for_an_order_that_is_not_pending(): void public function test_shipping_rejects_a_quote_for_an_order_that_is_not_pending(): void
@@ -149,7 +158,11 @@ class IntegrationsTest extends TestCase
return $request['scope'] === 'public-api' return $request['scope'] === 'public-api'
? Http::response(['access_token' => 'test-token']) ? Http::response(['access_token' => 'test-token'])
: Http::response(['error' => 'invalid_scope'], 400); : Http::response(['error' => 'invalid_scope'], 400);
}, 'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403)]); },
'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403),
'https://shipping.example.test/api/me/detail' => Http::response([], 403),
'https://shipping.example.test/api/me/prepay/balance' => Http::response([], 403),
]);
config()->set('services.parcel2go', [ config()->set('services.parcel2go', [
'client_id' => 'id', 'client_secret' => 'secret', 'client_id' => 'id', 'client_secret' => 'secret',
'auth_url' => 'https://auth.example.test/token', 'auth_url' => 'https://auth.example.test/token',
@@ -160,7 +173,7 @@ class IntegrationsTest extends TestCase
$this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping'))
->assertOk()->assertSee('Parcel2Go denied shipment history') ->assertOk()->assertSee('Parcel2Go denied shipment history')
->assertDontSee('Parcel2Go is unavailable'); ->assertDontSee('Parcel2Go is unavailable');
Http::assertSentCount(3); Http::assertSentCount(5);
} }
public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void