mirror of
https://github.com/dyzulk/trustlab-api.git
synced 2026-01-26 13:22:05 +07:00
First commit
This commit is contained in:
178
app/Http/Controllers/Api/Admin/LegalPageController.php
Normal file
178
app/Http/Controllers/Api/Admin/LegalPageController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Api/ApiKeyController.php
Normal file
106
app/Http/Controllers/Api/ApiKeyController.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
app/Http/Controllers/Api/CertificateApiController.php
Normal file
241
app/Http/Controllers/Api/CertificateApiController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Api/DashboardController.php
Normal file
160
app/Http/Controllers/Api/DashboardController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Api/InquiryController.php
Normal file
118
app/Http/Controllers/Api/InquiryController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/Api/LegalPageController.php
Normal file
42
app/Http/Controllers/Api/LegalPageController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/Api/MailController.php
Normal file
64
app/Http/Controllers/Api/MailController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Api/NotificationController.php
Normal file
70
app/Http/Controllers/Api/NotificationController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/Api/PasswordResetController.php
Normal file
96
app/Http/Controllers/Api/PasswordResetController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
356
app/Http/Controllers/Api/ProfileController.php
Normal file
356
app/Http/Controllers/Api/ProfileController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
171
app/Http/Controllers/Api/PublicCaController.php
Normal file
171
app/Http/Controllers/Api/PublicCaController.php
Normal 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"');
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Api/RootCaApiController.php
Normal file
69
app/Http/Controllers/Api/RootCaApiController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
241
app/Http/Controllers/Api/TicketController.php
Normal file
241
app/Http/Controllers/Api/TicketController.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
157
app/Http/Controllers/Api/UserApiController.php
Normal file
157
app/Http/Controllers/Api/UserApiController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Http/Controllers/Api/VerificationController.php
Normal file
62
app/Http/Controllers/Api/VerificationController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
377
app/Http/Controllers/AuthController.php
Normal file
377
app/Http/Controllers/AuthController.php
Normal 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.']);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
161
app/Http/Controllers/NavigationController.php
Normal file
161
app/Http/Controllers/NavigationController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Http/Controllers/ServiceController.php
Normal file
35
app/Http/Controllers/ServiceController.php
Normal 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'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
195
app/Http/Controllers/TwoFactorController.php
Normal file
195
app/Http/Controllers/TwoFactorController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
24
app/Http/Middleware/AdminMiddleware.php
Normal file
24
app/Http/Middleware/AdminMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
57
app/Http/Middleware/CheckApiKey.php
Normal file
57
app/Http/Middleware/CheckApiKey.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user