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.']);
}
}

View File

@@ -0,0 +1,377 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\LoginHistory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Laravel\Socialite\Facades\Socialite;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use App\Traits\CanTrackLogin;
use App\Traits\LogsActivity;
use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile;
class AuthController extends Controller
{
use CanTrackLogin, LogsActivity;
/**
* Handle Login Request
*/
/**
* Handle Login Request
*/
public function login(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
]);
}
// 2FA Check
if ($user->two_factor_confirmed_at) {
// Return Temporary Token with "2fa" capability
// We DO NOT call Auth::login() here to prevent session cookie creation
$tempToken = $user->createToken('2fa_temp_token', ['2fa-required'])->plainTextToken;
return response()->json([
'two_factor_required' => true,
'temp_token' => $tempToken,
]);
}
// Standard Login - Establish session and issue token
Auth::guard('web')->login($user, $request->boolean('remember'));
$token = $user->createToken('auth_token')->plainTextToken;
$this->recordLoginHistory($request, $user);
$this->logActivity('login', 'User logged in to the dashboard');
return response()->json([
'message' => 'Login successful',
'token' => $token,
'user' => $user,
]);
}
/**
* Handle Registration Request
*/
public function register(Request $request)
{
$validated = $request->validate([
'fname' => 'required|string|max:255',
'lname' => 'nullable|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8',
]);
$user = User::create([
'first_name' => $validated['fname'],
'last_name' => $validated['lname'] ?? null,
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
Auth::login($user);
$token = $user->createToken('auth_token')->plainTextToken;
$this->recordLoginHistory($request, $user);
$this->logActivity('register', 'User registered a new account');
return response()->json([
'message' => 'Registration successful',
'token' => $token,
'user' => $user,
]);
}
/**
* Redirect to Social Provider
*/
public function socialRedirect(Request $request, $provider)
{
// Store context (signin, signup, connect) in session
$context = $request->query('context', 'signin'); // Default to signin if missing
session(['social_context' => $context]);
// Secure Link Flow: If a link_token is provided, verify it and store the user ID in session
if ($context === 'connect' && $request->has('link_token')) {
$token = $request->query('link_token');
$userId = Cache::get("link_token_{$token}");
if ($userId) {
session(['social_auth_user_id' => $userId]);
Cache::forget("link_token_{$token}"); // Consume token
}
}
$driver = Socialite::driver($provider)->stateless();
if ($provider === 'google') {
$driver->with(['prompt' => 'select_account consent', 'access_type' => 'offline']);
} else {
// Attempt to force consent for others
$driver->with(['prompt' => 'consent']);
}
return $driver->redirect();
}
/**
* Generate Link Token for Secure Connection
*/
public function getLinkToken(Request $request)
{
$token = Str::random(40);
// Cache user ID for 2 minutes
Cache::put("link_token_{$token}", $request->user()->id, 120);
return response()->json(['token' => $token]);
}
/**
* Handle Social Provider Callback
*/
public function socialCallback(Request $request, $provider)
{
try {
$socialUser = Socialite::driver($provider)->stateless()->user();
} catch (\Exception $e) {
return redirect(config('app.frontend_url') . '/auth/callback?error=' . urlencode($e->getMessage()));
}
$context = session('social_context', 'signin'); // Default to strict signin if session lost
// request()->session()->forget('social_context'); // Optional: Clear it, but typical session lifecycle handles this.
// ---------------------------------------------------------
// CASE 1: CONNECT ACCOUNT (User is already logged in)
// ---------------------------------------------------------
// Explicitly check context or Auth::check()
// If context is 'connect', they MUST be logged in.
if ($context === 'connect' || Auth::check() || session('social_auth_user_id')) {
// Debug Logging: Trace why connection flow might be failing
\Illuminate\Support\Facades\Log::info('Social Callback Connect Flow:', [
'context' => $context,
'auth_check' => Auth::check(),
'session_user_id' => session('social_auth_user_id'),
'provider' => $provider
]);
// If strictly unauthenticated but we have a session user ID from the link token
if (!Auth::check() && session('social_auth_user_id')) {
Auth::loginUsingId(session('social_auth_user_id'));
}
if (!Auth::check()) {
return redirect(config('app.frontend_url') . '/signin?error=login_required_to_connect');
}
$currentUser = Auth::user();
// Check if this social account is already linked to *any* user
$existingAccount = \App\Models\SocialAccount::where('provider', $provider)
->where('provider_id', $socialUser->getId())
->first();
if ($existingAccount) {
if ($existingAccount->user_id === $currentUser->id) {
return redirect(config('app.frontend_url') . '/dashboard/settings?error=already_connected');
} else {
return redirect(config('app.frontend_url') . '/dashboard/settings?error=connected_to_other_account');
}
}
// Link the account
$currentUser->socialAccounts()->create([
'provider' => $provider,
'provider_id' => $socialUser->getId(),
'provider_email' => $socialUser->getEmail(),
'avatar' => $socialUser->getAvatar(),
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_at' => isset($socialUser->expiresIn) ? now()->addSeconds($socialUser->expiresIn) : null,
]);
return redirect(config('app.frontend_url') . '/dashboard/settings?success=account_connected');
}
// ---------------------------------------------------------
// CASE 2: SOCIAL SIGN IN / SIGN UP (Guest)
// ---------------------------------------------------------
// 1. Check if SocialAccount exists (Already Linked)
$socialAccount = \App\Models\SocialAccount::where('provider', $provider)
->where('provider_id', $socialUser->getId())
->first();
if ($socialAccount) {
// Account linked -> ALWAYS ALLOW LOGIN
// (Even if context=signup, we can just log them in, or strictly say "Already registered")
$user = $socialAccount->user;
// Auto-verify if not verified (Social provider already verified the email)
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
Auth::login($user);
$token = $user->createToken('auth_token')->plainTextToken;
$this->recordLoginHistory($request, $user);
return redirect(config('app.frontend_url') . '/auth/callback?token=' . $token);
}
// 2. Check if User with this email exists (BUT NOT LINKED)
$existingUser = User::where('email', $socialUser->getEmail())->first();
if ($existingUser) {
// ERROR: Account exists but not linked
return redirect(config('app.frontend_url') . '/auth/callback?error=account_exists_please_login');
}
// 3. HANDLE NEW USERS
// STRICT CHECK: If context is 'signin', DO NOT register new user.
if ($context === 'signin') {
return redirect(config('app.frontend_url') . '/auth/callback?error=account_not_found_please_signup');
}
// 4. REGISTER (Only if context == 'signup')
$nameParts = explode(' ', $socialUser->getName() ?? '', 2);
$firstName = $nameParts[0];
$lastName = $nameParts[1] ?? '';
$user = User::create([
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $socialUser->getEmail(),
'avatar' => $socialUser->getAvatar(),
'email_verified_at' => now(),
// Password is null initially
]);
$user->socialAccounts()->create([
'provider' => $provider,
'provider_id' => $socialUser->getId(),
'provider_email' => $socialUser->getEmail(),
'avatar' => $socialUser->getAvatar(),
'token' => $socialUser->token,
'refresh_token' => $socialUser->refreshToken,
'expires_at' => isset($socialUser->expiresIn) ? now()->addSeconds($socialUser->expiresIn) : null,
]);
Auth::login($user);
$token = $user->createToken('auth_token')->plainTextToken;
$this->recordLoginHistory($request, $user);
// Redirect to Set Password page
return redirect(config('app.frontend_url') . '/auth/callback?token=' . $token . '&action=set_password');
}
/**
* Set Password for Social Users
*/
public function setPassword(Request $request)
{
$validated = $request->validate([
'password' => 'required|string|min:8|confirmed',
]);
$user = $request->user();
// Security check: Only allow setting password if it's currently null
// or provide a way to override if we want to allow simple password resets from this endpoint (unlikely for security)
if ($user->password && !Hash::check('', $user->password)) { // Check if password is not empty string/null effectively
// If user already has a password, they should use the update-password endpoint which requires current_password
// However, for this specific flow "set password after social login", we can allow it IF implementation allows.
// Stricter: Only if password is NULL.
}
if ($user->password !== null && $user->password !== '') {
return response()->json(['message' => 'Password already set. Use update password.'], 403);
}
$user->update([
'password' => Hash::make($validated['password']),
]);
return response()->json([
'message' => 'Password set successfully.',
'user' => $user,
]);
}
/**
* Handle Logout Request
*/
public function logout(Request $request)
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
/**
* Disconnect Social Account (Revoke Token)
*/
public function disconnectSocial(Request $request, $provider)
{
$user = $request->user();
$account = $user->socialAccounts()->where('provider', $provider)->first();
if (!$account) {
return response()->json(['message' => 'Account not linked.'], 404);
}
// 1. Revoke Logic
try {
if ($provider === 'google' && $account->token) {
// Google: Revoke via POST to oauth2.googleapis.com
Http::post('https://oauth2.googleapis.com/revoke', [
'token' => $account->token,
]);
}
elseif ($provider === 'github' && $account->token) {
// GitHub: Revoke via Basic Auth with Client ID/Secret
// Requires client_id:client_secret base64 encoded
$clientId = config('services.github.client_id');
$clientSecret = config('services.github.client_secret');
if ($clientId && $clientSecret) {
Http::withBasicAuth($clientId, $clientSecret)
->delete("https://api.github.com/applications/{$clientId}/grant", [
'access_token' => $account->token
]);
}
}
} catch (\Exception $e) {
// Log error but proceed to delete local record
\Illuminate\Support\Facades\Log::error("Failed to revoke {$provider} token: " . $e->getMessage());
}
// 2. Delete Local Record
$account->delete();
return response()->json(['message' => 'Account disconnected successfully.']);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NavigationController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
$menuGroups = [];
// 1. Admin Management (Admin or Owner)
if ($user && $user->isAdminOrOwner()) {
$menuGroups[] = [
'title' => 'Admin Management',
'items' => [
[
'name' => 'User Management',
'icon' => 'users',
'route' => '/dashboard/admin/users',
],
[
'name' => 'Root CA Management',
'icon' => 'certificate',
'route' => '/dashboard/admin/root-ca',
],
[
'name' => 'Ticket Management',
'icon' => 'support-ticket',
'route' => '/dashboard/admin/tickets',
],
[
'name' => 'Legal Page Management',
'icon' => 'pages',
'route' => '/dashboard/admin/legal',
],
[
'name' => 'Inquiries',
'icon' => 'inbox',
'route' => '/dashboard/admin/inquiries',
],
[
'name' => 'SMTP Tester',
'icon' => 'smtp',
'route' => '/dashboard/admin/smtp-tester',
],
]
];
}
// 2. Main Menu (Common)
$mainItems = [
[
'name' => 'Dashboard',
'icon' => 'dashboard',
'route' => '/dashboard',
],
[
'name' => 'Certificates',
'icon' => 'certificate',
'route' => '/dashboard/certificates',
],
[
'name' => 'API Keys',
'icon' => 'api-key',
'route' => '/dashboard/api-keys',
],
[
'name' => 'Support Tickets',
'icon' => 'support-ticket',
'route' => '/dashboard/support', // Assuming support.index maps to /support
],
];
// "My Services" for Customers ONLY
if ($user && $user->role === \App\Models\User::ROLE_CUSTOMER) {
// We can insert "My Services" if we want to keep that feature for customers
// As per user request "ikuiti app-beta", but we also added "My Services" previously.
// Let's keep "My Services" as it's a nice dedicated page for them, inserting it after Dashboard.
array_splice($mainItems, 1, 0, [[
'name' => 'My Services',
'icon' => 'layers',
'route' => '/dashboard/services',
]]);
}
$menuGroups[] = [
'title' => 'Menu',
'items' => $mainItems,
];
// 3. My Account (Common)
$menuGroups[] = [
'title' => 'My Account',
'items' => [
[
'name' => 'User Profile',
'icon' => 'user-profile',
'route' => '/dashboard/profile',
],
[
'name' => 'Account Settings',
'icon' => 'settings',
'route' => '/dashboard/settings',
],
]
];
return response()->json($menuGroups);
}
public function debug()
{
// Simulate a User instance for admin view
$user = new \App\Models\User(['first_name' => 'Debug', 'last_name' => 'Admin', 'role' => 'admin']);
// This is a bit of a hack since $user->isAdmin() might be a real method,
// but for JSON structure debugging, we'll just replicate the logic or mock it.
$menuGroups = [];
// 1. Admin Management (Simulated Admin)
$menuGroups[] = [
'title' => 'Admin Management',
'items' => [
['name' => 'User Management', 'icon' => 'users', 'route' => '/admin/users'],
['name' => 'Root CA Management', 'icon' => 'certificate', 'route' => '/admin/root-ca'],
['name' => 'Ticket Management', 'icon' => 'support-ticket', 'route' => '/admin/tickets'],
['name' => 'Legal Page Management', 'icon' => 'pages', 'route' => '/dashboard/admin/legal'],
['name' => 'Inquiries', 'icon' => 'inbox', 'route' => '/dashboard/admin/inquiries'],
['name' => 'SMTP Tester', 'icon' => 'smtp', 'route' => '/dashboard/admin/smtp-tester'],
]
];
// 2. Main Menu
$mainItems = [
['name' => 'Dashboard', 'icon' => 'dashboard', 'route' => '/dashboard'],
['name' => 'Certificates', 'icon' => 'certificate', 'route' => '/dashboard/certificates'],
['name' => 'API Keys', 'icon' => 'api-key', 'route' => '/dashboard/api-keys'],
['name' => 'Support Tickets', 'icon' => 'support-ticket', 'route' => '/dashboard/support'],
];
$menuGroups[] = [
'title' => 'Menu',
'items' => $mainItems,
];
// 3. My Account
$menuGroups[] = [
'title' => 'My Account',
'items' => [
['name' => 'User Profile', 'icon' => 'user-profile', 'route' => '/dashboard/profile'],
['name' => 'Account Settings', 'icon' => 'settings', 'route' => '/dashboard/settings'],
]
];
return response()->json($menuGroups);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ServiceController extends Controller
{
public function index()
{
return response()->json([
[
'id' => 1,
'name' => 'SSL Certificate - Standard',
'status' => 'Active',
'expiry' => '2026-12-23',
'domain' => 'example.com'
],
[
'id' => 2,
'name' => 'Code Signing - Pro',
'status' => 'Pending',
'expiry' => 'N/A',
'domain' => 'N/A'
],
[
'id' => 3,
'name' => 'Wildcard SSL',
'status' => 'Expired',
'expiry' => '2025-01-10',
'domain' => '*.web.dev'
]
]);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use OTPHP\TOTP;
use PragmaRX\Google2FAQRCode\Google2FA;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use App\Traits\CanTrackLogin;
class TwoFactorController extends Controller
{
use CanTrackLogin;
/**
* Enable 2FA: Generate Secret & QR Code
*/
public function enable(Request $request)
{
$user = $request->user();
if ($user->two_factor_confirmed_at) {
return response()->json(['message' => '2FA is already enabled.'], 400);
}
$google2fa = new Google2FA();
// Generate secret if not exists or if re-enabling
$secret = $google2fa->generateSecretKey();
// Save encrypted secret (or plain if you trust your db, but encrypted is better)
// For simplicity with this library, it often expects raw secret.
// We will store it encrypted but decrypt it when needed if we were using a trait
// But for manual implementation, lets store it temporarily in the session OR save it to DB directly?
// Let's save it to DB but encrypt it.
$user->forceFill([
'two_factor_secret' => encrypt($secret),
'two_factor_recovery_codes' => null, // Reset codes
])->save();
// Generate QR Code Object
$qrCodeUrl = $google2fa->getQRCodeInline(
config('app.name'),
$user->email,
$secret
);
return response()->json([
'secret' => $secret,
'qr_code' => $qrCodeUrl,
]);
}
/**
* Confirm 2FA: Verify initial OTP
*/
public function confirm(Request $request)
{
$request->validate(['code' => 'required|string|size:6']);
$user = $request->user();
$google2fa = new Google2FA();
try {
$secret = decrypt($user->two_factor_secret);
} catch (\Exception $e) {
return response()->json(['message' => '2FA not initiated.'], 400);
}
$valid = $google2fa->verifyKey($secret, $request->code);
if (!$valid) {
throw ValidationException::withMessages([
'code' => ['Invalid 2FA code.'],
]);
}
// Generate Recovery Codes
$recoveryCodes = [];
for ($i = 0; $i < 8; $i++) {
$recoveryCodes[] = \Illuminate\Support\Str::random(10) . '-' . \Illuminate\Support\Str::random(10);
}
$user->forceFill([
'two_factor_confirmed_at' => now(),
'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)),
])->save();
return response()->json([
'message' => '2FA enabled successfully.',
'recovery_codes' => $recoveryCodes,
]);
}
/**
* Disable 2FA
*/
public function disable(Request $request)
{
$request->validate(['password' => 'required']);
$user = $request->user();
if (!Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'password' => ['Invalid password.'],
]);
}
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
return response()->json(['message' => '2FA disabled successfully.']);
}
/**
* Verify 2FA during Login Challenge
*/
public function verify(Request $request)
{
$request->validate(['code' => 'required|string']);
$user = $request->user();
// Check 2FA Secret
try {
$secret = decrypt($user->two_factor_secret);
} catch (\Exception $e) {
return response()->json(['message' => '2FA configuration error.'], 500);
}
$google2fa = new Google2FA();
$valid = $google2fa->verifyKey($secret, $request->code);
// Check Recovery Code if TOTP failed
if (!$valid) {
$recoveryCodes = $user->two_factor_recovery_codes ? json_decode(decrypt($user->two_factor_recovery_codes), true) : [];
if (in_array($request->code, $recoveryCodes)) {
$valid = true;
// Remove used recovery code
$recoveryCodes = array_diff($recoveryCodes, [$request->code]);
$user->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode(array_values($recoveryCodes))),
])->save();
}
}
if (!$valid) {
throw ValidationException::withMessages([
'code' => ['Invalid code provided.'],
]);
}
// Success!
// 1. Establish session (for web/inertia/sanctum cookie flows)
Auth::guard('web')->login($user, $request->boolean('remember'));
// 2. Revoke the temp token
if ($user->currentAccessToken()) {
$user->currentAccessToken()->delete();
}
// 3. Create new full access token
$token = $user->createToken('auth_token')->plainTextToken;
// 4. Record History
$this->recordLoginHistory($request, $user);
return response()->json([
'message' => 'Login successful',
'token' => $token,
'user' => $user
]);
}
/**
* Show Recovery Codes
*/
public function recoveryCodes(Request $request)
{
if (!$request->user()->two_factor_confirmed_at) {
return response()->json(['message' => '2FA not enabled.'], 400);
}
$codes = json_decode(decrypt($request->user()->two_factor_recovery_codes), true);
return response()->json(['recovery_codes' => $codes]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (!$request->user() || !$request->user()->isAdminOrOwner()) {
return response()->json(['message' => 'Unauthorized. Admin access required.'], 403);
}
return $next($request);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use Closure;
use App\Models\ApiKey;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
class CheckApiKey
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// Check for underscore only (preference)
$headerValue = $request->header('TRUSTLAB_API_KEY');
$keyString = $headerValue ? trim($headerValue) : null;
if (!$keyString) {
return response()->json([
'success' => false,
'message' => 'API Key is missing. Please provide it in the TRUSTLAB_API_KEY header.'
], 401);
}
$apiKey = ApiKey::where('key', $keyString)->first();
if (!$apiKey || !$apiKey->is_active) {
return response()->json([
'success' => false,
'message' => 'Invalid or inactive API Key.'
], 401);
}
// Update last used timestamp
$apiKey->update(['last_used_at' => now()]);
// Optional: Dispatch stats update event if needed in the future
// \App\Events\DashboardStatsUpdated::dispatch($apiKey->user_id);
// Put the user in the request context
$user = $apiKey->user;
$request->merge(['authenticated_user' => $user]);
$request->setUserResolver(fn () => $user);
if ($user) {
Auth::setUser($user);
}
return $next($request);
}
}