From ff9960fe064d7317b8a2c9761361bbfb3b35286a Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:15:02 +0100 Subject: [PATCH] Move integration credentials into encrypted dashboard settings --- README.md | 7 +- app/Http/Controllers/BambuddyController.php | 2 +- app/Http/Controllers/DashboardController.php | 8 +- app/Http/Controllers/DryerController.php | 4 +- .../Controllers/EtsySettingsController.php | 49 ++++++++- .../IntegrationSettingsController.php | 41 +++++++ app/Models/EtsyAppCredential.php | 4 +- app/Models/IntegrationSetting.php | 28 +++++ app/Services/Bambuddy.php | 20 ++-- app/Services/DryerClient.php | 31 +++++- app/Services/Etsy.php | 10 +- app/Services/IntegrationSettings.php | 24 +++++ app/Services/Parcel2Go.php | 6 +- .../factories/IntegrationSettingFactory.php | 28 +++++ ...0931_create_integration_settings_table.php | 28 +++++ ...d_shop_details_to_etsy_app_credentials.php | 29 +++++ database/seeders/IntegrationSettingSeeder.php | 19 ++++ resources/views/dryer.blade.php | 2 +- resources/views/printers/index.blade.php | 2 +- resources/views/settings/index.blade.php | 66 ++++++++++-- routes/web.php | 2 + tests/Feature/EtsySettingsTest.php | 24 +++++ tests/Feature/IntegrationSettingsTest.php | 100 ++++++++++++++++++ 23 files changed, 493 insertions(+), 41 deletions(-) create mode 100644 app/Http/Controllers/IntegrationSettingsController.php create mode 100644 app/Models/IntegrationSetting.php create mode 100644 app/Services/IntegrationSettings.php create mode 100644 database/factories/IntegrationSettingFactory.php create mode 100644 database/migrations/2026_10_11_150931_create_integration_settings_table.php create mode 100644 database/migrations/2026_10_11_151102_add_shop_details_to_etsy_app_credentials.php create mode 100644 database/seeders/IntegrationSettingSeeder.php create mode 100644 tests/Feature/IntegrationSettingsTest.php diff --git a/README.md b/README.md index 63db565..ad004c5 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,9 @@ The migrations use the legacy table and column names. If you point the app at th ## Integrations -- Parcel2Go: set `P2G_CLIENT_ID`, `P2G_CLIENT_SECRET`, and `P2G_API_AUTH_URL`. -- Etsy: add the app keystring and shared secret on the Settings page, then connect the shop with OAuth. -- Printers: set `BAMBUDDY_URL` and `BAMBUDDY_API_KEY` for live print farm status. -- Dryer: set `DRYER_URL` and `DRYER_API_KEY`. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests. +- Enter Etsy, Parcel2Go, BamBuddy, and dryer connection details on the admin Settings page. Saved values are encrypted in the database and take precedence over matching `.env` values. +- Existing `P2G_*`, `BAMBUDDY_*`, `DRYER_*`, and `ETSY_*` environment values remain available as fallbacks. Save each integration on the Settings page to import those values into the database. +- Connect the Etsy shop with OAuth after saving its app credentials. The browser talks to authenticated Laravel routes for dryer status and controls. ## Verification diff --git a/app/Http/Controllers/BambuddyController.php b/app/Http/Controllers/BambuddyController.php index c5608a7..deee15f 100644 --- a/app/Http/Controllers/BambuddyController.php +++ b/app/Http/Controllers/BambuddyController.php @@ -20,6 +20,6 @@ class BambuddyController extends Controller $integrationError = 'BamBuddy is unavailable. Check its API key and connection.'; } - return view('printers.index', compact('printers', 'integrationError')); + return view('printers.index', compact('printers', 'integrationError') + ['bambuddyUrl' => $bambuddy->url()]); } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 487d49a..94f0564 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -6,6 +6,7 @@ use App\Models\EtsyConnection; use App\Services\Bambuddy; use App\Services\DryerClient; use App\Services\Etsy; +use App\Services\IntegrationSettings; use App\Services\Parcel2Go; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -14,7 +15,7 @@ use Throwable; class DashboardController extends Controller { - public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer): View + public function __invoke(Request $request, Etsy $etsy, Bambuddy $bambuddy, Parcel2Go $parcel2Go, DryerClient $dryer, IntegrationSettings $settings): View { $isAdmin = $request->user()->role === 'admin'; $etsyStatus = $isAdmin ? 'Not connected' : null; @@ -42,7 +43,8 @@ class DashboardController extends Controller } } - if ($isAdmin && filled(config('services.parcel2go.client_id')) && filled(config('services.parcel2go.client_secret'))) { + $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'; @@ -85,7 +87,7 @@ class DashboardController extends Controller }); $dryerState = 'Not configured'; - if (filled(config('services.dryer.api_key'))) { + if ($dryer->configured()) { try { $dryerState = ucfirst((string) ($dryer->status()['state'] ?? 'Unknown')); } catch (Throwable $exception) { diff --git a/app/Http/Controllers/DryerController.php b/app/Http/Controllers/DryerController.php index 23ad643..64ebde2 100644 --- a/app/Http/Controllers/DryerController.php +++ b/app/Http/Controllers/DryerController.php @@ -10,9 +10,9 @@ use Throwable; class DryerController extends Controller { - public function index(): View + public function index(DryerClient $dryer): View { - return view('dryer'); + return view('dryer', ['dryerUrl' => $dryer->dashboardUrl()]); } public function status(DryerClient $dryer): JsonResponse diff --git a/app/Http/Controllers/EtsySettingsController.php b/app/Http/Controllers/EtsySettingsController.php index ec16bd0..ee6a297 100644 --- a/app/Http/Controllers/EtsySettingsController.php +++ b/app/Http/Controllers/EtsySettingsController.php @@ -4,21 +4,49 @@ namespace App\Http\Controllers; use App\Models\EtsyAppCredential; use App\Models\EtsyConnection; +use App\Models\IntegrationSetting; use App\Services\Etsy; +use App\Services\IntegrationSettings; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; class EtsySettingsController extends Controller { - public function index(Request $request, Etsy $etsy): View + public function index(Request $request, Etsy $etsy, IntegrationSettings $settings): View { abort_unless($request->user()->role === 'admin', 403); + $credentials = EtsyAppCredential::find(1); + $storedIntegrations = IntegrationSetting::query()->whereIn('provider', ['parcel2go', 'bambuddy', 'dryer']) + ->pluck('provider')->all(); + $parcel2go = $settings->for('parcel2go'); + $bambuddy = $settings->for('bambuddy'); + $dryer = $settings->for('dryer'); + return view('settings.index', [ - 'storedCredentials' => EtsyAppCredential::find(1) !== null, + 'storedCredentials' => $credentials !== null, 'configured' => $etsy->configured(), 'redirectUri' => $etsy->redirectUri(), + 'shopName' => $credentials?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'), + 'integrations' => [ + 'parcel2go' => [ + 'stored' => in_array('parcel2go', $storedIntegrations, true), + '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'] ?? '', + ], + 'bambuddy' => [ + 'stored' => in_array('bambuddy', $storedIntegrations, true), + 'configured' => filled($bambuddy['url'] ?? null) && filled($bambuddy['api_key'] ?? null), + 'url' => $bambuddy['url'] ?? '', + ], + 'dryer' => [ + 'stored' => in_array('dryer', $storedIntegrations, true), + 'configured' => filled($dryer['url'] ?? null) && filled($dryer['api_key'] ?? null), + 'url' => $dryer['url'] ?? '', + ], + ], ]); } @@ -27,15 +55,26 @@ class EtsySettingsController extends Controller abort_unless($request->user()->role === 'admin', 403); $credentials = EtsyAppCredential::find(1); - $requireCredentials = $credentials === null ? 'required' : 'nullable'; + $requireKeystring = $credentials === null && blank(config('services.etsy.keystring')) ? 'required' : 'nullable'; + $requireSecret = $credentials === null && blank(config('services.etsy.shared_secret')) ? 'required' : 'nullable'; $validated = $request->validate([ - 'keystring' => [$requireCredentials, 'string', 'max:255'], - 'shared_secret' => [$requireCredentials, 'string', 'max:255'], + 'keystring' => [$requireKeystring, 'string', 'max:255'], + 'shared_secret' => [$requireSecret, 'string', 'max:255'], + 'shop_name' => ['nullable', 'string', 'max:255'], + 'redirect_uri' => ['nullable', 'url:https', 'max:2048'], ]); $previousKeystring = $credentials?->keystring ?: config('services.etsy.keystring'); $credentials ??= new EtsyAppCredential; $credentials->id = 1; + if (! $credentials->exists) { + $credentials->fill([ + 'keystring' => config('services.etsy.keystring'), + 'shared_secret' => config('services.etsy.shared_secret'), + 'shop_name' => config('services.etsy.shop_name', 'HickLab3D'), + 'redirect_uri' => config('services.etsy.redirect_uri'), + ]); + } $credentials->fill(array_filter($validated, fn (?string $value): bool => filled($value))); $credentials->save(); diff --git a/app/Http/Controllers/IntegrationSettingsController.php b/app/Http/Controllers/IntegrationSettingsController.php new file mode 100644 index 0000000..4d42bea --- /dev/null +++ b/app/Http/Controllers/IntegrationSettingsController.php @@ -0,0 +1,41 @@ +user()->role === 'admin', 403); + abort_unless(in_array($provider, ['parcel2go', 'bambuddy', 'dryer'], true), 404); + + $rules = match ($provider) { + 'parcel2go' => [ + 'client_id' => ['nullable', 'string', 'max:255'], + 'client_secret' => ['nullable', 'string', 'max:255'], + 'auth_url' => ['nullable', 'url:https', 'max:2048'], + 'base_url' => ['nullable', 'url:https', 'max:2048'], + ], + default => [ + 'url' => ['nullable', 'url:https', 'max:2048'], + 'api_key' => ['nullable', 'string', 'max:512'], + ], + }; + $submitted = array_filter($request->validate($rules), fn (?string $value): bool => filled($value)); + $effective = $settings->for($provider); + if ($submitted === [] && ! collect($effective)->contains(fn ($value): bool => filled($value))) { + return redirect()->route('settings.index')->with('status', 'No integration settings changed.'); + } + + $record = IntegrationSetting::find($provider) ?? new IntegrationSetting(['provider' => $provider]); + $record->settings = array_replace($effective, $submitted); + $record->save(); + + return redirect()->route('settings.index')->with('status', ucfirst($provider).' settings saved.'); + } +} diff --git a/app/Models/EtsyAppCredential.php b/app/Models/EtsyAppCredential.php index 8c6ff7e..e881f47 100644 --- a/app/Models/EtsyAppCredential.php +++ b/app/Models/EtsyAppCredential.php @@ -11,7 +11,7 @@ class EtsyAppCredential extends Model /** @use HasFactory */ use HasFactory; - protected $fillable = ['keystring', 'shared_secret']; + protected $fillable = ['keystring', 'shared_secret', 'shop_name', 'redirect_uri']; protected $hidden = ['keystring', 'shared_secret']; @@ -20,6 +20,8 @@ class EtsyAppCredential extends Model return [ 'keystring' => 'encrypted', 'shared_secret' => 'encrypted', + 'shop_name' => 'encrypted', + 'redirect_uri' => 'encrypted', ]; } } diff --git a/app/Models/IntegrationSetting.php b/app/Models/IntegrationSetting.php new file mode 100644 index 0000000..111c7fa --- /dev/null +++ b/app/Models/IntegrationSetting.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + public $incrementing = false; + + protected $primaryKey = 'provider'; + + protected $keyType = 'string'; + + protected $fillable = ['provider', 'settings']; + + protected $hidden = ['settings']; + + protected function casts(): array + { + return ['settings' => 'encrypted:array']; + } +} diff --git a/app/Services/Bambuddy.php b/app/Services/Bambuddy.php index 4a3ce26..988e299 100644 --- a/app/Services/Bambuddy.php +++ b/app/Services/Bambuddy.php @@ -8,9 +8,16 @@ use Throwable; class Bambuddy { + public function __construct(private IntegrationSettings $settings) {} + + public function url(): string + { + return (string) ($this->settings->for('bambuddy')['url'] ?? ''); + } + public function configured(): bool { - return filled(config('services.bambuddy.url')) && filled(config('services.bambuddy.api_key')); + return filled($this->url()) && filled($this->settings->for('bambuddy')['api_key'] ?? null); } public function printers(): array @@ -19,17 +26,18 @@ class Bambuddy throw new RuntimeException('BamBuddy API key is not configured.'); } - $url = rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers'; + $url = rtrim($this->url(), '/').'/api/v1/printers'; + $apiKey = $this->settings->for('bambuddy')['api_key']; - $printers = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')]) + $printers = Http::withHeaders(['X-API-Key' => $apiKey]) ->acceptJson()->connectTimeout(3)->timeout(8)->get($url)->throw()->json() ?? []; return collect($printers)->filter(fn ($printer) => is_array($printer) && isset($printer['id'])) - ->take(20)->map(function (array $printer): array { + ->take(20)->map(function (array $printer) use ($apiKey): array { try { - $status = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')]) + $status = Http::withHeaders(['X-API-Key' => $apiKey]) ->acceptJson()->connectTimeout(3)->timeout(5) - ->get(rtrim(config('services.bambuddy.url'), '/').'/api/v1/printers/'.(int) $printer['id'].'/status') + ->get(rtrim($this->url(), '/').'/api/v1/printers/'.(int) $printer['id'].'/status') ->throw()->json() ?? []; return $printer + ['live' => $status]; diff --git a/app/Services/DryerClient.php b/app/Services/DryerClient.php index 52efbcc..2d898c4 100644 --- a/app/Services/DryerClient.php +++ b/app/Services/DryerClient.php @@ -7,10 +7,31 @@ use RuntimeException; class DryerClient { + public function __construct(private IntegrationSettings $settings) {} + + public function configured(): bool + { + $settings = $this->settings->for('dryer'); + + return filled($settings['url'] ?? null) && filled($settings['api_key'] ?? null); + } + + public function dashboardUrl(): string + { + $url = rtrim((string) ($this->settings->for('dryer')['url'] ?? ''), '/'); + + return preg_replace('#/api/v1$#', '', $url) ?? $url; + } + + private function apiKey(): string + { + return (string) ($this->settings->for('dryer')['api_key'] ?? ''); + } + private function url(string $path): string { - $base = config('services.dryer.url'); - if (! $base || ! config('services.dryer.api_key')) { + $base = $this->settings->for('dryer')['url'] ?? null; + if (! $this->configured()) { throw new RuntimeException('Dryer URL and API key must be configured.'); } @@ -24,13 +45,13 @@ class DryerClient public function status(): array { - return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson() + return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() ->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? []; } public function history(): array { - return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson() + return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() ->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? []; } @@ -53,7 +74,7 @@ class DryerClient private function command(string $path, array $payload = []): array { - return Http::withHeaders(['X-API-Key' => config('services.dryer.api_key')])->acceptJson() + return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() ->connectTimeout(3)->timeout(7)->post($this->url($path), [ 'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload, ])->throw()->json() ?? []; diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index 5411bd9..630aa76 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -11,7 +11,7 @@ class Etsy { private const API_URL = 'https://api.etsy.com/v3'; - /** @var array{keystring: ?string, shared_secret: ?string}|null */ + /** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */ private ?array $credentials = null; public function configured(): bool @@ -51,7 +51,7 @@ class Etsy if (empty($shop['shop_id'])) { throw new RuntimeException('This Etsy account does not own a shop.'); } - if (strcasecmp($shop['shop_name'] ?? '', config('services.etsy.shop_name', 'HickLab3D')) !== 0) { + if (strcasecmp($shop['shop_name'] ?? '', $this->credentials()['shop_name']) !== 0) { throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.'); } @@ -108,10 +108,10 @@ class Etsy public function redirectUri(): string { - return config('services.etsy.redirect_uri') ?: route('etsy.callback'); + return $this->credentials()['redirect_uri'] ?: route('etsy.callback'); } - /** @return array{keystring: ?string, shared_secret: ?string} */ + /** @return array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string} */ private function credentials(): array { if ($this->credentials !== null) { @@ -123,6 +123,8 @@ class Etsy return $this->credentials = [ 'keystring' => $stored?->keystring ?: config('services.etsy.keystring'), 'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'), + 'shop_name' => $stored?->shop_name ?: config('services.etsy.shop_name', 'HickLab3D'), + 'redirect_uri' => $stored?->redirect_uri ?: config('services.etsy.redirect_uri'), ]; } } diff --git a/app/Services/IntegrationSettings.php b/app/Services/IntegrationSettings.php new file mode 100644 index 0000000..b0d93dd --- /dev/null +++ b/app/Services/IntegrationSettings.php @@ -0,0 +1,24 @@ +> */ + private array $resolved = []; + + /** @return array */ + public function for(string $provider): array + { + if (! isset($this->resolved[$provider])) { + $this->resolved[$provider] = array_replace( + config('services.'.$provider, []), + IntegrationSetting::find($provider)?->settings ?? [], + ); + } + + return $this->resolved[$provider]; + } +} diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index 25b3f25..b900057 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -8,6 +8,8 @@ use RuntimeException; class Parcel2Go { + public function __construct(private IntegrationSettings $settings) {} + public function fetch(string $path): array { return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15) @@ -90,12 +92,12 @@ class Parcel2Go private function url(string $path): string { - return rtrim(config('services.parcel2go.base_url'), '/').'/'.ltrim($path, '/'); + return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/'); } private function accessToken(): string { - $settings = config('services.parcel2go'); + $settings = $this->settings->for('parcel2go'); if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) { throw new RuntimeException('Parcel2Go credentials are not configured.'); } diff --git a/database/factories/IntegrationSettingFactory.php b/database/factories/IntegrationSettingFactory.php new file mode 100644 index 0000000..39d1eb3 --- /dev/null +++ b/database/factories/IntegrationSettingFactory.php @@ -0,0 +1,28 @@ + + */ +class IntegrationSettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'provider' => 'bambuddy', + 'settings' => [ + 'url' => 'https://bambuddy.example.test', + 'api_key' => 'example-api-key', + ], + ]; + } +} diff --git a/database/migrations/2026_10_11_150931_create_integration_settings_table.php b/database/migrations/2026_10_11_150931_create_integration_settings_table.php new file mode 100644 index 0000000..6466b04 --- /dev/null +++ b/database/migrations/2026_10_11_150931_create_integration_settings_table.php @@ -0,0 +1,28 @@ +string('provider')->primary(); + $table->longText('settings'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('integration_settings'); + } +}; diff --git a/database/migrations/2026_10_11_151102_add_shop_details_to_etsy_app_credentials.php b/database/migrations/2026_10_11_151102_add_shop_details_to_etsy_app_credentials.php new file mode 100644 index 0000000..e3b93cd --- /dev/null +++ b/database/migrations/2026_10_11_151102_add_shop_details_to_etsy_app_credentials.php @@ -0,0 +1,29 @@ +text('shop_name')->nullable(); + $table->text('redirect_uri')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('etsy_app_credentials', function (Blueprint $table) { + $table->dropColumn(['shop_name', 'redirect_uri']); + }); + } +}; diff --git a/database/seeders/IntegrationSettingSeeder.php b/database/seeders/IntegrationSettingSeeder.php new file mode 100644 index 0000000..39ce468 --- /dev/null +++ b/database/seeders/IntegrationSettingSeeder.php @@ -0,0 +1,19 @@ +isLocal()) { + IntegrationSetting::factory()->create(); + } + } +} diff --git a/resources/views/dryer.blade.php b/resources/views/dryer.blade.php index 4894b51..dce3af4 100644 --- a/resources/views/dryer.blade.php +++ b/resources/views/dryer.blade.php @@ -1,7 +1,7 @@ @extends('layouts.app') @section('title', 'Filament dryer') @section('content') -

Workshop tools

Filament dryer

Live programme, sensor readings and relay state.

Open dryer ↗
+

Workshop tools

Filament dryer

Live programme, sensor readings and relay state.

@if($dryerUrl)Open dryer ↗@endif

Connecting to dryer…

@foreach([['dryer-state','State'],['temperature','Temperature'],['humidity','Humidity'],['remaining','Remaining time']] as [$id,$label])

{{ $label }}

—

@endforeach
diff --git a/resources/views/printers/index.blade.php b/resources/views/printers/index.blade.php index 8a1228d..e581836 100644 --- a/resources/views/printers/index.blade.php +++ b/resources/views/printers/index.blade.php @@ -1,7 +1,7 @@ @extends('layouts.app') @section('title', 'Print farm') @section('content') -

Production

BamBuddy printers

Live state, progress and temperatures from the workshop.

Open BamBuddy ↗
+

Production

BamBuddy printers

Live state, progress and temperatures from the workshop.

Open BamBuddy ↗
@if($integrationError)@endif
@forelse($printers as $printer) @php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index a2b6c6b..acb7b09 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -3,14 +3,14 @@ @section('content')

Integrations

-

Etsy app credentials

-

Save the credentials from your Etsy developer app, then connect your shop with OAuth.

+

Integration settings

+

Manage the connections used by your shop and workshop. Saved values are encrypted in the database.

-

API access

+

Etsy shop

{{ $storedCredentials ? 'Credentials saved in the database.' : ($configured ? 'Using server credentials. Save them here to manage them in the dashboard.' : 'No credentials saved yet.') }}

{{ $configured ? 'Configured' : 'Setup needed' }} @@ -21,19 +21,73 @@ @method('PUT') + +
-

Saved values are encrypted and are never shown again. Changing the app key disconnects the current Etsy shop.

+

Blank credential fields keep the current values. Saving imports any server credentials you have not entered here. Changing the app key disconnects the current Etsy shop. The callback must match the URL registered with Etsy.

+
+
+
+

Parcel2Go

Quotes, labels, shipments and collections.

+ $integrations['parcel2go']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['configured']])>{{ $integrations['parcel2go']['configured'] ? 'Configured' : 'Setup needed' }} +
+

{{ $integrations['parcel2go']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}

+
+ @csrf @method('PUT') + + + + + +
+
+ +
+
+

BamBuddy

Live print farm data.

+ $integrations['bambuddy']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['bambuddy']['configured']])>{{ $integrations['bambuddy']['configured'] ? 'Configured' : 'Setup needed' }} +
+

{{ $integrations['bambuddy']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}

+
+ @csrf @method('PUT') + + + +
+
+ +
+
+

Filament dryer

Status, cycles and fault controls.

+ $integrations['dryer']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['dryer']['configured']])>{{ $integrations['dryer']['configured'] ? 'Configured' : 'Setup needed' }} +
+

{{ $integrations['dryer']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}

+
+ @csrf @method('PUT') + + + +
+
+
+

OAuth callback

Register this exact HTTPS URL in your Etsy developer app.

diff --git a/routes/web.php b/routes/web.php index 68f1340..ebc2cfb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\DashboardController; use App\Http\Controllers\DryerController; use App\Http\Controllers\EtsyController; use App\Http\Controllers\EtsySettingsController; +use App\Http\Controllers\IntegrationSettingsController; use App\Http\Controllers\PrinterController; use App\Http\Controllers\ShippingController; use App\Http\Controllers\UserController; @@ -33,6 +34,7 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback'); Route::get('/settings', [EtsySettingsController::class, 'index'])->name('settings.index'); Route::put('/settings/etsy', [EtsySettingsController::class, 'update'])->name('settings.etsy.update'); + Route::put('/settings/integrations/{provider}', [IntegrationSettingsController::class, 'update'])->name('settings.integrations.update'); Route::get('/dryer', [DryerController::class, 'index'])->name('dryer'); Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status'); Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control'); diff --git a/tests/Feature/EtsySettingsTest.php b/tests/Feature/EtsySettingsTest.php index 4c7f2fc..941b054 100644 --- a/tests/Feature/EtsySettingsTest.php +++ b/tests/Feature/EtsySettingsTest.php @@ -109,4 +109,28 @@ class EtsySettingsTest extends TestCase $this->assertSame('first-secret', EtsyAppCredential::firstOrFail()->shared_secret); $this->assertDatabaseCount('etsy_connections', 0); } + + public function test_shop_name_and_oauth_callback_are_saved_with_server_credentials(): void + { + config()->set('services.etsy.keystring', 'server-key'); + config()->set('services.etsy.shared_secret', 'server-secret'); + config()->set('services.etsy.redirect_uri', 'https://old.example.test/etsy/callback'); + $admin = User::factory()->create(['role' => 'admin']); + + $this->actingAs($admin)->put(route('settings.etsy.update'), [ + 'shop_name' => 'HickLab3D', + 'redirect_uri' => 'https://new.example.test/etsy/callback', + ])->assertRedirect(route('settings.index')); + + $credentials = EtsyAppCredential::firstOrFail(); + $this->assertSame('server-key', $credentials->keystring); + $this->assertSame('server-secret', $credentials->shared_secret); + $this->assertSame('HickLab3D', $credentials->shop_name); + $this->assertSame('https://new.example.test/etsy/callback', $credentials->redirect_uri); + $this->assertNotSame($credentials->redirect_uri, $credentials->getRawOriginal('redirect_uri')); + $this->actingAs($admin)->post(route('etsy.connect')) + ->assertRedirectContains('https%3A%2F%2Fnew.example.test%2Fetsy%2Fcallback'); + $this->actingAs($admin)->get(route('settings.index')) + ->assertOk()->assertDontSee('server-secret'); + } } diff --git a/tests/Feature/IntegrationSettingsTest.php b/tests/Feature/IntegrationSettingsTest.php new file mode 100644 index 0000000..0764c26 --- /dev/null +++ b/tests/Feature/IntegrationSettingsTest.php @@ -0,0 +1,100 @@ +put($route, ['api_key' => 'secret'])->assertRedirect(route('login')); + $this->actingAs(User::factory()->create())->put($route, ['api_key' => 'secret'])->assertForbidden(); + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->put(route('settings.integrations.update', 'unknown'), ['api_key' => 'secret'])->assertNotFound(); + $this->assertDatabaseCount('integration_settings', 0); + } + + public function test_saved_values_are_encrypted_masked_and_override_server_values(): void + { + config()->set('services.bambuddy', ['url' => 'https://old.example.test', 'api_key' => 'old-key']); + $admin = User::factory()->create(['role' => 'admin']); + $this->actingAs($admin)->put(route('settings.integrations.update', 'bambuddy'), [ + 'url' => 'https://new.example.test', 'api_key' => 'saved-key', + ])->assertRedirect(route('settings.index')); + + $record = IntegrationSetting::findOrFail('bambuddy'); + $this->assertSame('saved-key', $record->settings['api_key']); + $this->assertStringNotContainsString('saved-key', $record->getRawOriginal('settings')); + $this->assertArrayNotHasKey('settings', $record->toArray()); + $this->actingAs($admin)->get(route('settings.index')) + ->assertOk()->assertSee('https://new.example.test')->assertDontSee('saved-key')->assertDontSee('old-key'); + + Http::preventStrayRequests(); + Http::fake(['https://new.example.test/api/v1/printers' => Http::response([])]); + $this->assertSame([], app(Bambuddy::class)->printers()); + Http::assertSent(fn ($request) => $request->url() === 'https://new.example.test/api/v1/printers' + && $request->hasHeader('X-API-Key', 'saved-key')); + } + + public function test_blank_secret_keeps_existing_value_and_save_imports_server_values(): void + { + config()->set('services.parcel2go', [ + 'client_id' => 'server-id', 'client_secret' => 'server-secret', + 'auth_url' => 'https://auth.example.test/token', 'base_url' => 'https://api.example.test', + ]); + $admin = User::factory()->create(['role' => 'admin']); + $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), []) + ->assertRedirect(route('settings.index')); + $this->assertSame('server-secret', IntegrationSetting::findOrFail('parcel2go')->settings['client_secret']); + $this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ + 'client_id' => 'new-id', 'client_secret' => '', + ])->assertRedirect(route('settings.index')); + $this->assertSame('server-secret', IntegrationSetting::findOrFail('parcel2go')->settings['client_secret']); + + Http::preventStrayRequests(); + Http::fake([ + 'https://auth.example.test/token' => Http::response(['access_token' => 'token']), + 'https://api.example.test/me/detail' => Http::response(['ok' => true]), + ]); + $this->assertSame(['ok' => true], app(Parcel2Go::class)->fetch('me/detail')); + Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/token' + && $request['client_id'] === 'new-id' && $request['client_secret'] === 'server-secret'); + } + + public function test_dryer_uses_saved_endpoint_and_key(): void + { + config()->set('services.dryer', ['url' => 'https://old.example.test/api/v1', 'api_key' => 'old-key']); + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->put(route('settings.integrations.update', 'dryer'), [ + 'url' => 'https://dryer.example.test/api/v1', 'api_key' => 'new-key', + ])->assertRedirect(route('settings.index')); + + Http::preventStrayRequests(); + Http::fake(['https://dryer.example.test/api/v1/status' => Http::response(['state' => 'idle'])]); + $this->assertSame('https://dryer.example.test', app(DryerClient::class)->dashboardUrl()); + $this->assertSame(['state' => 'idle'], app(DryerClient::class)->status()); + Http::assertSent(fn ($request) => $request->url() === 'https://dryer.example.test/api/v1/status' + && $request->hasHeader('X-API-Key', 'new-key')); + $this->get(route('dryer'))->assertOk()->assertSee('https://dryer.example.test'); + } + + public function test_endpoint_urls_must_use_https(): void + { + $this->actingAs(User::factory()->create(['role' => 'admin'])) + ->put(route('settings.integrations.update', 'dryer'), [ + 'url' => 'http://dryer.example.test', 'api_key' => 'key', + ])->assertSessionHasErrors('url'); + $this->assertDatabaseCount('integration_settings', 0); + } +}