First commit

This commit is contained in:
dyzulk
2025-12-30 12:11:01 +07:00
commit f68f34980a
150 changed files with 22717 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\LegalPage;
use App\Models\LegalPageRevision;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class LegalPageController extends Controller
{
public function index()
{
$pages = LegalPage::with(['latestRevision' => function ($query) {
$query->orderBy('major', 'desc')
->orderBy('minor', 'desc')
->orderBy('patch', 'desc');
}])->get();
return response()->json(['data' => $pages]);
}
public function show($id)
{
$legalPage = LegalPage::findOrFail($id);
// Manual load latest revision
$latestRevision = $legalPage->revisions()
->orderBy('major', 'desc')
->orderBy('minor', 'desc')
->orderBy('patch', 'desc')
->first();
$legalPage->setRelation('latestRevision', $latestRevision);
return response()->json(['data' => $legalPage]);
}
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'required|in:draft,published',
]);
$slug = Str::slug($request->title);
// Check if page exists
$page = LegalPage::where('slug', $slug)->first();
if ($page) {
// Smart Versioning: If exists, increment Major version automatically for "Create" flow
$maxMajor = $page->revisions()->max('major') ?? 0;
$major = $maxMajor + 1;
// Auto-Archive Logic: If new version is published, archive others
if ($request->status === 'published') {
$page->revisions()->where('status', 'published')->update(['status' => 'archived']);
}
$page->revisions()->create([
'content' => $request->content,
'major' => $major,
'minor' => 0,
'patch' => 0,
'status' => $request->status,
'published_at' => $request->status === 'published' ? now() : null,
'change_log' => 'Created via New Page (Auto-increment Major)',
'is_active' => true,
'created_by' => auth()->id(),
]);
return response()->json(['data' => $page, 'message' => 'New major version created for existing Legal Page'], 201);
} else {
// Create New
$page = LegalPage::create([
'title' => $request->title,
'slug' => $slug,
'is_active' => true,
]);
// Initial create is always 1.0.0
$page->revisions()->create([
'content' => $request->content,
'major' => 1,
'minor' => 0,
'patch' => 0,
'status' => $request->status,
'published_at' => $request->status === 'published' ? now() : null,
'change_log' => 'Initial creation',
'is_active' => true,
'created_by' => auth()->id(),
]);
return response()->json(['data' => $page, 'message' => 'Legal page created successfully'], 201);
}
}
public function update(Request $request, LegalPage $legalPage)
{
$request->validate([
'title' => 'string|max:255',
'content' => 'required|string',
'version_type' => 'required|in:major,minor,patch', // 'major', 'minor', 'patch'
'parent_major' => 'nullable|integer',
'parent_minor' => 'nullable|integer',
'status' => 'required|in:draft,published',
'change_log' => 'nullable|string',
]);
if ($request->has('title')) {
$legalPage->update(['title' => $request->title]);
}
// Calculate Version
$major = 0; $minor = 0; $patch = 0;
if ($request->version_type === 'major') {
$maxMajor = $legalPage->revisions()->max('major') ?? 0;
$major = $maxMajor + 1;
$minor = 0;
$patch = 0;
} elseif ($request->version_type === 'minor') {
if (!$request->parent_major) return response()->json(['message' => 'Parent Major required for Minor version'], 422);
$maxMinor = $legalPage->revisions()
->where('major', $request->parent_major)
->max('minor') ?? -1;
$major = $request->parent_major;
$minor = $maxMinor + 1;
$patch = 0;
} elseif ($request->version_type === 'patch') {
if (!$request->parent_major || is_null($request->parent_minor)) return response()->json(['message' => 'Parent Major and Minor required for Patch'], 422);
$maxPatch = $legalPage->revisions()
->where('major', $request->parent_major)
->where('minor', $request->parent_minor)
->max('patch') ?? -1;
$major = $request->parent_major;
$minor = $request->parent_minor;
$patch = $maxPatch + 1;
}
// Auto-Archive Logic: If new version is published, archive others
if ($request->status === 'published') {
$legalPage->revisions()->where('status', 'published')->update(['status' => 'archived']);
}
$legalPage->revisions()->create([
'content' => $request->content,
'major' => $major,
'minor' => $minor,
'patch' => $patch,
'status' => $request->status,
'published_at' => $request->status === 'published' ? now() : null,
'change_log' => $request->change_log ?? 'Updated content',
'is_active' => true,
'created_by' => auth()->id(),
]);
return response()->json(['data' => $legalPage, 'message' => 'Legal page updated with new revision']);
}
public function getHistory($id) {
$legalPage = LegalPage::findOrFail($id);
$revisions = $legalPage->revisions()
->orderBy('major', 'desc')
->orderBy('minor', 'desc')
->orderBy('patch', 'desc')
->get();
return response()->json(['data' => $revisions]);
}
public function destroy(LegalPage $legalPage)
{
$legalPage->delete();
return response()->json(['message' => 'Legal page deleted']);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ApiKey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ApiKeyController extends Controller
{
/**
* Display a listing of personal access tokens.
*/
public function index(Request $request)
{
return response()->json([
'data' => $request->user()->apiKeys()->orderBy('created_at', 'desc')->get()
]);
}
/**
* Create a new personal access token.
*/
public function store(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
]);
$key = ApiKey::generate();
$apiKey = $request->user()->apiKeys()->create([
'name' => $request->name,
'key' => $key,
'is_active' => true,
]);
return response()->json([
'message' => 'API Key created successfully',
'token' => $key,
'key' => $apiKey
], 201);
}
/**
* Revoke a personal access token.
*/
public function destroy($id)
{
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
if (!$apiKey) {
return response()->json(['message' => 'API Key not found'], 404);
}
$apiKey->delete();
return response()->json(['message' => 'API Key revoked successfully']);
}
/**
* Toggle the active status of an API Key.
*/
public function toggle($id)
{
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
if (!$apiKey) {
return response()->json(['message' => 'API Key not found'], 404);
}
$apiKey->update([
'is_active' => !$apiKey->is_active
]);
return response()->json([
'message' => 'API Key status updated successfully',
'is_active' => $apiKey->is_active
]);
}
/**
* Regenerate the contents of an API Key.
*/
public function regenerate($id)
{
$apiKey = Auth::user()->apiKeys()->where('id', $id)->first();
if (!$apiKey) {
return response()->json(['message' => 'API Key not found'], 404);
}
$newKey = ApiKey::generate();
$apiKey->update([
'key' => $newKey,
'last_used_at' => null,
]);
return response()->json([
'message' => 'API Key regenerated successfully',
'token' => $newKey
]);
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Certificate;
use App\Models\CaCertificate;
use App\Services\OpenSslService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Str;
use App\Traits\LogsActivity;
use App\Notifications\CertificateNotification;
class CertificateApiController extends Controller
{
use LogsActivity;
protected $sslService;
public function __construct(OpenSslService $sslService)
{
$this->sslService = $sslService;
}
/**
* List user certificates.
*/
public function index(Request $request)
{
$perPage = $request->input('per_page', 10);
$search = $request->input('search');
$query = Certificate::where('user_id', Auth::id());
if ($search) {
$query->where(function($q) use ($search) {
$q->where('common_name', 'like', "%{$search}%")
->orWhere('serial_number', 'like', "%{$search}%")
->orWhere('san', 'like', "%{$search}%");
});
}
$certificates = $query->latest()->paginate($perPage);
return response()->json([
'status' => 'success',
'data' => $certificates,
'ca_status' => $this->getCaStatus()
]);
}
/**
* Generate a new certificate.
*/
public function store(Request $request)
{
$validated = $request->validate([
'common_name' => 'required|string|max:255',
'config_mode' => 'required|in:default,manual',
'organization' => 'nullable|required_if:config_mode,manual|string|max:255',
'locality' => 'nullable|required_if:config_mode,manual|string|max:255',
'state' => 'nullable|required_if:config_mode,manual|string|max:255',
'country' => 'nullable|required_if:config_mode,manual|string|size:2',
'san' => 'nullable|string',
'key_bits' => 'required|in:2048,4096',
'is_test_short_lived' => 'nullable|boolean',
]);
if (!empty($validated['is_test_short_lived']) && !Auth::user()->isAdminOrOwner()) {
return response()->json(['status' => 'error', 'message' => 'Unauthorized for test mode'], 403);
}
try {
if ($validated['config_mode'] === 'default') {
$defaults = Config::get('openssl.ca_leaf_default');
$validated['organization'] = $defaults['organizationName'];
$validated['locality'] = $defaults['localityName'];
$validated['state'] = $defaults['stateOrProvinceName'];
$validated['country'] = $defaults['countryName'];
}
$result = $this->sslService->generateLeaf($validated);
$certificate = Certificate::create([
'user_id' => Auth::id(),
'common_name' => $validated['common_name'],
'organization' => $validated['organization'],
'locality' => $validated['locality'],
'state' => $validated['state'],
'country' => $validated['country'],
'san' => $validated['san'],
'key_bits' => $validated['key_bits'],
'serial_number' => $result['serial'],
'cert_content' => $result['cert'],
'key_content' => $result['key'],
'csr_content' => $result['csr'],
'valid_from' => $result['valid_from'],
'valid_to' => $result['valid_to'],
]);
$this->logActivity('issue_cert', "Issued certificate for {$certificate->common_name}");
// Notify User
try {
Auth::user()->notify(new CertificateNotification($certificate, 'issued'));
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Failed to send certificate notification: ' . $e->getMessage());
}
return response()->json([
'status' => 'success',
'message' => 'Certificate generated successfully',
'data' => $certificate
], 201);
} catch (\Throwable $e) {
\Log::error('Certificate generation failed: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString()
]);
return response()->json([
'status' => 'error',
'message' => 'Failed to generate certificate: ' . $e->getMessage()
], 500);
}
}
/**
* Show certificate details.
*/
public function show(Certificate $certificate)
{
$this->authorizeOwner($certificate);
return response()->json([
'status' => 'success',
'data' => $certificate
]);
}
/**
* Delete a certificate.
*/
public function destroy(Certificate $certificate)
{
$this->authorizeOwner($certificate);
$commonName = $certificate->common_name;
$certificate->delete();
$this->logActivity('delete_cert', "Deleted certificate for {$commonName}");
// Notify User
try {
Auth::user()->notify(new CertificateNotification($certificate, 'revoked'));
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Failed to send certificate revocation notification: ' . $e->getMessage());
}
return response()->json([
'status' => 'success',
'message' => 'Certificate deleted successfully'
]);
}
/**
* Initialize CA (Admin only).
*/
public function setupCa()
{
if (!Auth::user()->isAdminOrOwner()) {
return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
}
// Allow setup if any of the required CA types are missing
$status = $this->getCaStatus();
if ($status['is_ready']) {
return response()->json(['status' => 'error', 'message' => 'CA already fully initialized'], 400);
}
if ($this->sslService->setupCa()) {
return response()->json(['status' => 'success', 'message' => 'CA successfully initialized']);
}
return response()->json(['status' => 'error', 'message' => 'Failed to initialize CA'], 500);
}
/**
* Download certificate files.
*/
public function downloadFile(Certificate $certificate, $type)
{
$this->authorizeOwner($certificate);
$content = match($type) {
'cert' => $certificate->cert_content,
'key' => $certificate->key_content,
'csr' => $certificate->csr_content,
default => abort(404)
};
$extension = match($type) {
'cert' => 'crt',
'key' => 'key',
'csr' => 'csr',
};
$filename = Str::slug($certificate->common_name) . '.' . $extension;
return response($content)
->header('Content-Type', 'text/plain')
->header('Content-Disposition', "attachment; filename={$filename}");
}
protected function getCaStatus()
{
$root = CaCertificate::where('ca_type', 'root')->exists();
$int2048 = CaCertificate::where('ca_type', 'intermediate_2048')->exists();
$int4096 = CaCertificate::where('ca_type', 'intermediate_4096')->exists();
return [
'root' => $root,
'intermediate_2048' => $int2048,
'intermediate_4096' => $int4096,
'is_ready' => $root && $int2048 && $int4096,
'missing' => array_keys(array_filter([
'root' => !$root,
'intermediate_2048' => !$int2048,
'intermediate_4096' => !$int4096,
]))
];
}
protected function authorizeOwner(Certificate $certificate)
{
if ($certificate->user_id !== Auth::id()) {
abort(403);
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Certificate;
use App\Models\Ticket;
use App\Models\Inquiry;
use App\Models\ActivityLog;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
// Helper to calculate percentage change
$getTrend = function($current, $previous) {
if ($previous == 0) return $current > 0 ? 100 : 0;
return round((($current - $previous) / $previous) * 100, 1);
};
// Basic Stats
$currentMonth = now()->startOfMonth();
$previousMonth = now()->subMonth()->startOfMonth();
// Certificates (Scoped to User)
$totalCertificates = Certificate::where('user_id', $user->id)->count();
$prevCertificates = Certificate::where('user_id', $user->id)->where('created_at', '<', $currentMonth)->count();
// Active Certificates (Scoped to User)
$activeCertificates = Certificate::where('user_id', $user->id)->where('status', 'ISSUED')->where('valid_to', '>', now())->count();
$prevActiveCertificates = Certificate::where('user_id', $user->id)->where('status', 'ISSUED')->where('valid_to', '>', now()->subMonth())->where('created_at', '<', $currentMonth)->count();
// Expired (Scoped to User)
$expiredCertificates = Certificate::where('user_id', $user->id)->where('valid_to', '<', now())->count();
// Tickets (Role Based)
$ticketQuery = Ticket::query()->whereIn('status', ['open', 'answered']);
if (!$user->isAdmin()) {
$ticketQuery->where('user_id', $user->id);
}
$activeTickets = $ticketQuery->count();
// Previous Tickets (Role Based)
$prevTicketQuery = Ticket::query()->whereIn('status', ['open', 'answered'])->where('created_at', '<', $currentMonth);
if (!$user->isAdmin()) {
$prevTicketQuery->where('user_id', $user->id);
}
$prevActiveTickets = $prevTicketQuery->count();
$stats = [
'total_certificates' => [
'value' => $totalCertificates,
'trend' => $getTrend($totalCertificates, $prevCertificates),
'trend_label' => 'vs last month'
],
'active_certificates' => [
'value' => $activeCertificates,
'trend' => $getTrend($activeCertificates, $prevActiveCertificates),
'trend_label' => 'vs last month'
],
'expired_certificates' => [
'value' => $expiredCertificates,
'trend' => 0,
'trend_label' => 'vs last month'
],
'active_tickets' => [
'value' => $activeTickets,
'trend' => $getTrend($activeTickets, $prevActiveTickets),
'trend_label' => 'vs last month'
],
];
// Admin only stats
if ($user->isAdmin()) {
$totalUsers = User::count();
$prevUsers = User::where('created_at', '<', $currentMonth)->count();
$stats['total_users'] = [
'value' => $totalUsers,
'trend' => $getTrend($totalUsers, $prevUsers),
'trend_label' => 'vs last month'
];
// Inquiries - trend calculation for "Pending" is hard, so we just wrap value to keep consistent structure
$stats['pending_inquiries'] = [
'value' => Inquiry::where('status', 'unread')->count(),
];
// CA Certificate Downloads
$caDownloads = \App\Models\CaCertificate::select('ca_type', 'download_count')->get();
foreach ($caDownloads as $ca) {
$stats['ca_downloads_' . $ca->ca_type] = [
'value' => $ca->download_count ?? 0,
'label' => str_replace('_', ' ', strtoupper($ca->ca_type)) . ' Downloads'
];
}
$stats['recent_users'] = User::latest()->take(5)->get(['id', 'first_name', 'last_name', 'email', 'created_at']);
}
// Recent Activity
$activityLogQuery = ActivityLog::with('user:id,first_name,last_name,email,avatar')
->latest()
->take(10);
if (!$user->isAdmin()) {
$activityLogQuery->where('user_id', $user->id);
}
$recentActivity = $activityLogQuery->get()->map(function($log) {
return [
'id' => $log->id,
'user_name' => $log->user ? $log->user->first_name . ' ' . $log->user->last_name : 'System',
'user_avatar' => $log->user ? $log->user->avatar : null,
'action' => $log->action,
'description' => $log->description,
'created_at' => $log->created_at->toIso8601String(),
];
});
// Chart Data (Certificate Issuance Trend - Last 7 Days)
$chartData = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i)->format('Y-m-d');
$countQuery = Certificate::whereDate('created_at', $date);
if (!$user->isAdmin()) {
$countQuery->where('user_id', $user->id);
}
$chartData[] = [
'date' => $date,
'day' => now()->subDays($i)->format('D'),
'count' => $countQuery->count()
];
}
return response()->json([
'status' => 'success',
'data' => [
'stats' => $stats,
'recent_activity' => $recentActivity,
'chart_data' => $chartData,
'server_time' => now()->toIso8601String(),
]
]);
}
public function ping()
{
return response()->json([
'pong' => true,
'time' => microtime(true),
]);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Inquiry;
use App\Models\User;
use App\Notifications\NewInquiryNotification;
use App\Mail\InquiryReplyMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Notification;
class InquiryController extends Controller
{
/**
* Store a new inquiry (Public).
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'category' => 'required|string|max:255',
'subject' => 'required|string|max:255',
'message' => 'required|string|max:5000',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$inquiry = Inquiry::create($request->all());
try {
// Notify all admins
$admins = User::where('role', 'admin')->get();
Notification::send($admins, new NewInquiryNotification($inquiry));
} catch (\Exception $e) {
// Log the error but fail silently to the user, as the inquiry was saved.
\Illuminate\Support\Facades\Log::error('Failed to send NewInquiryNotification: ' . $e->getMessage());
}
return response()->json([
'message' => 'Your message has been sent successfully. We will get back to you soon!',
'inquiry' => $inquiry
], 201);
}
/**
* List all inquiries (Admin).
*/
public function index(Request $request)
{
$search = $request->query('search');
$status = $request->query('status');
$query = Inquiry::query();
if ($search) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('subject', 'like', "%{$search}%");
});
}
if ($status) {
$query->where('status', $status);
}
$inquiries = $query->orderBy('created_at', 'desc')->paginate(10);
return response()->json($inquiries);
}
/**
* Show a specific inquiry (Admin).
*/
public function show(Inquiry $inquiry)
{
return response()->json($inquiry);
}
/**
* Delete an inquiry (Admin).
*/
public function destroy(Inquiry $inquiry)
{
$inquiry->delete();
return response()->json(['message' => 'Inquiry deleted successfully.']);
}
/**
* Reply to an inquiry (Admin).
*/
public function reply(Request $request, Inquiry $inquiry)
{
$request->validate([
'message' => 'required|string|max:5000',
]);
try {
// Send email using the support mailer
Mail::mailer('support')->to($inquiry->email)->send(new \App\Mail\InquiryReplyMail($inquiry, $request->message));
$inquiry->update([
'status' => 'replied',
'replied_at' => now(),
]);
return response()->json(['message' => 'Reply sent successfully.']);
} catch (\Exception $e) {
return response()->json(['message' => 'Failed to send reply: ' . $e->getMessage()], 500);
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LegalPage;
use Illuminate\Http\Request;
class LegalPageController extends Controller
{
public function show($slug)
{
$page = LegalPage::where('slug', $slug)->where('is_active', true)->firstOrFail();
// Robustly fetch the latest active revision that is published
$latestRevision = $page->revisions()
->where('is_active', true)
->where('status', 'published')
->orderBy('major', 'desc')
->orderBy('minor', 'desc')
->orderBy('patch', 'desc')
->first();
if (!$latestRevision) {
return response()->json(['message' => 'Content not available'], 404);
}
// Manually attach for response structure if needed, or just build response
return response()->json(['data' => [
'title' => $page->title,
'content' => $latestRevision->content,
'updated_at' => $latestRevision->created_at,
'version' => $latestRevision->version,
]]);
}
public function index()
{
$pages = LegalPage::where('is_active', true)->select('title', 'slug')->get();
return response()->json(['data' => $pages]);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\TestMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Config;
class MailController extends Controller
{
/**
* Send a test email.
*/
public function sendTestEmail(Request $request)
{
$request->validate([
'email' => 'required|email',
'mailer' => 'required|string|in:smtp,support',
]);
$mailer = $request->mailer;
$recipient = $request->email;
$host = Config::get("mail.mailers.{$mailer}.host");
try {
Mail::mailer($mailer)->to($recipient)->send(new TestMail($mailer, $host));
return response()->json([
'success' => true,
'message' => "Test email successfully sent via {$mailer} mailer.",
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => "Failed to send email: " . $e->getMessage(),
], 500);
}
}
/**
* Get current mailer configurations (excluding passwords).
*/
public function getConfigurations()
{
$configs = [
'smtp' => [
'host' => config('mail.mailers.smtp.host'),
'port' => config('mail.mailers.smtp.port'),
'encryption' => config('mail.mailers.smtp.encryption'),
'from' => config('mail.from.address'),
],
'support' => [
'host' => config('mail.mailers.support.host'),
'port' => config('mail.mailers.support.port'),
'encryption' => config('mail.mailers.support.encryption'),
'from' => config('mail.mailers.support.from.address'),
],
];
return response()->json($configs);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class NotificationController extends Controller
{
/**
* Display a listing of notifications.
*/
public function index(Request $request)
{
$user = $request->user();
$query = $user->notifications();
// Filter by state
if ($request->has('filter')) {
if ($request->filter === 'unread') {
$query = $user->unreadNotifications();
} elseif ($request->filter === 'read') {
$query = $user->readNotifications();
}
}
// Search in data (JSON)
if ($request->has('search')) {
$search = $request->search;
$query->where('data', 'like', "%{$search}%");
}
$notifications = $query->latest()->paginate(10);
return response()->json($notifications);
}
/**
* Mark a specific notification as read.
*/
public function markAsRead(Request $request, $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
return response()->json(['message' => 'Notification marked as read']);
}
/**
* Mark all notifications as read.
*/
public function markAllAsRead(Request $request)
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['message' => 'All notifications marked as read']);
}
/**
* Remove the specified notification.
*/
public function destroy(Request $request, $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->delete();
return response()->json(['message' => 'Notification deleted']);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Carbon\Carbon;
class PasswordResetController extends Controller
{
/**
* Send a reset link to the given user.
*/
public function sendResetLinkEmail(Request $request)
{
$request->validate(['email' => 'required|email']);
$user = User::where('email', $request->email)->first();
if (!$user) {
// We return a "success" message anyway to prevent email enumeration
return response()->json(['message' => 'Jika email tersebut terdaftar, kami akan mengirimkan link reset password.']);
}
// Generate a token
$token = Str::random(64);
// Store token in password_reset_tokens table
DB::table('password_reset_tokens')->updateOrInsert(
['email' => $request->email],
[
'token' => Hash::make($token),
'created_at' => Carbon::now()
]
);
// Send Email
$resetUrl = config('app.frontend_url') . '/reset-password?token=' . $token . '&email=' . urlencode($request->email);
Mail::send('emails.password-reset', ['url' => $resetUrl, 'name' => $user->first_name], function ($message) use ($request) {
$message->to($request->email);
$message->subject('Reset Password - TrustLab');
});
return response()->json(['message' => 'Reset link sent to your email.']);
}
/**
* Reset the given user's password.
*/
public function resetPassword(Request $request)
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => 'required|min:8|confirmed',
]);
$reset = DB::table('password_reset_tokens')
->where('email', $request->email)
->first();
if (!$reset || !Hash::check($request->token, $reset->token)) {
return response()->json(['message' => 'Invalid or expired token.'], 400);
}
// Check expiry (e.g., 60 minutes)
if (Carbon::parse($reset->created_at)->addMinutes(60)->isPast()) {
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
return response()->json(['message' => 'Token has expired.'], 400);
}
$user = User::where('email', $request->email)->first();
if (!$user) {
return response()->json(['message' => 'User not found.'], 404);
}
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
// Delete token
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
return response()->json(['message' => 'Password reset successful.']);
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Support\Str;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
class ProfileController extends Controller
{
/**
* Update the user's profile information.
*/
public function update(Request $request)
{
$user = $request->user();
$validated = $request->validate([
'first_name' => 'nullable|string|max:255',
'last_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255|unique:users,email,' . $user->id,
'phone' => 'nullable|string|max:20',
'bio' => 'nullable|string',
'job_title' => 'nullable|string|max:255',
'location' => 'nullable|string|max:255',
'country' => 'nullable|string|max:255',
'city_state' => 'nullable|string|max:255',
'postal_code' => 'nullable|string|max:20',
'tax_id' => 'nullable|string|max:50',
'facebook' => 'nullable|string|max:255',
'twitter' => 'nullable|string|max:255',
'linkedin' => 'nullable|string|max:255',
'instagram' => 'nullable|string|max:255',
'settings_email_alerts' => 'sometimes|boolean',
'settings_certificate_renewal' => 'sometimes|boolean',
'default_landing_page' => 'sometimes|string|max:255',
'theme' => 'sometimes|string|in:light,dark,system',
'language' => 'sometimes|string|max:10',
]);
// Handle Email Change Logic
if (isset($validated['email']) && $validated['email'] !== $user->email) {
$pendingEmail = $validated['email'];
// Basic check to avoid duplication with other pending_emails if necessary,
// but unique:users,email already covers the main one.
$user->pending_email = $pendingEmail;
$user->save();
// Send notification to the NEW email
$user->notify(new \App\Notifications\PendingEmailVerificationNotification);
// Remove email from validated so it doesn't update the primary email yet
unset($validated['email']);
}
// Sanitize social links to store only usernames
if (isset($validated['facebook'])) $validated['facebook'] = $this->extractUsername($validated['facebook'], 'facebook.com');
if (isset($validated['twitter'])) $validated['twitter'] = $this->extractUsername($validated['twitter'], ['twitter.com', 'x.com']);
if (isset($validated['linkedin'])) $validated['linkedin'] = $this->extractUsername($validated['linkedin'], 'linkedin.com/in');
if (isset($validated['instagram'])) $validated['instagram'] = $this->extractUsername($validated['instagram'], 'instagram.com');
// Log the update attempt for debugging
\Illuminate\Support\Facades\Log::info('Profile Update Request:', ['user_id' => $user->id, 'payload' => $validated]);
$user->update($validated);
return response()->json([
'message' => 'Profile updated successfully.',
'user' => $user,
]);
}
/**
* Update the user's password.
*/
public function updatePassword(Request $request)
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return response()->json([
'message' => 'Password updated successfully.',
]);
}
/**
* Update the user's avatar.
*/
public function updateAvatar(Request $request)
{
$request->validate([
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:5120',
]);
$user = $request->user();
$file = $request->file('avatar');
$extension = $file->getClientOriginalExtension();
// Requirement 1: URL avatar yang sedang digunakan adalah clean uuid
// Output: avatars/{user-uuid}.{ext}
$newFilename = "{$user->id}.{$extension}";
$newPath = "avatars/{$newFilename}";
// Requirement 2: Jika ganti, pindahkan lama ke trash/{user-uuid}-{trash-random}-{original-filename}
if ($user->avatar) {
$oldPath = $this->getRelativePath($user->avatar);
if ($oldPath) {
// If it's on R2, move it to trash
if (Storage::disk('r2')->exists($oldPath)) {
$trashRandom = Str::random(10);
$oldBasename = basename($oldPath);
$trashPath = "trash/{$user->id}-{$trashRandom}-{$oldBasename}";
// S3/R2 copy + delete is more reliable than move in some environments
Storage::disk('r2')->copy($oldPath, $trashPath);
Storage::disk('r2')->delete($oldPath);
}
// If it's still on local storage (migration case), just delete it
elseif (Storage::disk('public')->exists($oldPath)) {
Storage::disk('public')->delete($oldPath);
}
}
}
// Upload to R2
// Upload to R2 with Cache-Control to prevent long caching
$path = $file->storeAs('avatars', $newFilename, [
'disk' => 'r2',
'CacheControl' => 'no-cache, no-store, max-age=0, must-revalidate',
]);
$url = Storage::disk('r2')->url($path);
$user->update(['avatar' => $url]);
return response()->json([
'message' => 'Avatar updated successfully.',
'avatar_url' => $url,
]);
}
/**
* Helper to extract relative path from full URL
*/
private function getRelativePath($url)
{
if (!$url) return null;
$baseUrl = config('filesystems.disks.r2.url');
// Strip query string if any before processing
$urlWithoutQuery = explode('?', $url)[0];
if (str_starts_with($urlWithoutQuery, $baseUrl)) {
return ltrim(str_replace($baseUrl, '', $urlWithoutQuery), '/');
}
// Handle legacy local storage URLs if any
if (str_contains($urlWithoutQuery, '/storage/')) {
return 'avatars/' . basename($urlWithoutQuery);
}
return $urlWithoutQuery;
}
/**
* Get Login History (Last 1 month, max 10)
*/
public function getLoginHistory(Request $request)
{
$history = $request->user()->loginHistories()
->where('created_at', '>=', now()->subMonth())
->latest()
->limit(10)
->get();
return response()->json($history);
}
/**
* Delete user account
*/
public function deleteAccount(Request $request)
{
$user = $request->user();
// Optional: Perform any cleanup here (delete avatar, certificates, etc.)
if ($user->avatar) {
$oldPath = $this->getRelativePath($user->avatar);
if ($oldPath && Storage::disk('r2')->exists($oldPath)) {
$trashRandom = Str::random(10);
$oldBasename = basename($oldPath);
$trashPath = "trash/deleted_user_{$user->id}-{$trashRandom}-{$oldBasename}";
Storage::disk('r2')->move($oldPath, $trashPath);
}
}
// Revoke Social Tokens
foreach ($user->socialAccounts as $account) {
// Re-use logic or call external helper? For simplicity/speed, implementing inline revocation or calling AuthController logic if possible.
// Better: Iterate and manually revoke to ensure clean slate.
try {
if ($account->provider === 'google' && $account->token) {
\Illuminate\Support\Facades\Http::post('https://oauth2.googleapis.com/revoke', ['token' => $account->token]);
}
// GitHub revocation is more complex inline without config access handy, but we attempt basic cleanup
} catch (\Exception $e) {
// Continue deletion
}
}
$user->delete();
return response()->json([
'message' => 'Account deleted successfully.',
]);
}
/**
* Get active sessions from database
*/
public function getActiveSessions(Request $request)
{
$sessions = DB::table('sessions')
->where('user_id', $request->user()->id)
->get()
->map(function ($session) use ($request) {
$info = $this->parseUserAgent($session->user_agent);
return [
'id' => $session->id,
'ip_address' => $session->ip_address,
'browser' => $info['browser'],
'os' => $info['os'],
'device_type' => $info['device'],
'last_active' => $session->last_activity,
'is_current' => $session->id === $request->session()->getId(),
];
});
return response()->json($sessions);
}
/**
* Revoke a specific session
*/
public function revokeSession(Request $request, $id)
{
// Don't allow revoking current session via this endpoint for safety
if ($id === $request->session()->getId()) {
return response()->json(['message' => 'Cannot revoke current session.'], 400);
}
DB::table('sessions')
->where('user_id', $request->user()->id)
->where('id', $id)
->delete();
return response()->json(['message' => 'Session revoked successfully.']);
}
/**
* Simple User Agent Parser (Copied from AuthController for consistency)
*/
private function parseUserAgent($agent)
{
$os = 'Unknown OS';
$browser = 'Unknown Browser';
$device = 'Desktop';
if (!$agent) return ['os' => $os, 'browser' => $browser, 'device' => $device];
// OS Parsing
if (preg_match('/iphone|ipad|ipod/i', $agent)) {
$os = 'iOS';
$device = 'iOS';
} elseif (preg_match('/android/i', $agent)) {
$os = 'Android';
$device = 'Android';
} elseif (preg_match('/windows/i', $agent)) {
$os = 'Windows';
$device = 'Windows';
} elseif (preg_match('/macintosh|mac os x/i', $agent)) {
$os = 'Mac';
$device = 'Mac';
} elseif (preg_match('/linux/i', $agent)) {
$os = 'Linux';
$device = 'Linux';
}
// Browser Parsing
if (preg_match('/msie/i', $agent) && !preg_match('/opera/i', $agent)) {
$browser = 'Internet Explorer';
} elseif (preg_match('/firefox/i', $agent)) {
$browser = 'Firefox';
} elseif (preg_match('/chrome/i', $agent)) {
$browser = 'Chrome';
} elseif (preg_match('/safari/i', $agent)) {
$browser = 'Safari';
} elseif (preg_match('/opera/i', $agent)) {
$browser = 'Opera';
}
return [
'os' => $os,
'browser' => $browser,
'device' => $device
];
}
/**
* Helper to extract username from social media URLs.
*/
private function extractUsername(?string $value, $domains): ?string
{
if (!$value) return null;
$value = trim($value);
if (empty($value)) return null;
// If it doesn't look like a URL, assume it's already a username
if (!str_contains($value, '/') && !str_contains($value, '.')) {
return ltrim($value, '@');
}
$domains = (array) $domains;
foreach ($domains as $domain) {
// Clean domain for regex (e.g. linkedin.com/in)
$safeDomain = str_replace('/', '\/', preg_quote($domain));
$pattern = "/(?:https?:\/\/)?(?:www\.)?{$safeDomain}\/([^\/\?#]+)/i";
if (preg_match($pattern, $value, $matches)) {
return $matches[1];
}
}
// If it's a URL but doesn't match the expected domain, just return the last part or the value itself
// to avoid losing data if the user inputs something slightly different
$parts = explode('/', rtrim($value, '/'));
return end($parts);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CaCertificate;
use Illuminate\Http\Request;
class PublicCaController extends Controller
{
/**
* Display a listing of public CA certificates.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$caTypes = ['root', 'intermediate_2048', 'intermediate_4096'];
$certificates = CaCertificate::whereIn('ca_type', $caTypes)
->get(['common_name', 'ca_type', 'serial_number', 'valid_to', 'cert_content'])
->map(function ($cert) {
return [
'name' => $cert->common_name,
'type' => $cert->ca_type,
'serial' => $cert->serial_number,
'expires_at' => $cert->valid_to->toIso8601String(),
];
});
return response()->json([
'success' => true,
'data' => $certificates
]);
}
/**
* Download certificate in various formats.
*/
public function download(Request $request, $serial)
{
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
$cert->increment('download_count');
$cert->update(['last_downloaded_at' => now()]);
$format = $request->query('format', 'pem');
if ($format === 'der') {
// Convert PEM to DER (Base64 decode the body)
$pem = $cert->cert_content;
$lines = explode("\n", trim($pem));
$payload = '';
foreach ($lines as $line) {
if (!str_starts_with($line, '-----')) {
$payload .= trim($line);
}
}
$der = base64_decode($payload);
return response($der)
->header('Content-Type', 'application/x-x509-ca-cert')
->header('Content-Disposition', 'attachment; filename="' . $cert->common_name . '.der"');
}
// Default PEM
return response($cert->cert_content)
->header('Content-Type', 'application/x-pem-file')
->header('Content-Disposition', 'attachment; filename="' . $cert->common_name . '.crt"');
}
/**
* Download Windows One-Click Installer (.bat)
*/
public function downloadWindows($serial)
{
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
$cert->increment('download_count');
$cert->update(['last_downloaded_at' => now()]);
$store = $cert->ca_type === 'root' ? 'Root' : 'CA';
$filename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $cert->common_name);
// Convert CRLF to ensure batch file works
$certContent = str_replace("\n", "\r\n", str_replace("\r\n", "\n", $cert->cert_content));
$script = "@echo off\r\n";
$script .= "echo Installing " . $cert->common_name . "...\r\n";
$script .= "echo Please allow the security prompt to trust this certificate.\r\n";
$script .= "set \"CERT_FILE=%TEMP%\\" . $filename . ".crt\"\r\n";
$script .= "((\r\n";
foreach(explode("\r\n", $certContent) as $line) {
if(!empty($line)) $script .= "echo " . $line . "\r\n";
}
$script .= ")) > \"%CERT_FILE%\"\r\n";
$script .= "certutil -addstore -f \"" . $store . "\" \"%CERT_FILE%\"\r\n";
$script .= "del \"%CERT_FILE%\"\r\n";
$script .= "pause\r\n";
return response($script)
->header('Content-Type', 'application/x-bat')
->header('Content-Disposition', 'attachment; filename="install-' . $filename . '.bat"');
}
/**
* Download macOS Configuration Profile (.mobileconfig)
*/
public function downloadMac($serial)
{
$cert = CaCertificate::where('serial_number', $serial)->firstOrFail();
$cert->increment('download_count');
$cert->update(['last_downloaded_at' => now()]);
// Extract Base64 payload
$pem = $cert->cert_content;
$lines = explode("\n", trim($pem));
$payload = '';
foreach ($lines as $line) {
if (!str_starts_with($line, '-----')) {
$payload .= trim($line);
}
}
$uuid = \Illuminate\Support\Str::uuid();
$identifier = 'com.trustlab.cert.' . $serial;
$name = $cert->common_name;
$xml = '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadCertificateFileName</key>
<string>' . $name . '.cer</string>
<key>PayloadContent</key>
<data>
' . $payload . '
</data>
<key>PayloadDescription</key>
<string>Adds ' . $name . ' to Trusted Root Store</string>
<key>PayloadDisplayName</key>
<string>' . $name . '</string>
<key>PayloadIdentifier</key>
<string>' . $identifier . '.cert</string>
<key>PayloadType</key>
<string>com.apple.security.pkcs1</string>
<key>PayloadUUID</key>
<string>' . \Illuminate\Support\Str::uuid() . '</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>' . $name . ' Installer</string>
<key>PayloadIdentifier</key>
<string>' . $identifier . '</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>' . $uuid . '</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>';
return response($xml)
->header('Content-Type', 'application/x-apple-aspen-config')
->header('Content-Disposition', 'attachment; filename="' . $name . '.mobileconfig"');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CaCertificate;
use App\Services\OpenSslService;
use Illuminate\Http\Request;
class RootCaApiController extends Controller
{
protected $sslService;
public function __construct(OpenSslService $sslService)
{
$this->sslService = $sslService;
}
public function index()
{
$this->authorizeAdmin();
$certificates = CaCertificate::all()->map(function($cert) {
$cert->status = $cert->valid_to->isFuture() ? 'valid' : 'expired';
return $cert;
});
return response()->json([
'status' => 'success',
'data' => $certificates
]);
}
public function renew(Request $request, CaCertificate $certificate)
{
$this->authorizeAdmin();
$days = (int) $request->input('days', 3650);
try {
$newData = $this->sslService->renewCaCertificate($certificate, $days);
$certificate->update([
'cert_content' => $newData['cert_content'],
'serial_number' => $newData['serial_number'],
'valid_from' => $newData['valid_from'],
'valid_to' => $newData['valid_to'],
]);
return response()->json([
'status' => 'success',
'message' => 'Certificate renewed successfully.',
'data' => $certificate
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => 'Renewal failed: ' . $e->getMessage()
], 500);
}
}
protected function authorizeAdmin()
{
if (auth()->user()->role !== 'admin') {
abort(403, 'Unauthorized action.');
}
}
}

View File

@@ -0,0 +1,241 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Models\TicketReply;
use Illuminate\Http\Request;
use App\Models\User;
use App\Notifications\NewTicketNotification;
use App\Notifications\TicketReplyNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Traits\LogsActivity;
class TicketController extends Controller
{
use LogsActivity;
/**
* Display a listing of tickets.
*/
public function index(Request $request)
{
$user = $request->user();
$query = Ticket::with(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments']);
// Only show all tickets if user is admin AND explicitly asks for all
if ($user->isAdmin() && $request->has('all')) {
// No additional where clause needed
} else {
// Everyone else (including admins in personal view) only sees their own
$query->where('user_id', $user->id);
}
$tickets = $query->latest()->paginate(10);
return response()->json($tickets);
}
/**
* Store a newly created ticket.
*/
public function store(Request $request)
{
$user = $request->user();
$validator = Validator::make($request->all(), [
'subject' => 'required|string|max:255',
'category' => 'required|string',
'priority' => 'required|in:low,medium,high',
'message' => 'required|string',
'attachments' => 'array|max:5',
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx,zip,txt|max:10240',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
try {
return DB::transaction(function () use ($request, $user) {
$ticket = Ticket::create([
'user_id' => $user->id,
'ticket_number' => Ticket::generateTicketNumber(),
'subject' => $request->subject,
'category' => $request->category,
'priority' => $request->priority,
'status' => 'open',
]);
$this->logActivity('create_ticket', "Created ticket #{$ticket->ticket_number}: {$ticket->subject}");
$reply = TicketReply::create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'message' => $request->message,
]);
// Handle Attachments
if ($request->hasFile('attachments')) {
foreach ($request->file('attachments') as $file) {
$path = $file->store('ticket-attachments', 'r2');
$url = Storage::disk('r2')->url($path);
TicketAttachment::create([
'ticket_reply_id' => $reply->id,
'file_name' => $file->getClientOriginalName(),
'file_path' => $url,
'file_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
]);
}
}
// Notify Admins
try {
$admins = User::where('role', 'admin')
->where('id', '!=', $user->id)
->get();
if ($admins->isNotEmpty()) {
Notification::send($admins, new NewTicketNotification($ticket->load('user')));
}
} catch (\Throwable $e) {
// Log error but don't fail the request
\Illuminate\Support\Facades\Log::error('Failed to send ticket notification: ' . $e->getMessage());
}
return response()->json([
'message' => 'Ticket created successfully',
'ticket' => $ticket->load(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments'])
], 201);
});
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Ticket creation failed: ' . $e->getMessage());
return response()->json(['message' => 'Failed to create ticket: ' . $e->getMessage()], 500);
}
}
/**
* Display the specified ticket with replies.
*/
public function show(Request $request, $id)
{
$user = $request->user();
$ticket = Ticket::with(['user:id,first_name,last_name,email,avatar', 'replies.user:id,first_name,last_name,avatar', 'replies.attachments'])->findOrFail($id);
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
return response()->json($ticket);
}
/**
* Add a reply to the ticket.
*/
public function reply(Request $request, $id)
{
$user = $request->user();
$ticket = Ticket::findOrFail($id);
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($ticket->status === 'closed') {
return response()->json(['message' => 'Cannot reply to a closed ticket'], 422);
}
$validator = Validator::make($request->all(), [
'message' => 'required|string',
'attachments' => 'array|max:5',
'attachments.*' => 'file|mimes:jpg,jpeg,png,pdf,doc,docx,zip,txt|max:10240',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$reply = TicketReply::create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'message' => $request->message,
]);
$this->logActivity('reply_ticket', "Replied to ticket #{$ticket->ticket_number}");
// Handle Attachments
if ($request->hasFile('attachments')) {
foreach ($request->file('attachments') as $file) {
$path = $file->store('ticket-attachments', 'r2');
$url = Storage::disk('r2')->url($path);
TicketAttachment::create([
'ticket_reply_id' => $reply->id,
'file_name' => $file->getClientOriginalName(),
'file_path' => $url,
'file_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
]);
}
}
// Update ticket status & Notify
try {
if ($user->isAdmin()) {
$ticket->update(['status' => 'answered']);
// Notify Customer
$ticketUser = $ticket->user;
if ($ticketUser && $ticketUser->id !== $user->id) {
$ticketUser->notify(new TicketReplyNotification($ticket, $reply, true));
}
// Also notify OTHER admins
$otherAdmins = User::where('role', 'admin')
->where('id', '!=', $user->id)
->get();
if ($otherAdmins->isNotEmpty()) {
Notification::send($otherAdmins, new TicketReplyNotification($ticket, $reply, true));
}
} else {
$ticket->update(['status' => 'open']);
// Notify All Admins
$admins = User::where('role', 'admin')->get();
if ($admins->isNotEmpty()) {
Notification::send($admins, new TicketReplyNotification($ticket, $reply, false));
}
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::error('Failed to send ticket reply notification: ' . $e->getMessage());
}
return response()->json([
'message' => 'Reply added successfully',
'reply' => $reply->load(['user:id,first_name,last_name,avatar', 'attachments'])
], 201);
}
/**
* Close the ticket.
*/
public function close(Request $request, $id)
{
$user = $request->user();
$ticket = Ticket::findOrFail($id);
if (!$user->isAdmin() && $ticket->user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
$ticket->update(['status' => 'closed']);
$this->logActivity('close_ticket', "Closed ticket #{$ticket->ticket_number}");
return response()->json([
'message' => 'Ticket closed successfully',
'ticket' => $ticket
]);
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\DB;
class UserApiController extends Controller
{
/**
* Display a listing of the users.
*/
public function index(Request $request)
{
$this->authorizeAdminOrOwner();
$query = User::query();
// Search by name or email
if ($request->has('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('email', 'like', "%{$search}%")
->orWhere('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%");
});
}
// Filter by role
if ($request->has('role')) {
$query->where('role', $request->input('role'));
}
// Sort
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
$query->orderBy($sortBy, $sortOrder);
return $query->paginate($request->input('per_page', 10));
}
/**
* Store a newly created user in storage.
*/
public function store(Request $request)
{
$this->authorizeAdminOrOwner();
if (auth()->user()->isOwner()) {
$roles = [User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_CUSTOMER];
} else {
// Admins can only create Customers
$roles = [User::ROLE_CUSTOMER];
}
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'nullable|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
'role' => ['required', Rule::in($roles)],
]);
$validated['password'] = Hash::make($validated['password']);
$user = User::create($validated);
return response()->json([
'message' => 'User created successfully',
'user' => $user
], 201);
}
/**
* Display the specified user.
*/
public function show(User $user)
{
$this->authorizeAdminOrOwner();
return $user;
}
/**
* Update the specified user in storage.
*/
public function update(Request $request, User $user)
{
$this->authorizeAdminOrOwner();
// Permission check: Admins cannot modify Admin/Owner accounts
if (auth()->user()->isAdmin() && $user->role !== User::ROLE_CUSTOMER) {
abort(403, 'Admins can only manage Customer accounts.');
}
$validated = $request->validate([
'first_name' => 'sometimes|required|string|max:255',
'last_name' => 'nullable|string|max:255',
'email' => ['sometimes', 'required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'password' => 'nullable|string|min:8',
'role' => [
'sometimes',
'required',
auth()->user()->isOwner() ? Rule::in([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_CUSTOMER]) : Rule::in([$user->role])
],
]);
// Admins cannot change roles (already handled by Rule::in([$user->role]) above for safety, but let's be explicit)
if (auth()->user()->isAdmin() && isset($validated['role']) && $validated['role'] !== $user->role) {
abort(403, 'Admins cannot change user roles.');
}
if (!empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
unset($validated['password']);
}
$user->update($validated);
return response()->json([
'message' => 'User updated successfully',
'user' => $user
]);
}
/**
* Remove the specified user from storage.
*/
public function destroy(User $user)
{
$this->authorizeAdminOrOwner();
// Permission check: Admins can only delete Customer accounts
if (auth()->user()->isAdmin() && $user->role !== User::ROLE_CUSTOMER) {
abort(403, 'Admins can only delete Customer accounts.');
}
// Prevent deleting yourself
if (auth()->id() === $user->id) {
return response()->json(['message' => 'You cannot delete your own account.'], 403);
}
$user->delete();
return response()->json(['message' => 'User deleted successfully']);
}
protected function authorizeAdminOrOwner()
{
if (!auth()->user()->isAdminOrOwner()) {
abort(403, 'Unauthorized action.');
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
class VerificationController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*
* @param \Illuminate\Foundation\Auth\EmailVerificationRequest $request
* @return \Illuminate\Http\RedirectResponse
*/
public function verify(EmailVerificationRequest $request)
{
$user = $request->user();
if ($user->hasVerifiedEmail() && !$user->pending_email) {
return redirect(config('app.frontend_url') . '/verify-success?already_verified=1');
}
// If there is a pending email, promote it to the main email
if ($user->pending_email) {
$user->email = $user->pending_email;
$user->pending_email = null;
}
if ($user->markEmailAsVerified()) {
event(new Verified($user));
}
return redirect(config('app.frontend_url') . '/verify-success?verified=1');
}
/**
* Resend the email verification notification.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function resend(Request $request)
{
$user = $request->user();
if ($user->hasVerifiedEmail() && !$user->pending_email) {
return response()->json(['message' => 'Email already verified.'], 400);
}
if ($user->pending_email) {
$user->notify(new \App\Notifications\PendingEmailVerificationNotification);
} else {
$user->sendEmailVerificationNotification();
}
return response()->json(['message' => 'Verification link sent.']);
}
}