Move integration credentials into encrypted dashboard settings
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m42s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 1m42s
This commit is contained in:
@@ -30,10 +30,9 @@ The migrations use the legacy table and column names. If you point the app at th
|
|||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
- Parcel2Go: set `P2G_CLIENT_ID`, `P2G_CLIENT_SECRET`, and `P2G_API_AUTH_URL`.
|
- 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.
|
||||||
- Etsy: add the app keystring and shared secret on the Settings page, then connect the shop with OAuth.
|
- 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.
|
||||||
- Printers: set `BAMBUDDY_URL` and `BAMBUDDY_API_KEY` for live print farm status.
|
- Connect the Etsy shop with OAuth after saving its app credentials. The browser talks to authenticated Laravel routes for dryer status and controls.
|
||||||
- Dryer: set `DRYER_URL` and `DRYER_API_KEY`. The browser talks to authenticated Laravel routes; Laravel forwards status and control requests.
|
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ class BambuddyController extends Controller
|
|||||||
$integrationError = 'BamBuddy is unavailable. Check its API key and connection.';
|
$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()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\EtsyConnection;
|
|||||||
use App\Services\Bambuddy;
|
use App\Services\Bambuddy;
|
||||||
use App\Services\DryerClient;
|
use App\Services\DryerClient;
|
||||||
use App\Services\Etsy;
|
use App\Services\Etsy;
|
||||||
|
use App\Services\IntegrationSettings;
|
||||||
use App\Services\Parcel2Go;
|
use App\Services\Parcel2Go;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -14,7 +15,7 @@ use Throwable;
|
|||||||
|
|
||||||
class DashboardController extends Controller
|
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';
|
$isAdmin = $request->user()->role === 'admin';
|
||||||
$etsyStatus = $isAdmin ? 'Not connected' : null;
|
$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 {
|
try {
|
||||||
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
|
$shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4);
|
||||||
$shippingStatus = 'Live account';
|
$shippingStatus = 'Live account';
|
||||||
@@ -85,7 +87,7 @@ class DashboardController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
$dryerState = 'Not configured';
|
$dryerState = 'Not configured';
|
||||||
if (filled(config('services.dryer.api_key'))) {
|
if ($dryer->configured()) {
|
||||||
try {
|
try {
|
||||||
$dryerState = ucfirst((string) ($dryer->status()['state'] ?? 'Unknown'));
|
$dryerState = ucfirst((string) ($dryer->status()['state'] ?? 'Unknown'));
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use Throwable;
|
|||||||
|
|
||||||
class DryerController extends Controller
|
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
|
public function status(DryerClient $dryer): JsonResponse
|
||||||
|
|||||||
@@ -4,21 +4,49 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\EtsyAppCredential;
|
use App\Models\EtsyAppCredential;
|
||||||
use App\Models\EtsyConnection;
|
use App\Models\EtsyConnection;
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
use App\Services\Etsy;
|
use App\Services\Etsy;
|
||||||
|
use App\Services\IntegrationSettings;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class EtsySettingsController extends Controller
|
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);
|
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', [
|
return view('settings.index', [
|
||||||
'storedCredentials' => EtsyAppCredential::find(1) !== null,
|
'storedCredentials' => $credentials !== null,
|
||||||
'configured' => $etsy->configured(),
|
'configured' => $etsy->configured(),
|
||||||
'redirectUri' => $etsy->redirectUri(),
|
'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);
|
abort_unless($request->user()->role === 'admin', 403);
|
||||||
|
|
||||||
$credentials = EtsyAppCredential::find(1);
|
$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([
|
$validated = $request->validate([
|
||||||
'keystring' => [$requireCredentials, 'string', 'max:255'],
|
'keystring' => [$requireKeystring, 'string', 'max:255'],
|
||||||
'shared_secret' => [$requireCredentials, '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');
|
$previousKeystring = $credentials?->keystring ?: config('services.etsy.keystring');
|
||||||
$credentials ??= new EtsyAppCredential;
|
$credentials ??= new EtsyAppCredential;
|
||||||
$credentials->id = 1;
|
$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->fill(array_filter($validated, fn (?string $value): bool => filled($value)));
|
||||||
$credentials->save();
|
$credentials->save();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
|
use App\Services\IntegrationSettings;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class IntegrationSettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function update(Request $request, string $provider, IntegrationSettings $settings): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class EtsyAppCredential extends Model
|
|||||||
/** @use HasFactory<EtsyAppCredentialFactory> */
|
/** @use HasFactory<EtsyAppCredentialFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = ['keystring', 'shared_secret'];
|
protected $fillable = ['keystring', 'shared_secret', 'shop_name', 'redirect_uri'];
|
||||||
|
|
||||||
protected $hidden = ['keystring', 'shared_secret'];
|
protected $hidden = ['keystring', 'shared_secret'];
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@ class EtsyAppCredential extends Model
|
|||||||
return [
|
return [
|
||||||
'keystring' => 'encrypted',
|
'keystring' => 'encrypted',
|
||||||
'shared_secret' => 'encrypted',
|
'shared_secret' => 'encrypted',
|
||||||
|
'shop_name' => 'encrypted',
|
||||||
|
'redirect_uri' => 'encrypted',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\IntegrationSettingFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class IntegrationSetting extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<IntegrationSettingFactory> */
|
||||||
|
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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,16 @@ use Throwable;
|
|||||||
|
|
||||||
class Bambuddy
|
class Bambuddy
|
||||||
{
|
{
|
||||||
|
public function __construct(private IntegrationSettings $settings) {}
|
||||||
|
|
||||||
|
public function url(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->settings->for('bambuddy')['url'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
public function configured(): bool
|
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
|
public function printers(): array
|
||||||
@@ -19,17 +26,18 @@ class Bambuddy
|
|||||||
throw new RuntimeException('BamBuddy API key is not configured.');
|
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() ?? [];
|
->acceptJson()->connectTimeout(3)->timeout(8)->get($url)->throw()->json() ?? [];
|
||||||
|
|
||||||
return collect($printers)->filter(fn ($printer) => is_array($printer) && isset($printer['id']))
|
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 {
|
try {
|
||||||
$status = Http::withHeaders(['X-API-Key' => config('services.bambuddy.api_key')])
|
$status = Http::withHeaders(['X-API-Key' => $apiKey])
|
||||||
->acceptJson()->connectTimeout(3)->timeout(5)
|
->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() ?? [];
|
->throw()->json() ?? [];
|
||||||
|
|
||||||
return $printer + ['live' => $status];
|
return $printer + ['live' => $status];
|
||||||
|
|||||||
@@ -7,10 +7,31 @@ use RuntimeException;
|
|||||||
|
|
||||||
class DryerClient
|
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
|
private function url(string $path): string
|
||||||
{
|
{
|
||||||
$base = config('services.dryer.url');
|
$base = $this->settings->for('dryer')['url'] ?? null;
|
||||||
if (! $base || ! config('services.dryer.api_key')) {
|
if (! $this->configured()) {
|
||||||
throw new RuntimeException('Dryer URL and API key must be configured.');
|
throw new RuntimeException('Dryer URL and API key must be configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +45,13 @@ class DryerClient
|
|||||||
|
|
||||||
public function status(): array
|
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() ?? [];
|
->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(): array
|
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() ?? [];
|
->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
|
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), [
|
->connectTimeout(3)->timeout(7)->post($this->url($path), [
|
||||||
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload,
|
||||||
])->throw()->json() ?? [];
|
])->throw()->json() ?? [];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Etsy
|
|||||||
{
|
{
|
||||||
private const API_URL = 'https://api.etsy.com/v3';
|
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;
|
private ?array $credentials = null;
|
||||||
|
|
||||||
public function configured(): bool
|
public function configured(): bool
|
||||||
@@ -51,7 +51,7 @@ class Etsy
|
|||||||
if (empty($shop['shop_id'])) {
|
if (empty($shop['shop_id'])) {
|
||||||
throw new RuntimeException('This Etsy account does not own a shop.');
|
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.');
|
throw new RuntimeException('The Etsy account does not own the configured HickLab3D shop.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +108,10 @@ class Etsy
|
|||||||
|
|
||||||
public function redirectUri(): string
|
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
|
private function credentials(): array
|
||||||
{
|
{
|
||||||
if ($this->credentials !== null) {
|
if ($this->credentials !== null) {
|
||||||
@@ -123,6 +123,8 @@ class Etsy
|
|||||||
return $this->credentials = [
|
return $this->credentials = [
|
||||||
'keystring' => $stored?->keystring ?: config('services.etsy.keystring'),
|
'keystring' => $stored?->keystring ?: config('services.etsy.keystring'),
|
||||||
'shared_secret' => $stored?->shared_secret ?: config('services.etsy.shared_secret'),
|
'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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
|
|
||||||
|
class IntegrationSettings
|
||||||
|
{
|
||||||
|
/** @var array<string, array<string, mixed>> */
|
||||||
|
private array $resolved = [];
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use RuntimeException;
|
|||||||
|
|
||||||
class Parcel2Go
|
class Parcel2Go
|
||||||
{
|
{
|
||||||
|
public function __construct(private IntegrationSettings $settings) {}
|
||||||
|
|
||||||
public function fetch(string $path): array
|
public function fetch(string $path): array
|
||||||
{
|
{
|
||||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||||
@@ -90,12 +92,12 @@ class Parcel2Go
|
|||||||
|
|
||||||
private function url(string $path): string
|
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
|
private function accessToken(): string
|
||||||
{
|
{
|
||||||
$settings = config('services.parcel2go');
|
$settings = $this->settings->for('parcel2go');
|
||||||
if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) {
|
if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) {
|
||||||
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
throw new RuntimeException('Parcel2Go credentials are not configured.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<IntegrationSetting>
|
||||||
|
*/
|
||||||
|
class IntegrationSettingFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => 'bambuddy',
|
||||||
|
'settings' => [
|
||||||
|
'url' => 'https://bambuddy.example.test',
|
||||||
|
'api_key' => 'example-api-key',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('integration_settings', function (Blueprint $table) {
|
||||||
|
$table->string('provider')->primary();
|
||||||
|
$table->longText('settings');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('integration_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('etsy_app_credentials', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class IntegrationSettingSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
if (app()->isLocal()) {
|
||||||
|
IntegrationSetting::factory()->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
@section('title', 'Filament dryer')
|
@section('title', 'Filament dryer')
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workshop tools</p><h2 class="mt-2 text-2xl font-bold">Filament dryer</h2><p class="mt-1 text-slate-400">Live programme, sensor readings and relay state.</p></div><a href="https://filamentdryer.hickmeister.uk" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open dryer ↗</a></div>
|
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Workshop tools</p><h2 class="mt-2 text-2xl font-bold">Filament dryer</h2><p class="mt-1 text-slate-400">Live programme, sensor readings and relay state.</p></div>@if($dryerUrl)<a href="{{ $dryerUrl }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open dryer ↗</a>@endif</div>
|
||||||
<p id="dryer-message" role="status" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-400">Connecting to dryer…</p>
|
<p id="dryer-message" role="status" class="rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-400">Connecting to dryer…</p>
|
||||||
<div id="dryer-fault" role="alert" hidden class="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-sm text-rose-200"><strong>Dryer fault</strong><p id="fault-detail" class="mt-1"></p></div>
|
<div id="dryer-fault" role="alert" hidden class="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-sm text-rose-200"><strong>Dryer fault</strong><p id="fault-detail" class="mt-1"></p></div>
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">@foreach([['dryer-state','State'],['temperature','Temperature'],['humidity','Humidity'],['remaining','Remaining time']] as [$id,$label])<div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">{{ $label }}</p><p id="{{ $id }}" class="mt-5 text-3xl font-bold">—</p></div>@endforeach</div>
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">@foreach([['dryer-state','State'],['temperature','Temperature'],['humidity','Humidity'],['remaining','Remaining time']] as [$id,$label])<div class="rounded-2xl border border-slate-800 bg-slate-900 p-6"><p class="text-sm text-slate-400">{{ $label }}</p><p id="{{ $id }}" class="mt-5 text-3xl font-bold">—</p></div>@endforeach</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
@section('title', 'Print farm')
|
@section('title', 'Print farm')
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Production</p><h2 class="mt-2 text-2xl font-bold">BamBuddy printers</h2><p class="mt-1 text-slate-400">Live state, progress and temperatures from the workshop.</p></div><a href="{{ config('services.bambuddy.url') }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open BamBuddy ↗</a></div>
|
<div class="flex flex-wrap items-end justify-between gap-4"><div><p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Production</p><h2 class="mt-2 text-2xl font-bold">BamBuddy printers</h2><p class="mt-1 text-slate-400">Live state, progress and temperatures from the workshop.</p></div><a href="{{ $bambuddyUrl }}" target="_blank" rel="noopener noreferrer" class="rounded-xl border border-brand/40 px-4 py-3 text-sm font-semibold text-brand hover:bg-brand/10">Open BamBuddy ↗</a></div>
|
||||||
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
@if($integrationError)<div role="alert" class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-200">{{ $integrationError }}</div>@endif
|
||||||
<div class="grid gap-4 lg:grid-cols-2">@forelse($printers as $printer)
|
<div class="grid gap-4 lg:grid-cols-2">@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
|
@php $live = $printer['live'] ?? []; $progress = min(100, max(0, (float) ($live['progress'] ?? 0))); $printerErrors = is_array($live['hms_errors'] ?? null) ? $live['hms_errors'] : []; @endphp
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Integrations</p>
|
<p class="text-xs font-bold uppercase tracking-[.2em] text-brand">Integrations</p>
|
||||||
<h2 class="mt-2 text-2xl font-bold">Etsy app credentials</h2>
|
<h2 class="mt-2 text-2xl font-bold">Integration settings</h2>
|
||||||
<p class="mt-1 text-sm text-slate-400">Save the credentials from your Etsy developer app, then connect your shop with OAuth.</p>
|
<p class="mt-1 text-sm text-slate-400">Manage the connections used by your shop and workshop. Saved values are encrypted in the database.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold">API access</h3>
|
<h3 class="text-lg font-semibold">Etsy shop</h3>
|
||||||
<p class="mt-1 text-sm text-slate-400">{{ $storedCredentials ? 'Credentials saved in the database.' : ($configured ? 'Using server credentials. Save them here to manage them in the dashboard.' : 'No credentials saved yet.') }}</p>
|
<p class="mt-1 text-sm text-slate-400">{{ $storedCredentials ? 'Credentials saved in the database.' : ($configured ? 'Using server credentials. Save them here to manage them in the dashboard.' : 'No credentials saved yet.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $configured ? 'bg-emerald-500/10 text-emerald-300' : 'bg-amber-500/10 text-amber-200' }}">{{ $configured ? 'Configured' : 'Setup needed' }}</span>
|
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $configured ? 'bg-emerald-500/10 text-emerald-300' : 'bg-amber-500/10 text-amber-200' }}">{{ $configured ? 'Configured' : 'Setup needed' }}</span>
|
||||||
@@ -21,19 +21,73 @@
|
|||||||
@method('PUT')
|
@method('PUT')
|
||||||
<label class="grid gap-2 text-sm text-slate-300">
|
<label class="grid gap-2 text-sm text-slate-300">
|
||||||
Etsy app keystring
|
Etsy app keystring
|
||||||
<input name="keystring" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $storedCredentials ? 'Leave blank to keep saved key' : 'Enter your app keystring' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $storedCredentials ? '' : 'required' }}>
|
<input name="keystring" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $configured ? 'Leave blank to keep current key' : 'Enter your app keystring' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $configured ? '' : 'required' }}>
|
||||||
</label>
|
</label>
|
||||||
<label class="grid gap-2 text-sm text-slate-300">
|
<label class="grid gap-2 text-sm text-slate-300">
|
||||||
Etsy shared secret
|
Etsy shared secret
|
||||||
<input name="shared_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $storedCredentials ? 'Leave blank to keep saved secret' : 'Enter your shared secret' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $storedCredentials ? '' : 'required' }}>
|
<input name="shared_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $configured ? 'Leave blank to keep current secret' : 'Enter your shared secret' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500" {{ $configured ? '' : 'required' }}>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">
|
||||||
|
Shop name
|
||||||
|
<input name="shop_name" type="text" maxlength="255" value="{{ old('shop_name', $shopName) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">
|
||||||
|
OAuth callback URL
|
||||||
|
<input name="redirect_uri" type="url" maxlength="2048" value="{{ old('redirect_uri', $redirectUri) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||||
</label>
|
</label>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<button class="rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save credentials</button>
|
<button class="rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save credentials</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p class="mt-4 text-xs text-slate-500">Saved values are encrypted and are never shown again. Changing the app key disconnects the current Etsy shop.</p>
|
<p class="mt-4 text-xs text-slate-500">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.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-5 xl:grid-cols-2">
|
||||||
|
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div><h3 class="text-lg font-semibold">Parcel2Go</h3><p class="mt-1 text-sm text-slate-400">Quotes, labels, shipments and collections.</p></div>
|
||||||
|
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['configured']])>{{ $integrations['parcel2go']['configured'] ? 'Configured' : 'Setup needed' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-slate-500">{{ $integrations['parcel2go']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}</p>
|
||||||
|
<form method="post" action="{{ route('settings.integrations.update', 'parcel2go') }}" class="mt-5 grid gap-4">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">Client ID<input name="client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" 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">Client secret<input name="client_secret" type="password" autocomplete="new-password" maxlength="255" placeholder="Leave blank to keep current value" 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">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div><h3 class="text-lg font-semibold">BamBuddy</h3><p class="mt-1 text-sm text-slate-400">Live print farm data.</p></div>
|
||||||
|
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['bambuddy']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['bambuddy']['configured']])>{{ $integrations['bambuddy']['configured'] ? 'Configured' : 'Setup needed' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-slate-500">{{ $integrations['bambuddy']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}</p>
|
||||||
|
<form method="post" action="{{ route('settings.integrations.update', 'bambuddy') }}" class="mt-5 grid gap-4">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">BamBuddy URL<input name="url" type="url" maxlength="2048" value="{{ old('url', $integrations['bambuddy']['url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">API key<input name="api_key" type="password" autocomplete="new-password" maxlength="512" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
|
||||||
|
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save BamBuddy</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div><h3 class="text-lg font-semibold">Filament dryer</h3><p class="mt-1 text-sm text-slate-400">Status, cycles and fault controls.</p></div>
|
||||||
|
<span @class(['rounded-full px-3 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['dryer']['configured'], 'bg-amber-500/10 text-amber-200' => ! $integrations['dryer']['configured']])>{{ $integrations['dryer']['configured'] ? 'Configured' : 'Setup needed' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-slate-500">{{ $integrations['dryer']['stored'] ? 'Saved in the database.' : 'Using server values where available. Save to import them.' }}</p>
|
||||||
|
<form method="post" action="{{ route('settings.integrations.update', 'dryer') }}" class="mt-5 grid gap-4">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">Dryer API URL<input name="url" type="url" maxlength="2048" value="{{ old('url', $integrations['dryer']['url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||||
|
<label class="grid gap-2 text-sm text-slate-300">API key<input name="api_key" type="password" autocomplete="new-password" maxlength="512" placeholder="Leave blank to keep current value" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
|
||||||
|
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save dryer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||||
<h3 class="text-lg font-semibold">OAuth callback</h3>
|
<h3 class="text-lg font-semibold">OAuth callback</h3>
|
||||||
<p class="mt-1 text-sm text-slate-400">Register this exact HTTPS URL in your Etsy developer app.</p>
|
<p class="mt-1 text-sm text-slate-400">Register this exact HTTPS URL in your Etsy developer app.</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\DashboardController;
|
|||||||
use App\Http\Controllers\DryerController;
|
use App\Http\Controllers\DryerController;
|
||||||
use App\Http\Controllers\EtsyController;
|
use App\Http\Controllers\EtsyController;
|
||||||
use App\Http\Controllers\EtsySettingsController;
|
use App\Http\Controllers\EtsySettingsController;
|
||||||
|
use App\Http\Controllers\IntegrationSettingsController;
|
||||||
use App\Http\Controllers\PrinterController;
|
use App\Http\Controllers\PrinterController;
|
||||||
use App\Http\Controllers\ShippingController;
|
use App\Http\Controllers\ShippingController;
|
||||||
use App\Http\Controllers\UserController;
|
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('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback');
|
||||||
Route::get('/settings', [EtsySettingsController::class, 'index'])->name('settings.index');
|
Route::get('/settings', [EtsySettingsController::class, 'index'])->name('settings.index');
|
||||||
Route::put('/settings/etsy', [EtsySettingsController::class, 'update'])->name('settings.etsy.update');
|
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', [DryerController::class, 'index'])->name('dryer');
|
||||||
Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status');
|
Route::get('/dryer/status', [DryerController::class, 'status'])->name('dryer.status');
|
||||||
Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control');
|
Route::post('/dryer/control', [DryerController::class, 'control'])->name('dryer.control');
|
||||||
|
|||||||
@@ -109,4 +109,28 @@ class EtsySettingsTest extends TestCase
|
|||||||
$this->assertSame('first-secret', EtsyAppCredential::firstOrFail()->shared_secret);
|
$this->assertSame('first-secret', EtsyAppCredential::firstOrFail()->shared_secret);
|
||||||
$this->assertDatabaseCount('etsy_connections', 0);
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\IntegrationSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Bambuddy;
|
||||||
|
use App\Services\DryerClient;
|
||||||
|
use App\Services\Parcel2Go;
|
||||||
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class IntegrationSettingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use LazilyRefreshDatabase;
|
||||||
|
|
||||||
|
public function test_only_administrators_can_manage_integration_settings(): void
|
||||||
|
{
|
||||||
|
$route = route('settings.integrations.update', 'bambuddy');
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user