Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions app/Actions/Teams/ExportTeamConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

namespace App\Actions\Teams;

use App\Models\Team;

class ExportTeamConfiguration
{
/**
* Export team print infrastructure configuration to JSON.
*
* @param \App\Models\Team $team
* @return array
*/
public function export(Team $team): array
{
$printServers = $team->PrintServers()
->with(['Printers.ClientApplications', 'tokens'])
->get()
->map(function ($server) {
return [
'ulid' => $server->ulid,
'name' => $server->name,
'location' => $server->location,
'api_token' => $this->getApiToken($server),
'printers' => $server->Printers->map(function ($printer) {
return [
'ulid' => $printer->ulid,
'name' => $printer->name,
'location' => $printer->location,
'enabled' => $printer->enabled,
'uri' => $printer->uri,
'ppd_support' => $printer->ppd_support,
'ppd_options' => $printer->ppd_options,
'ppd_options_layout' => $printer->ppd_options_layout,
'raw_languages_supported' => $printer->raw_languages_supported,
];
})->values()->all(),
];
})->values()->all();

$clientApplications = $team->ClientApplications()
->with(['Printers', 'tokens'])
->get()
->map(function ($app) {
return [
'ulid' => $app->ulid,
'name' => $app->name,
'url' => $app->url,
'api_token' => $this->getApiToken($app),
'printer_access' => $app->Printers->pluck('ulid')->values()->all(),
];
})->values()->all();

return [
'version' => '1.0.0',
'schema_version' => 'webprint-server-1.0',
'export_date' => now()->toIso8601String(),
'export_type' => 'team_infrastructure',
'metadata' => [
'source_version' => config('app.version', '1.0.0'),
'exporter_ulid' => auth()->user()?->ulid,
],
'print_servers' => $printServers,
'client_applications' => $clientApplications,
];
}

/**
* Get the hashed API token for a tokenable model.
*
* @param mixed $tokenable
* @return string|null
*/
protected function getApiToken($tokenable): ?string
{
$token = $tokenable->tokens->first();

return $token?->token;
}

/**
* Export team configuration as JSON string.
*
* @param \App\Models\Team $team
* @return string
*/
public function exportAsJson(Team $team): string
{
return json_encode($this->export($team), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

/**
* Generate filename for the export.
*
* @param \App\Models\Team $team
* @return string
*/
public function generateFilename(Team $team): string
{
$slug = \Illuminate\Support\Str::slug($team->name);
$date = now()->format('Y-m-d_His');

return sprintf('team-config-%s-%s.json', $slug, $date);
}
}
86 changes: 86 additions & 0 deletions app/Console/Commands/ExportTeamConfigurationCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace App\Console\Commands;

use App\Actions\Teams\ExportTeamConfiguration;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

class ExportTeamConfigurationCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'team:export-configuration
{team_ulid : The ULID of the team to export}
{--output= : Output file path (optional)}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Export team print infrastructure configuration to JSON';

/**
* Execute the console command.
*
* @param \App\Actions\Teams\ExportTeamConfiguration $exporter
* @return int
*/
public function handle(ExportTeamConfiguration $exporter): int
{
$teamUlid = $this->argument('team_ulid');

// Find team by ULID
$team = Team::where('ulid', $teamUlid)->first();

if (! $team) {
$this->error("Team with ULID '{$teamUlid}' not found.");

return self::FAILURE;
}

if ($team->personal_team) {
$this->error('Cannot export personal team configuration.');

return self::FAILURE;
}

$this->info("Exporting configuration for team: {$team->name}");

// Export the configuration
$json = $exporter->exportAsJson($team);

// Determine output path
$outputPath = $this->option('output');

if (! $outputPath) {
$outputPath = storage_path('app/'.$exporter->generateFilename($team));
}

// Ensure directory exists
$directory = dirname($outputPath);
if (! File::isDirectory($directory)) {
File::makeDirectory($directory, 0755, true);
}

// Write to file
File::put($outputPath, $json);

$this->info("Configuration exported successfully to: {$outputPath}");

// Show statistics
$data = json_decode($json, true);
$this->newLine();
$this->line('<fg=cyan>Export Statistics:</>');
$this->line(' Print Servers: '.count($data['print_servers'] ?? []));
$this->line(' Printers: '.collect($data['print_servers'] ?? [])->sum(fn ($server) => count($server['printers'] ?? [])));
$this->line(' Client Applications: '.count($data['client_applications'] ?? []));

return self::SUCCESS;
}
}
83 changes: 83 additions & 0 deletions app/Http/Livewire/Teams/ExportTeamConfigurationForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

namespace App\Http\Livewire\Teams;

use App\Actions\Teams\ExportTeamConfiguration;
use Illuminate\Support\Facades\Gate;
use Laravel\Jetstream\ConfirmsPasswords;
use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ExportTeamConfigurationForm extends Component
{
use ConfirmsPasswords;

/**
* The team instance.
*
* @var mixed
*/
public $team;

/**
* Indicates if export is being confirmed.
*
* @var bool
*/
public $confirmingExport = false;

/**
* Mount the component.
*
* @param mixed $team
* @return void
*/
public function mount($team)
{
$this->team = $team;
}

/**
* Confirm export and check password.
*
* @return void
*/
public function confirmExport()
{
$this->ensurePasswordIsConfirmed();

$this->confirmingExport = true;
}

/**
* Export the team configuration.
*
* @param \App\Actions\Teams\ExportTeamConfiguration $exporter
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function export(ExportTeamConfiguration $exporter): StreamedResponse
{
Gate::authorize('export', $this->team);

$json = $exporter->exportAsJson($this->team);
$filename = $exporter->generateFilename($this->team);

$this->confirmingExport = false;

return response()->streamDownload(function () use ($json) {
echo $json;
}, $filename, [
'Content-Type' => 'application/json',
]);
}

/**
* Render the component.
*
* @return \Illuminate\View\View
*/
public function render()
{
return view('teams.export-team-configuration-form');
}
}
10 changes: 10 additions & 0 deletions app/Policies/TeamPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,14 @@ public function delete(User $user, Team $team): bool
{
return $user->ownsTeam($team) && ! $team->personal_team;
}

/**
* Determine whether the user can export team configuration.
*
* @return mixed
*/
public function export(User $user, Team $team): bool
{
return $user->ownsTeam($team) && ! $team->personal_team;
}
}
70 changes: 70 additions & 0 deletions resources/views/teams/export-team-configuration-form.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<x-jet-action-section>
<x-slot name="title">
{{ __('Export Team Configuration') }}
</x-slot>

<x-slot name="description">
{{ __('Download team print infrastructure configuration.') }}
</x-slot>

<x-slot name="content">
<div class="max-w-xl text-sm text-gray-600">
{{ __('Export the complete print infrastructure configuration for this team, including print servers, printers, client applications, and their API tokens. This file can be used to migrate to a new WebPrint Server instance.') }}
</div>

<div class="mt-5">
<x-jet-confirms-password wire:then="confirmExport">
<x-jet-button wire:loading.attr="disabled">
{{ __('Export Configuration') }}
</x-jet-button>
</x-jet-confirms-password>
</div>

<!-- Export Confirmation Modal -->
<x-jet-confirmation-modal wire:model="confirmingExport">
<x-slot name="title">
{{ __('Export Team Configuration') }}
</x-slot>

<x-slot name="content">
<div class="space-y-4">
<p>{{ __('You are about to export the complete print infrastructure configuration for this team.') }}</p>

<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">{{ __('Security Notice') }}</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>{{ __('The export file contains hashed API tokens. Store this file securely as it can be used to restore access to your print infrastructure.') }}</p>
</div>
</div>
</div>
</div>

<p class="text-sm text-gray-600">{{ __('The export includes:') }}</p>
<ul class="list-disc list-inside text-sm text-gray-600 space-y-1">
<li>{{ __('Print servers and their configurations') }}</li>
<li>{{ __('Printers with PPD options and settings') }}</li>
<li>{{ __('Client applications and printer access mappings') }}</li>
<li>{{ __('API tokens (hashed)') }}</li>
</ul>
</div>
</x-slot>

<x-slot name="footer">
<x-jet-secondary-button wire:click="$toggle('confirmingExport')" wire:loading.attr="disabled">
{{ __('Cancel') }}
</x-jet-secondary-button>

<x-jet-button class="ml-3" wire:click="export" wire:loading.attr="disabled">
{{ __('Download Export') }}
</x-jet-button>
</x-slot>
</x-jet-confirmation-modal>
</x-slot>
</x-jet-action-section>
8 changes: 8 additions & 0 deletions resources/views/teams/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@

@livewire('teams.team-member-manager', ['team' => $team])

@if (Gate::check('export', $team) && ! $team->personal_team)
<x-jet-section-border />

<div class="mt-10 sm:mt-0">
@livewire('teams.export-team-configuration-form', ['team' => $team])
</div>
@endif

@if (Gate::check('delete', $team) && ! $team->personal_team)
<x-jet-section-border />

Expand Down
Loading
Loading