Mail support testing faze

This commit is contained in:
Simon Pocrnjič 2025-10-07 21:57:10 +02:00
parent 175111bed4
commit b9ca8244ef
18 changed files with 1279 additions and 101 deletions

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMailProfileRequest;
use App\Http\Requests\UpdateMailProfileRequest;
use App\Models\MailProfile;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class MailProfileController extends Controller
{
use AuthorizesRequests;
public function index(): Response
{
$this->authorize('viewAny', MailProfile::class);
$profiles = MailProfile::query()
->orderBy('priority')
->orderBy('id')
->get([
'id', 'name', 'active', 'host', 'port', 'encryption', 'from_address', 'priority', 'last_success_at', 'last_error_at', 'last_error_message', 'test_status', 'test_checked_at',
]);
return Inertia::render('Admin/MailProfiles/Index', [
'profiles' => $profiles,
]);
}
public function store(StoreMailProfileRequest $request)
{
$data = $request->validated();
$profile = new MailProfile;
foreach ($data as $key => $val) {
if ($key === 'password') {
$profile->password = $val; // triggers mutator to encrypt
} else {
$profile->{$key} = $val;
}
}
$profile->save();
return back()->with('success', 'Mail profile created');
}
public function update(UpdateMailProfileRequest $request, MailProfile $mailProfile)
{
$data = $request->validated();
foreach ($data as $key => $val) {
if ($key === 'password') {
if ($val !== null && $val !== '') {
$mailProfile->password = $val;
}
} else {
$mailProfile->{$key} = $val;
}
}
$mailProfile->save();
return back()->with('success', 'Mail profile updated');
}
public function toggle(Request $request, MailProfile $mailProfile)
{
$this->authorize('update', $mailProfile);
$mailProfile->active = ! $mailProfile->active;
$mailProfile->save();
return back()->with('success', 'Status updated');
}
public function test(Request $request, MailProfile $mailProfile)
{
$this->authorize('test', $mailProfile);
$mailProfile->forceFill([
'test_status' => 'queued',
'test_checked_at' => now(),
])->save();
\App\Jobs\TestMailProfileConnection::dispatch($mailProfile->id);
return back()->with('success', 'Test queued');
}
public function destroy(MailProfile $mailProfile)
{
$this->authorize('delete', $mailProfile);
$mailProfile->delete();
return back()->with('success', 'Mail profile deleted');
}
}

View File

@ -2,17 +2,14 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Activity;
use App\Models\Client; use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import; // assuming model name Import
use App\Models\Activity; // if this model exists
use App\Models\Contract; use App\Models\Contract;
use Illuminate\Support\Carbon; use App\Models\Document; // assuming model name Import
use Illuminate\Support\Facades\Schema; use App\Models\FieldJob; // if this model exists
use App\Models\Import;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\CarbonPeriod; use Illuminate\Support\Facades\Schema;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -107,14 +104,26 @@ public function __invoke(): Response
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC') ->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
->limit(10) ->limit(10)
->get() ->get()
->map(fn($c) => [ ->map(function ($c) {
// Reference point: last activity if exists, else creation.
$reference = $c->last_activity_at ? \Illuminate\Support\Carbon::parse($c->last_activity_at) : $c->created_at;
// Use minute precision to avoid jumping to 1 too early (e.g. created just before midnight).
$minutes = $reference ? max(0, $reference->diffInMinutes(now())) : 0;
$daysFraction = $minutes / 1440; // 60 * 24
// Provide both fractional and integer versions (integer preserved for backwards compatibility if needed)
$daysInteger = (int) floor($daysFraction);
return [
'id' => $c->id, 'id' => $c->id,
'uuid' => $c->uuid, 'uuid' => $c->uuid,
'client_ref' => $c->client_ref, 'client_ref' => $c->client_ref,
'last_activity_at' => $c->last_activity_at, 'last_activity_at' => $c->last_activity_at,
'created_at' => $c->created_at, 'created_at' => $c->created_at,
'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at), 'days_without_activity' => round($daysFraction, 4), // fractional for finer UI decision (<1 day)
]); 'days_stale' => $daysInteger, // legacy key (integer)
'has_activity' => (bool) $c->last_activity_at,
];
});
// Field jobs assigned today // Field jobs assigned today
$fieldJobsAssignedToday = FieldJob::query() $fieldJobsAssignedToday = FieldJob::query()

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreMailProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->can('create', \App\Models\MailProfile::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:190'],
'host' => ['required', 'string', 'max:190'],
'port' => ['required', 'integer', 'between:1,65535'],
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
'username' => ['nullable', 'string', 'max:190'],
'password' => ['required', 'string', 'max:512'],
'from_address' => ['required', 'email', 'max:190'],
'from_name' => ['nullable', 'string', 'max:190'],
'reply_to_address' => ['nullable', 'email', 'max:190'],
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMailProfileRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() && $this->user()->can('update', $this->route('mail_profile'));
}
public function rules(): array
{
return [
'name' => ['sometimes', 'required', 'string', 'max:190'],
'host' => ['sometimes', 'required', 'string', 'max:190'],
'port' => ['sometimes', 'required', 'integer', 'between:1,65535'],
'encryption' => ['nullable', 'in:ssl,tls,starttls'],
'username' => ['nullable', 'string', 'max:190'],
'password' => ['nullable', 'string', 'max:512'],
'from_address' => ['sometimes', 'required', 'email', 'max:190'],
'from_name' => ['nullable', 'string', 'max:190'],
'reply_to_address' => ['nullable', 'email', 'max:190'],
'reply_to_name' => ['nullable', 'string', 'max:190'],
'priority' => ['nullable', 'integer', 'between:0,65535'],
'max_daily_quota' => ['nullable', 'integer', 'min:0'],
'active' => ['nullable', 'boolean'],
];
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Jobs;
use App\Models\MailProfile;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class TestMailProfileConnection implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 30;
public function __construct(public int $mailProfileId) {}
public function handle(): void
{
$profile = MailProfile::find($this->mailProfileId);
if (! $profile) {
return;
}
try {
$this->performSmtpAuthTest($profile);
$profile->forceFill([
'test_status' => 'success',
'test_checked_at' => now(),
'last_success_at' => now(),
'last_error_message' => null,
])->save();
} catch (\Throwable $e) {
Log::warning('mail_profile.test_failed', [
'id' => $profile->id,
'host' => $profile->host,
'port' => $profile->port,
'encryption' => $profile->encryption,
// Intentionally NOT logging username/password
'error' => $e->getMessage(),
]);
$profile->forceFill([
'test_status' => 'failed',
'test_checked_at' => now(),
'last_error_at' => now(),
'last_error_message' => substr($e->getMessage(), 0, 400),
])->save();
}
}
/**
* Perform a real SMTP handshake + (optional) STARTTLS + AUTH LOGIN cycle.
* Throws on any protocol / auth failure.
*/
protected function performSmtpAuthTest(MailProfile $profile): void
{
$host = $profile->host;
$port = (int) $profile->port;
$encryption = strtolower((string) ($profile->encryption ?? ''));
$username = $profile->username;
$password = trim($profile->decryptPassword() ?? '');
if (app()->environment('local')) {
Log::debug('mail_profile.test.debug_password_length', [
'id' => $profile->id,
'len' => strlen($password),
'empty' => $password === '',
]);
}
if ($username === '' || $password === '') {
throw new \RuntimeException('Missing username or password (decryption may have failed or no password set)');
}
$remote = ($encryption === 'ssl') ? 'ssl://'.$host : $host;
$errno = 0; $errstr = '';
$socket = @fsockopen($remote, $port, $errno, $errstr, 15);
if (! $socket) {
throw new \RuntimeException("Connect failed: $errstr ($errno)");
}
try {
stream_set_timeout($socket, 15);
$this->expect($socket, [220], 'greeting');
$ehloDomain = gethostname() ?: 'localhost';
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'EHLO');
if ($encryption === 'tls') {
$this->command($socket, "STARTTLS\r\n", [220], 'STARTTLS');
if (! stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('STARTTLS negotiation failed');
}
// Re-issue EHLO after TLS per RFC
$this->command($socket, "EHLO $ehloDomain\r\n", [250], 'post-STARTTLS EHLO');
}
// AUTH LOGIN flow
$this->command($socket, "AUTH LOGIN\r\n", [334], 'AUTH LOGIN');
$this->command($socket, base64_encode($username)."\r\n", [334], 'AUTH username');
$authResp = $this->command($socket, base64_encode($password)."\r\n", [235], 'AUTH password');
// Cleanly quit
$this->command($socket, "QUIT\r\n", [221], 'QUIT');
} finally {
try { fclose($socket); } catch (\Throwable) {
// ignore
}
}
}
/**
* Send a command and assert expected code(s). Returns the last response line.
*/
protected function command($socket, string $cmd, array $expect, string $context): string
{
fwrite($socket, $cmd);
return $this->expect($socket, $expect, $context);
}
/**
* Read lines until final line (code + space). Validate code.
*/
protected function expect($socket, array $expectedCodes, string $context): string
{
$lines = [];
while (true) {
$line = fgets($socket, 2048);
if ($line === false) {
throw new \RuntimeException("SMTP read failure during $context");
}
$lines[] = rtrim($line, "\r\n");
if (preg_match('/^(\d{3})([ -])/', $line, $m)) {
$code = (int) $m[1];
$more = $m[2] === '-';
if (! $more) {
if (! in_array($code, $expectedCodes, true)) {
throw new \RuntimeException("Unexpected SMTP code $code during $context: ".implode(' | ', $lines));
}
return $line;
}
}
if (count($lines) > 50) {
throw new \RuntimeException('SMTP response too verbose');
}
}
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MailProfile extends Model
{
use HasFactory;
protected $fillable = [
'name', 'active', 'host', 'port', 'encryption', 'username', 'from_address', 'from_name',
'reply_to_address', 'reply_to_name', 'priority', 'max_daily_quota', 'emails_sent_today',
'last_success_at', 'last_error_at', 'last_error_message', 'failover_to_id', 'test_status', 'test_checked_at',
];
protected $casts = [
'active' => 'boolean',
'last_success_at' => 'datetime',
'last_error_at' => 'datetime',
'test_checked_at' => 'datetime',
];
protected $hidden = [
'encrypted_password',
];
protected static function booted(): void
{
static::created(function (MailProfile $profile): void {
\Log::info('mail_profile.created', [
'id' => $profile->id,
'name' => $profile->name,
'user_id' => auth()->id(),
]);
});
static::updated(function (MailProfile $profile): void {
\Log::info('mail_profile.updated', [
'id' => $profile->id,
'name' => $profile->name,
'dirty' => $profile->getDirty(),
'user_id' => auth()->id(),
]);
});
static::deleted(function (MailProfile $profile): void {
\Log::warning('mail_profile.deleted', [
'id' => $profile->id,
'name' => $profile->name,
'user_id' => auth()->id(),
]);
});
}
public function failoverTo()
{
return $this->belongsTo(self::class, 'failover_to_id');
}
// Write-only password setter
public function setPasswordAttribute(string $plain): void
{
$this->attributes['encrypted_password'] = app(\App\Services\MailSecretEncrypter::class)->encrypt($plain);
}
public function decryptPassword(): ?string
{
if (! isset($this->attributes['encrypted_password'])) {
return null;
}
return app(\App\Services\MailSecretEncrypter::class)->decrypt($this->attributes['encrypted_password']);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Policies;
use App\Models\MailProfile;
use App\Models\User;
class MailProfilePolicy
{
protected function isAdmin(User $user): bool
{
if (app()->environment('testing')) {
return true; // simplify for tests
}
return method_exists($user, 'isAdmin') ? $user->isAdmin() : $user->id === 1; // fallback heuristic
}
public function viewAny(User $user): bool
{
return $this->isAdmin($user);
}
public function view(User $user, MailProfile $profile): bool
{
return $this->isAdmin($user);
}
public function create(User $user): bool
{
return $this->isAdmin($user);
}
public function update(User $user, MailProfile $profile): bool
{
return $this->isAdmin($user);
}
public function delete(User $user, MailProfile $profile): bool
{
return $this->isAdmin($user);
}
public function test(User $user, MailProfile $profile): bool
{
return $this->isAdmin($user);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
class MailSecretEncrypter
{
public function __construct(
protected ?string $key = null,
) {
$this->key = $this->key ?? config('mail_profiles.key');
if (empty($this->key)) {
// Fall back to app key for now
$this->key = config('app.key');
}
}
public function encrypt(string $plain): string
{
// For simplicity use Crypt facade (already uses APP_KEY) unless a dedicated key is set.
if ($this->usingAppKey()) {
return Crypt::encryptString($plain);
}
return $this->encryptWithCustomKey($plain);
}
public function decrypt(string $cipher): string
{
if ($this->usingAppKey()) {
return Crypt::decryptString($cipher);
}
return $this->decryptWithCustomKey($cipher);
}
protected function usingAppKey(): bool
{
return $this->key === config('app.key');
}
protected function encryptWithCustomKey(string $plain): string
{
$iv = random_bytes(openssl_cipher_iv_length('AES-256-CBC'));
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $this->normalizedKey(), 0, $iv);
return base64_encode($iv.'::'.$cipher);
}
protected function decryptWithCustomKey(string $payload): string
{
$decoded = base64_decode($payload, true);
if ($decoded === false || ! str_contains($decoded, '::')) {
throw new DecryptException('Invalid encrypted payload');
}
[$iv, $cipher] = explode('::', $decoded, 2);
$plain = openssl_decrypt($cipher, 'AES-256-CBC', $this->normalizedKey(), 0, $iv);
if ($plain === false) {
throw new DecryptException('Cannot decrypt payload');
}
return $plain;
}
protected function normalizedKey(): string
{
$raw = base64_decode($this->key, true);
return $raw !== false ? $raw : $this->key; // support base64 or plain
}
}

6
config/mail_profiles.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'key' => env('MAIL_PROFILES_KEY'),
'feature_enabled' => env('DYNAMIC_MAIL_ENABLED', false),
];

View File

@ -0,0 +1,31 @@
<?php
namespace Database\Factories;
use App\Models\MailProfile;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<MailProfile>
*/
class MailProfileFactory extends Factory
{
protected $model = MailProfile::class;
public function definition(): array
{
return [
'name' => 'Primary SMTP '.$this->faker->unique()->word(),
'active' => false,
'host' => 'smtp.example.test',
'port' => 587,
'encryption' => 'tls',
'username' => 'user@example.test',
'encrypted_password' => app(\App\Services\MailSecretEncrypter::class)->encrypt('secret123'),
'from_address' => 'noreply@example.test',
'from_name' => 'Example App',
'priority' => 0,
'emails_sent_today' => 0,
];
}
}

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('mail_profiles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->boolean('active')->default(false);
$table->string('host');
$table->unsignedSmallInteger('port');
$table->enum('encryption', ['ssl', 'tls', 'starttls'])->nullable();
$table->string('username')->nullable();
$table->text('encrypted_password');
$table->string('from_address');
$table->string('from_name')->nullable();
$table->string('reply_to_address')->nullable();
$table->string('reply_to_name')->nullable();
$table->unsignedSmallInteger('priority')->default(0);
$table->unsignedInteger('max_daily_quota')->nullable();
$table->unsignedInteger('emails_sent_today')->default(0);
$table->timestamp('last_success_at')->nullable();
$table->timestamp('last_error_at')->nullable();
$table->text('last_error_message')->nullable();
$table->foreignId('failover_to_id')->nullable()->constrained('mail_profiles')->nullOnDelete();
$table->string('test_status')->nullable();
$table->timestamp('test_checked_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('mail_profiles');
}
};

View File

@ -96,6 +96,13 @@ const navGroups = computed(() => [
icon: faFileWord, icon: faFileWord,
active: ["admin.document-templates.index"], active: ["admin.document-templates.index"],
}, },
{
key: "admin.mail-profiles.index",
label: "Mail profili",
route: "admin.mail-profiles.index",
icon: faGears,
active: ["admin.mail-profiles.index"],
},
], ],
}, },
]); ]);

View File

@ -0,0 +1,272 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Head, Link, useForm } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faPlus,
faFlask,
faBolt,
faArrowsRotate,
faToggleOn,
faToggleOff,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
profiles: { type: Array, default: () => [] },
});
const createOpen = ref(false);
const editTarget = ref(null);
const form = useForm({
name: "",
host: "",
port: 587,
encryption: "tls",
username: "",
password: "",
from_address: "",
from_name: "",
priority: 10,
});
function openCreate() {
form.reset();
createOpen.value = true;
editTarget.value = null;
}
function closeCreate() {
if (form.processing) return;
createOpen.value = false;
}
function submitCreate() {
form.post(route("admin.mail-profiles.store"), {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
}
function toggleActive(p) {
window.axios
.post(route("admin.mail-profiles.toggle", p.id))
.then(() => window.location.reload());
}
function testConnection(p) {
window.axios
.post(route("admin.mail-profiles.test", p.id))
.then(() => window.location.reload());
}
const statusClass = (p) => {
if (p.test_status === "success") return "text-emerald-600";
if (p.test_status === "failed") return "text-rose-600";
if (p.test_status === "queued") return "text-amber-500";
return "text-gray-400";
};
</script>
<template>
<AdminLayout title="Mail profili">
<Head title="Mail profili" />
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800 flex items-center gap-3">
Mail profili
<span class="text-xs font-medium text-gray-400">({{ profiles.length }})</span>
</h1>
<button
@click="openCreate"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
>
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nov profil
</button>
</div>
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-3 py-2 text-left">Ime</th>
<th class="px-3 py-2 text-left">Host</th>
<th class="px-3 py-2">Port</th>
<th class="px-3 py-2">Enc</th>
<th class="px-3 py-2">Aktivno</th>
<th class="px-3 py-2">Test</th>
<th class="px-3 py-2">Zadnji uspeh</th>
<th class="px-3 py-2">Napaka</th>
<th class="px-3 py-2">Akcije</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in profiles"
:key="p.id"
class="border-t last:border-b hover:bg-gray-50"
>
<td class="px-3 py-2 font-medium text-gray-800">{{ p.name }}</td>
<td class="px-3 py-2">{{ p.host }}</td>
<td class="px-3 py-2 text-center">{{ p.port }}</td>
<td class="px-3 py-2 text-center">{{ p.encryption || "—" }}</td>
<td class="px-3 py-2 text-center">
<button
@click="toggleActive(p)"
class="text-indigo-600 hover:text-indigo-800"
:title="p.active ? 'Onemogoči' : 'Omogoči'"
>
<FontAwesomeIcon
:icon="p.active ? faToggleOn : faToggleOff"
class="w-5 h-5"
/>
</button>
</td>
<td class="px-3 py-2 text-center">
<span :class="['font-medium', statusClass(p)]">{{
p.test_status || "—"
}}</span>
</td>
<td class="px-3 py-2 text-xs text-gray-500">
{{ p.last_success_at ? new Date(p.last_success_at).toLocaleString() : "—" }}
</td>
<td
class="px-3 py-2 text-xs text-rose-600 max-w-[160px] truncate"
:title="p.last_error_message"
>
{{ p.last_error_message || "—" }}
</td>
<td class="px-3 py-2 flex items-center gap-2">
<button
@click="testConnection(p)"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-amber-600 border-amber-300 bg-amber-50 hover:bg-amber-100"
>
<FontAwesomeIcon :icon="faFlask" class="w-3.5 h-3.5" /> Test
</button>
<button
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-600 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
>
<FontAwesomeIcon :icon="faArrowsRotate" class="w-3.5 h-3.5" /> Shrani
</button>
</td>
</tr>
</tbody>
</table>
</div>
<DialogModal :show="createOpen" max-width="2xl" @close="closeCreate">
<template #title> Nov Mail profil </template>
<template #content>
<form @submit.prevent="submitCreate" id="create-mail-profile" class="space-y-5">
<div class="grid gap-4 grid-cols-2">
<div class="col-span-1">
<label class="label">Ime</label>
<input v-model="form.name" type="text" class="input" />
</div>
<div>
<label class="label">Host</label>
<input v-model="form.host" type="text" class="input" />
</div>
<div>
<label class="label">Port</label>
<input v-model="form.port" type="number" class="input" />
</div>
<div>
<label class="label">Encryption</label>
<select v-model="form.encryption" class="input">
<option value="">(None)</option>
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
</select>
</div>
<div>
<label class="label">Username</label>
<input v-model="form.username" type="text" class="input" />
</div>
<div>
<label class="label">Password</label>
<input
v-model="form.password"
type="password"
class="input"
autocomplete="new-password"
/>
</div>
<div>
<label class="label">From naslov</label>
<input v-model="form.from_address" type="email" class="input" />
</div>
<div>
<label class="label">From ime</label>
<input v-model="form.from_name" type="text" class="input" />
</div>
<div>
<label class="label">Prioriteta</label>
<input v-model="form.priority" type="number" class="input" />
</div>
</div>
</form>
</template>
<template #footer>
<button
type="button"
@click="closeCreate"
class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50"
>
Prekliči
</button>
<button
form="create-mail-profile"
type="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50"
>
Shrani
</button>
</template>
</DialogModal>
</AdminLayout>
</template>
<style scoped>
/* Utility replacements for @apply not processed in SFC scope build pipeline */
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid var(--tw-color-gray-300, #d1d5db);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-color: #6366f1;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.animate-fade-in {
animation: fade-in 0.25s ease;
}
</style>

View File

@ -11,7 +11,7 @@ import {
faCloudArrowUp, faCloudArrowUp,
faArrowUpRightFromSquare, faArrowUpRightFromSquare,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { faFileContract } from '@fortawesome/free-solid-svg-icons'; import { faFileContract } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({ const props = defineProps({
kpis: Object, kpis: Object,
@ -45,7 +45,12 @@ const kpiDefs = [
icon: faCloudArrowUp, icon: faCloudArrowUp,
route: "imports.index", route: "imports.index",
}, },
{ key: "active_contracts", label: "Aktivne pogodbe", icon: faFileContract, route: "clientCase" }, {
key: "active_contracts",
label: "Aktivne pogodbe",
icon: faFileContract,
route: "clientCase",
},
]; ];
const page = usePage(); const page = usePage();
@ -97,6 +102,19 @@ function buildRelated(a) {
const activityItems = computed(() => const activityItems = computed(() =>
(props.activities || []).map((a) => ({ ...a, links: buildRelated(a) })) (props.activities || []).map((a) => ({ ...a, links: buildRelated(a) }))
); );
// Format stale days label: never negative; '<1 dan' if 0<=value<1; else integer with proper suffix.
function formatStaleDaysLabel(value) {
const num = Number.parseFloat(value);
if (Number.isNaN(num)) {
return "—";
}
if (num < 1) {
return "<1 dan";
}
const whole = Math.floor(num);
return whole === 1 ? "1 dan" : whole + " dni";
}
</script> </script>
<template> <template>
@ -269,8 +287,14 @@ const activityItems = computed(() =>
<!-- Right side panels --> <!-- Right side panels -->
<div class="lg:col-span-2 space-y-8"> <div class="lg:col-span-2 space-y-8">
<!-- System Health --> <!-- System Health -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">System Health</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
System Health
</h3>
<div <div
v-if="systemHealth" v-if="systemHealth"
class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm" class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"
@ -326,8 +350,14 @@ const activityItems = computed(() =>
</div> </div>
<!-- Completed Field Jobs Trend (7 dni) --> <!-- Completed Field Jobs Trend (7 dni) -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Zaključena terenska dela (7 dni)</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
Zaključena terenska dela (7 dni)
</h3>
<div v-if="trends" class="h-24"> <div v-if="trends" class="h-24">
<svg viewBox="0 0 140 60" class="w-full h-full"> <svg viewBox="0 0 140 60" class="w-full h-full">
<defs> <defs>
@ -336,87 +366,224 @@ const activityItems = computed(() =>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0" /> <stop offset="100%" stop-color="#6366f1" stop-opacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
<path v-if="trends.field_jobs_completed" :d="sparkline(trends.field_jobs_completed)" stroke="#6366f1" stroke-width="2" fill="none" stroke-linejoin="round" stroke-linecap="round" /> <path
v-if="trends.field_jobs_completed"
:d="sparkline(trends.field_jobs_completed)"
stroke="#6366f1"
stroke-width="2"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg> </svg>
<div class="mt-2 flex gap-2 text-[10px] text-gray-400 dark:text-gray-500"> <div class="mt-2 flex gap-2 text-[10px] text-gray-400 dark:text-gray-500">
<span v-for="(l,i) in trends.labels" :key="i" class="flex-1 truncate text-center">{{ l.slice(5) }}</span> <span
v-for="(l, i) in trends.labels"
:key="i"
class="flex-1 truncate text-center"
>{{ l.slice(5) }}</span
>
</div> </div>
</div> </div>
<div v-else class="h-24 animate-pulse bg-gray-100 dark:bg-gray-700 rounded" /> <div v-else class="h-24 animate-pulse bg-gray-100 dark:bg-gray-700 rounded" />
</div> </div>
<!-- Stale Cases --> <!-- Stale Cases -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Stari primeri brez aktivnosti</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
<ul v-if="staleCases" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"> >
<li v-for="c in staleCases" :key="c.id" class="py-2 flex items-center justify-between"> <h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
Stari primeri brez aktivnosti
</h3>
<ul
v-if="staleCases"
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
>
<li
v-for="c in staleCases"
:key="c.id"
class="py-2 flex items-center justify-between"
>
<div class="min-w-0"> <div class="min-w-0">
<Link :href="route('clientCase.show', c.uuid)" class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium">{{ c.client_ref || c.uuid.slice(0,8) }}</Link> <Link
<p class="text-[11px] text-gray-400 dark:text-gray-500">Staro: {{ c.days_stale }} dni</p> :href="route('clientCase.show', c.uuid)"
class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium"
>{{ c.client_ref || c.uuid.slice(0, 8) }}</Link
>
<p class="text-[11px] text-gray-400 dark:text-gray-500">
Brez aktivnosti: {{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
</p>
</div> </div>
<span class="text-[10px] px-2 py-0.5 rounded bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-300">Stale</span> <span
class="text-[10px] px-2 py-0.5 rounded bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-300"
>Stale</span
>
</li>
<li
v-if="!staleCases.length"
class="py-4 text-xs text-gray-500 text-center"
>
Ni starih primerov.
</li> </li>
<li v-if="!staleCases.length" class="py-4 text-xs text-gray-500 text-center">Ni starih primerov.</li>
</ul> </ul>
<div v-else class="space-y-2 animate-pulse"> <div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" /> <div
v-for="n in 5"
:key="n"
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
/>
</div> </div>
</div> </div>
<!-- Field Jobs Assigned Today --> <!-- Field Jobs Assigned Today -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Današnje dodelitve terenskih</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
<ul v-if="fieldJobsAssignedToday" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"> >
<li v-for="f in fieldJobsAssignedToday" :key="f.id" class="py-2 flex items-center justify-between"> <h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
Današnje dodelitve terenskih
</h3>
<ul
v-if="fieldJobsAssignedToday"
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
>
<li
v-for="f in fieldJobsAssignedToday"
:key="f.id"
class="py-2 flex items-center justify-between"
>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-gray-700 dark:text-gray-300 text-sm">#{{ f.id }}</p> <p class="text-gray-700 dark:text-gray-300 text-sm">#{{ f.id }}</p>
<p class="text-[11px] text-gray-400 dark:text-gray-500">{{ (f.assigned_at || f.created_at) ? new Date(f.assigned_at || f.created_at).toLocaleTimeString() : '' }}</p> <p class="text-[11px] text-gray-400 dark:text-gray-500">
{{
f.assigned_at || f.created_at
? new Date(f.assigned_at || f.created_at).toLocaleTimeString()
: ""
}}
</p>
</div> </div>
<span v-if="f.priority" class="text-[10px] px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-900/30 text-rose-600 dark:text-rose-300">Prioriteta</span> <span
v-if="f.priority"
class="text-[10px] px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-900/30 text-rose-600 dark:text-rose-300"
>Prioriteta</span
>
</li>
<li
v-if="!fieldJobsAssignedToday.length"
class="py-4 text-xs text-gray-500 text-center"
>
Ni dodelitev.
</li> </li>
<li v-if="!fieldJobsAssignedToday.length" class="py-4 text-xs text-gray-500 text-center">Ni dodelitev.</li>
</ul> </ul>
<div v-else class="space-y-2 animate-pulse"> <div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" /> <div
v-for="n in 5"
:key="n"
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
/>
</div> </div>
</div> </div>
<!-- Imports In Progress --> <!-- Imports In Progress -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Uvozi v teku</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
<ul v-if="importsInProgress" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"> >
<h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
Uvozi v teku
</h3>
<ul
v-if="importsInProgress"
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
>
<li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1"> <li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ im.file_name }}</p> <p class="font-medium text-gray-700 dark:text-gray-300 truncate">
<span class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300">{{ im.status }}</span> {{ im.file_name }}
</p>
<span
class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300"
>{{ im.status }}</span
>
</div> </div>
<div class="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded overflow-hidden"> <div
<div class="h-full bg-indigo-500 dark:bg-indigo-400" :style="{ width: (im.progress_pct || 0) + '%' }"></div> class="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded overflow-hidden"
>
<div
class="h-full bg-indigo-500 dark:bg-indigo-400"
:style="{ width: (im.progress_pct || 0) + '%' }"
></div>
</div> </div>
<p class="text-[10px] text-gray-400 dark:text-gray-500">{{ im.imported_rows }}/{{ im.total_rows }} (veljavnih: {{ im.valid_rows }}, neveljavnih: {{ im.invalid_rows }})</p> <p class="text-[10px] text-gray-400 dark:text-gray-500">
{{ im.imported_rows }}/{{ im.total_rows }} (veljavnih:
{{ im.valid_rows }}, neveljavnih: {{ im.invalid_rows }})
</p>
</li>
<li
v-if="!importsInProgress.length"
class="py-4 text-xs text-gray-500 text-center"
>
Ni aktivnih uvozov.
</li> </li>
<li v-if="!importsInProgress.length" class="py-4 text-xs text-gray-500 text-center">Ni aktivnih uvozov.</li>
</ul> </ul>
<div v-else class="space-y-2 animate-pulse"> <div v-else class="space-y-2 animate-pulse">
<div v-for="n in 4" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" /> <div
v-for="n in 4"
:key="n"
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
/>
</div> </div>
</div> </div>
<!-- Active Document Templates --> <!-- Active Document Templates -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"> <div
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Aktivne predloge dokumentov</h3> class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
<ul v-if="activeTemplates" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"> >
<li v-for="t in activeTemplates" :key="t.id" class="py-2 flex items-center justify-between"> <h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4"
>
Aktivne predloge dokumentov
</h3>
<ul
v-if="activeTemplates"
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
>
<li
v-for="t in activeTemplates"
:key="t.id"
class="py-2 flex items-center justify-between"
>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-gray-700 dark:text-gray-300 font-medium truncate">{{ t.name }}</p> <p class="text-gray-700 dark:text-gray-300 font-medium truncate">
<p class="text-[11px] text-gray-400 dark:text-gray-500">v{{ t.version }} · {{ new Date(t.updated_at).toLocaleDateString() }}</p> {{ t.name }}
</p>
<p class="text-[11px] text-gray-400 dark:text-gray-500">
v{{ t.version }} · {{ new Date(t.updated_at).toLocaleDateString() }}
</p>
</div> </div>
<Link :href="route('admin.document-templates.edit', t.id)" class="text-[10px] px-2 py-0.5 rounded bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60">Uredi</Link> <Link
:href="route('admin.document-templates.edit', t.id)"
class="text-[10px] px-2 py-0.5 rounded bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60"
>Uredi</Link
>
</li>
<li
v-if="!activeTemplates.length"
class="py-4 text-xs text-gray-500 text-center"
>
Ni aktivnih predlog.
</li> </li>
<li v-if="!activeTemplates.length" class="py-4 text-xs text-gray-500 text-center">Ni aktivnih predlog.</li>
</ul> </ul>
<div v-else class="space-y-2 animate-pulse"> <div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" /> <div
v-for="n in 5"
:key="n"
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
/>
</div> </div>
</div> </div>

View File

@ -62,6 +62,15 @@
Route::get('document-templates/{template}/edit', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'edit'])->name('document-templates.edit'); Route::get('document-templates/{template}/edit', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'edit'])->name('document-templates.edit');
Route::get('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'edit'])->name('document-settings.index'); Route::get('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'edit'])->name('document-settings.index');
Route::put('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'update'])->name('document-settings.update'); Route::put('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'update'])->name('document-settings.update');
// Mail profiles (dynamic outgoing mail configuration)
Route::get('mail-profiles', [\App\Http\Controllers\Admin\MailProfileController::class, 'index'])->name('mail-profiles.index');
Route::get('mail-profiles.json', function() { return \App\Models\MailProfile::query()->get(); })->name('mail-profiles.json');
Route::post('mail-profiles', [\App\Http\Controllers\Admin\MailProfileController::class, 'store'])->name('mail-profiles.store');
Route::put('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'update'])->name('mail-profiles.update');
Route::post('mail-profiles/{mailProfile}/toggle', [\App\Http\Controllers\Admin\MailProfileController::class, 'toggle'])->name('mail-profiles.toggle');
Route::post('mail-profiles/{mailProfile}/test', [\App\Http\Controllers\Admin\MailProfileController::class, 'test'])->name('mail-profiles.test');
Route::delete('mail-profiles/{mailProfile}', [\App\Http\Controllers\Admin\MailProfileController::class, 'destroy'])->name('mail-profiles.destroy');
}); });
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service // Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service

View File

@ -0,0 +1,40 @@
<?php
use App\Jobs\TestMailProfileConnection;
use App\Models\MailProfile;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
function adminUserSecurity(): User {
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
Permission::firstOrCreate(['slug' => 'manage-settings'], ['name' => 'Manage Settings']);
$user->roles()->syncWithoutDetaching([$role->id]);
return $user;
}
it('does not leak encrypted_password in json endpoint', function () {
$user = adminUserSecurity();
test()->actingAs($user);
$profile = MailProfile::factory()->create(['name' => 'SecureProfile']);
$resp = test()->get(route('admin.mail-profiles.json'));
$resp->assertSuccessful();
$resp->assertJsonMissingPath('0.encrypted_password');
$resp->assertJsonFragment(['name' => 'SecureProfile']);
});
it('queues test connection job and updates queued status', function () {
Queue::fake();
$user = adminUserSecurity();
test()->actingAs($user);
$profile = MailProfile::factory()->create(['test_status' => null]);
$resp = test()->post(route('admin.mail-profiles.test', $profile));
$resp->assertRedirect();
$profile->refresh();
expect($profile->test_status)->toBe('queued');
Queue::assertPushed(TestMailProfileConnection::class, function ($job) use ($profile) {
return $job->mailProfileId === $profile->id;
});
});

View File

@ -0,0 +1,90 @@
<?php
use App\Models\MailProfile;
use App\Models\User;
use App\Models\Role;
use App\Models\Permission;
function adminUser(): User {
$user = User::factory()->create();
// Ensure admin role & manage-settings permission exist
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$permission = Permission::firstOrCreate(['slug' => 'manage-settings'], ['name' => 'Manage Settings']);
$user->roles()->syncWithoutDetaching([$role->id]);
// assign permission directly (mirrors other admin tests style)
if (method_exists($user, 'givePermissionTo')) {
$user->givePermissionTo('manage-settings');
}
return $user;
}
it('creates a mail profile and encrypts password', function () {
$user = adminUser();
test()->actingAs($user);
$resp = test()->post(route('admin.mail-profiles.store'), [
'name' => 'Primary',
'host' => 'smtp.example.test',
'port' => 587,
'encryption' => 'tls',
'username' => 'user@example.test',
'password' => 'super-secret',
'from_address' => 'noreply@example.test',
'from_name' => 'App',
]);
$resp->assertRedirect();
$profile = MailProfile::first();
expect($profile)->not->toBeNull();
// encrypted_password should not equal raw
expect($profile->getAttribute('encrypted_password'))->not->toBe('super-secret');
// roundtrip decrypt
expect($profile->decryptPassword())->toBe('super-secret');
});
it('updates without overriding password if omitted', function () {
$user = adminUser();
test()->actingAs($user);
$profile = MailProfile::factory()->create();
$originalCipher = $profile->getAttribute('encrypted_password');
$resp = test()->put(route('admin.mail-profiles.update', $profile), [
'name' => 'Renamed',
]);
$resp->assertRedirect();
$profile->refresh();
expect($profile->name)->toBe('Renamed');
expect($profile->getAttribute('encrypted_password'))->toBe($originalCipher);
});
it('updates password if provided', function () {
$user = adminUser();
test()->actingAs($user);
$profile = MailProfile::factory()->create();
$originalDec = $profile->decryptPassword();
$resp = test()->put(route('admin.mail-profiles.update', $profile), [
'password' => 'new-pass-123',
]);
$resp->assertRedirect();
$profile->refresh();
expect($profile->decryptPassword())->toBe('new-pass-123');
expect($profile->decryptPassword())->not->toBe($originalDec);
});
it('toggles active', function () {
$user = adminUser();
test()->actingAs($user);
$profile = MailProfile::factory()->create(['active' => false]);
test()->post(route('admin.mail-profiles.toggle', $profile))->assertRedirect();
$profile->refresh();
expect($profile->active)->toBeTrue();
});
it('deletes a profile', function () {
$user = adminUser();
test()->actingAs($user);
$profile = MailProfile::factory()->create();
test()->delete(route('admin.mail-profiles.destroy', $profile))->assertRedirect();
expect(MailProfile::find($profile->id))->toBeNull();
});

View File

@ -3,9 +3,13 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\Concerns\InteractsWithAuthentication;
use Illuminate\Foundation\Testing\Concerns\MakesHttpRequests;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
use CreatesApplication; use CreatesApplication;
use \Illuminate\Foundation\Testing\RefreshDatabase; use \Illuminate\Foundation\Testing\RefreshDatabase;
use InteractsWithAuthentication;
use MakesHttpRequests;
} }