Skip to content

Commit 46c846a

Browse files
committed
feat: Implement .env editor with password confirmation and direct file access in admin topbar
1 parent e849343 commit 46c846a

5 files changed

Lines changed: 347 additions & 1 deletion

File tree

app/Livewire/EnvEditor.php

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
namespace App\Livewire;
4+
5+
use App\Enums\UsergroupRoleEnums;
6+
use Filament\Notifications\Notification;
7+
use Illuminate\Support\Facades\Auth;
8+
use Illuminate\Support\Facades\File;
9+
use Livewire\Component;
10+
use Throwable;
11+
12+
class EnvEditor extends Component
13+
{
14+
public bool $showPasswordModal = false;
15+
16+
public bool $showEditorModal = false;
17+
18+
public string $password = '';
19+
20+
public ?string $passwordError = null;
21+
22+
public string $content = '';
23+
24+
public function mount(): void
25+
{
26+
abort_unless($this->authorized(), 403);
27+
}
28+
29+
protected function authorized(): bool
30+
{
31+
return Auth::check() && Auth::user()->hasRole(UsergroupRoleEnums::ADMIN->value);
32+
}
33+
34+
protected function passwordConfirmed(): bool
35+
{
36+
$confirmedAt = session('auth.password_confirmed_at');
37+
38+
return $confirmedAt !== null
39+
&& (time() - $confirmedAt) < config('auth.password_timeout', 10800);
40+
}
41+
42+
public function openModal(): void
43+
{
44+
abort_unless($this->authorized(), 403);
45+
46+
if ($this->passwordConfirmed()) {
47+
$this->loadEnvContent();
48+
$this->showEditorModal = true;
49+
50+
return;
51+
}
52+
53+
$this->password = '';
54+
$this->passwordError = null;
55+
$this->showPasswordModal = true;
56+
}
57+
58+
public function confirmPassword(): void
59+
{
60+
abort_unless($this->authorized(), 403);
61+
62+
if (! Auth::guard('web')->validate([
63+
'email' => Auth::user()->email,
64+
'password' => $this->password,
65+
])) {
66+
$this->password = '';
67+
$this->passwordError = __('filament/env-editor.invalid_password');
68+
69+
return;
70+
}
71+
72+
session()->put('auth.password_confirmed_at', time());
73+
74+
$this->password = '';
75+
$this->passwordError = null;
76+
$this->showPasswordModal = false;
77+
78+
$this->loadEnvContent();
79+
$this->showEditorModal = true;
80+
}
81+
82+
protected function envPath(): string
83+
{
84+
return base_path('.env');
85+
}
86+
87+
protected function loadEnvContent(): void
88+
{
89+
$path = $this->envPath();
90+
91+
$this->content = File::exists($path) ? File::get($path) : '';
92+
}
93+
94+
public function save(): void
95+
{
96+
abort_unless($this->authorized(), 403);
97+
98+
if (! $this->passwordConfirmed()) {
99+
$this->showEditorModal = false;
100+
$this->showPasswordModal = true;
101+
102+
return;
103+
}
104+
105+
$path = $this->envPath();
106+
107+
if (! File::exists($path) || ! is_writable($path)) {
108+
Notification::make()
109+
->title(__('filament/env-editor.write_error'))
110+
->danger()
111+
->send();
112+
113+
return;
114+
}
115+
116+
try {
117+
File::put($path, $this->content);
118+
} catch (Throwable) {
119+
Notification::make()
120+
->title(__('filament/env-editor.write_error'))
121+
->danger()
122+
->send();
123+
124+
return;
125+
}
126+
127+
$this->showEditorModal = false;
128+
$this->content = '';
129+
130+
Notification::make()
131+
->title(__('filament/env-editor.saved_success'))
132+
->success()
133+
->send();
134+
}
135+
136+
public function closeEditorModal(): void
137+
{
138+
$this->showEditorModal = false;
139+
$this->content = '';
140+
}
141+
142+
public function closePasswordModal(): void
143+
{
144+
$this->showPasswordModal = false;
145+
$this->password = '';
146+
$this->passwordError = null;
147+
}
148+
149+
public function render()
150+
{
151+
return view('livewire.env-editor');
152+
}
153+
}

app/Providers/Filament/AdminPanelProvider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Providers\Filament;
44

5+
use App\Enums\UsergroupRoleEnums;
56
use App\Filament\Pages\Dashboard;
67
use App\Http\Middleware\FilamentAdminMiddleware;
78
use Filament\Enums\GlobalSearchPosition;
@@ -91,6 +92,13 @@ public function panel(Panel $panel): Panel
9192
fn() => auth()->check() ? "<livewire:version-check />" : '',
9293
);
9394

95+
FilamentView::registerRenderHook(
96+
PanelsRenderHook::TOPBAR_END,
97+
fn() => (auth()->check() && auth()->user()->hasRole(UsergroupRoleEnums::ADMIN->value))
98+
? \Illuminate\Support\Facades\Blade::render('<livewire:env-editor />')
99+
: '',
100+
);
101+
94102
$ticketSystemPluginClass = 'SilkPanel\\TicketSystem\\TicketSystemPlugin';
95103
if (class_exists($ticketSystemPluginClass)) {
96104
$panel->plugin($ticketSystemPluginClass::make());
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
return [
4+
'button_tooltip' => 'Edit .env',
5+
'password_modal_title' => 'Confirm Password',
6+
'password_modal_subtitle' => 'Please confirm your password to continue.',
7+
'password_placeholder' => 'Password',
8+
'invalid_password' => 'The provided password is incorrect.',
9+
'confirm_password' => 'Confirm',
10+
'editor_modal_title' => 'Edit Environment File',
11+
'editor_modal_subtitle' => 'Editing the .env file directly affects the running application.',
12+
'cache_hint' => 'Changes take effect after the next request, or after clearing the config cache.',
13+
'cancel' => 'Cancel',
14+
'save' => 'Save',
15+
'saved_success' => '.env file saved successfully!',
16+
'write_error' => 'The .env file could not be written. Please check file permissions.',
17+
];
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<div>
2+
{{-- Button in der Topbar --}}
3+
<button
4+
wire:click="openModal"
5+
title="{{ __('filament/env-editor.button_tooltip') }}"
6+
class="flex items-center justify-center w-9 h-9 rounded-lg border-none cursor-pointer bg-transparent text-gray-400 hover:bg-gray-100 hover:text-primary-500 dark:text-gray-500 dark:hover:bg-white/5 dark:hover:text-primary-400 transition-colors duration-150"
7+
>
8+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
9+
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z" clip-rule="evenodd" />
10+
</svg>
11+
</button>
12+
13+
{{-- Password Confirmation Modal --}}
14+
@if($showPasswordModal)
15+
<div
16+
x-data
17+
x-init="$nextTick(() => $el.querySelector('[data-modal-backdrop]').focus())"
18+
class="fixed inset-0 z-[9998] flex items-center justify-center p-4"
19+
>
20+
<div
21+
data-modal-backdrop
22+
wire:click="closePasswordModal"
23+
tabindex="-1"
24+
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
25+
></div>
26+
27+
<div
28+
role="dialog"
29+
aria-modal="true"
30+
class="relative z-10 w-full max-w-sm rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 shadow-2xl p-7"
31+
@keydown.escape.window="$wire.closePasswordModal()"
32+
>
33+
<form wire:submit="confirmPassword">
34+
<div class="flex items-center gap-3.5 mb-4">
35+
<div class="flex-shrink-0 flex items-center justify-center w-11 h-11 rounded-xl bg-primary-500/10 dark:bg-primary-400/10">
36+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 text-primary-500 dark:text-primary-400">
37+
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z" clip-rule="evenodd" />
38+
</svg>
39+
</div>
40+
<div>
41+
<h2 class="m-0 text-base font-bold text-gray-900 dark:text-white">{{ __('filament/env-editor.password_modal_title') }}</h2>
42+
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ __('filament/env-editor.password_modal_subtitle') }}</p>
43+
</div>
44+
</div>
45+
46+
<div class="mb-5">
47+
<input
48+
type="password"
49+
wire:model="password"
50+
autofocus
51+
placeholder="{{ __('filament/env-editor.password_placeholder') }}"
52+
class="w-full rounded-lg border border-gray-300 dark:border-white/10 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
53+
/>
54+
@if($passwordError)
55+
<p class="mt-2 text-xs text-red-500">{{ $passwordError }}</p>
56+
@endif
57+
</div>
58+
59+
<div class="flex flex-wrap gap-3 justify-end">
60+
<button
61+
type="button"
62+
wire:click="closePasswordModal"
63+
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-gray-700 dark:text-gray-300 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
64+
>
65+
{{ __('filament/env-editor.cancel') }}
66+
</button>
67+
<button
68+
type="submit"
69+
wire:loading.attr="disabled"
70+
class="px-5 py-2 rounded-lg border-none bg-primary-600 hover:bg-primary-700 text-white text-sm font-semibold cursor-pointer transition-colors disabled:opacity-60"
71+
>
72+
{{ __('filament/env-editor.confirm_password') }}
73+
</button>
74+
</div>
75+
</form>
76+
</div>
77+
</div>
78+
@endif
79+
80+
{{-- Editor Modal --}}
81+
@if($showEditorModal)
82+
<div
83+
x-data
84+
x-init="$nextTick(() => $el.querySelector('[data-modal-backdrop]').focus())"
85+
class="fixed inset-0 z-[9998] flex items-center justify-center p-4"
86+
>
87+
<div
88+
data-modal-backdrop
89+
wire:click="closeEditorModal"
90+
tabindex="-1"
91+
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
92+
></div>
93+
94+
<div
95+
role="dialog"
96+
aria-modal="true"
97+
class="relative z-10 w-full max-w-2xl rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-white/10 shadow-2xl p-7"
98+
@keydown.escape.window="$wire.closeEditorModal()"
99+
>
100+
<div class="flex items-center gap-3.5 mb-4">
101+
<div class="flex-shrink-0 flex items-center justify-center w-11 h-11 rounded-xl bg-primary-500/10 dark:bg-primary-400/10">
102+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 text-primary-500 dark:text-primary-400">
103+
<path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 0 0-5.25 5.25v3a3 3 0 0 0-3 3v6.75a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3v-6.75a3 3 0 0 0-3-3v-3c0-2.9-2.35-5.25-5.25-5.25Zm3.75 8.25v-3a3.75 3.75 0 1 0-7.5 0v3h7.5Z" clip-rule="evenodd" />
104+
</svg>
105+
</div>
106+
<div>
107+
<h2 class="m-0 text-base font-bold text-gray-900 dark:text-white">{{ __('filament/env-editor.editor_modal_title') }}</h2>
108+
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ __('filament/env-editor.editor_modal_subtitle') }}</p>
109+
</div>
110+
</div>
111+
112+
<textarea
113+
wire:model="content"
114+
rows="18"
115+
spellcheck="false"
116+
autocomplete="off"
117+
class="w-full rounded-lg border border-gray-300 dark:border-white/10 bg-gray-50 dark:bg-gray-950 px-3 py-2 text-xs font-mono leading-relaxed text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-y"
118+
></textarea>
119+
120+
<p class="mt-3 mb-5 text-xs text-gray-500 dark:text-gray-400">
121+
{{ __('filament/env-editor.cache_hint') }}
122+
</p>
123+
124+
<div class="flex flex-wrap gap-3 justify-end">
125+
<button
126+
wire:click="closeEditorModal"
127+
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-white/10 bg-transparent text-gray-700 dark:text-gray-300 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-white/5 transition-colors"
128+
>
129+
{{ __('filament/env-editor.cancel') }}
130+
</button>
131+
<button
132+
wire:click="save"
133+
wire:loading.attr="disabled"
134+
class="px-5 py-2 rounded-lg border-none bg-primary-600 hover:bg-primary-700 text-white text-sm font-semibold cursor-pointer flex items-center gap-1.5 transition-colors disabled:opacity-60"
135+
>
136+
<span wire:loading.remove wire:target="save">{{ __('filament/env-editor.save') }}</span>
137+
<span wire:loading wire:target="save" class="flex items-center gap-1.5">
138+
<svg class="w-3.5 h-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
139+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
140+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
141+
</svg>
142+
{{ __('filament/env-editor.save') }}
143+
</span>
144+
</button>
145+
</div>
146+
</div>
147+
</div>
148+
@endif
149+
</div>

storage/app/version.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
{
2-
"current": "3.0.4",
2+
"current": "3.0.5",
33
"changelog": {
4+
"3.0.5": {
5+
"title": ".env Editor",
6+
"description": "Added a secure, permission-gated .env editor to the admin topbar so server configuration can be changed without SSH or FTP access.",
7+
"released_at": "2026-06-22",
8+
"features": [
9+
{
10+
"title": "Edit .env Button",
11+
"description": "New topbar icon visible only to Administrators, opening a password-confirmed editor for the application's .env file."
12+
},
13+
{
14+
"title": "Password Re-Confirmation",
15+
"description": "Requires re-entering the account password before the editor opens, reusing Laravel's standard password-confirmation session window."
16+
},
17+
{
18+
"title": "Direct File Read/Write",
19+
"description": "Loads the current .env contents on open and writes changes straight back to disk, with clear error handling if the file isn't writable."
20+
}
21+
]
22+
},
423
"3.0.4": {
524
"title": "Cache Management Widget",
625
"description": "Added a quick cache-clear and optimize button to the admin topbar, plus a new player level distribution chart on the dashboard.",

0 commit comments

Comments
 (0)