feat: inisialisasi project kit v2

This commit is contained in:
2026-05-21 15:57:29 +07:00
commit d4fd478e1f
271 changed files with 35300 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Actions\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginAction
{
/**
* Execute the login action.
*
* @param array $credentials
* @return array
* @throws ValidationException
*/
public function execute(array $credentials): array
{
$user = User::where('email', $credentials['email'])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
$token = $user->createToken('auth_token')->plainTextToken;
return [
'user' => $user,
'token' => $token,
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name'),
];
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Users;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class CreateUserAction
{
/**
* Execute the create user action.
*
* @param array $data
* @return User
*/
public function execute(array $data): User
{
$user = User::create([
'first_name' => $data['firstName'],
'last_name' => $data['lastName'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'status' => $data['status'] ?? 'active',
'meta' => $data['meta'] ?? [],
]);
if (isset($data['roles'])) {
$user->assignRole($data['roles']);
}
return $user;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Exports;
use App\Models\User;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
class UsersExport implements FromCollection, WithHeadings, WithMapping
{
public function collection()
{
return User::all();
}
public function headings(): array
{
return [
'ID',
'First Name',
'Last Name',
'Email',
'Status',
'Created At',
];
}
public function map($user): array
{
return [
$user->id,
$user->first_name,
$user->last_name,
$user->email,
$user->status,
$user->created_at->toDateTimeString(),
];
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Activitylog\Models\Activity;
class ActivityLogController extends Controller
{
public function index(Request $request)
{
$this->authorize('user.view');
$search = $request->input('search');
$logName = $request->input('log_name');
$event = $request->input('event');
$perPage = (int) $request->input('per_page', 15);
$query = Activity::with('causer')->latest();
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('log_name', 'like', "%{$search}%");
});
}
if ($logName) {
$query->where('log_name', $logName);
}
if ($event) {
$query->where('event', $event);
}
$activities = $query->paginate($perPage)->withQueryString();
$logNames = Activity::distinct()->pluck('log_name');
$events = Activity::distinct()->whereNotNull('event')->pluck('event');
return Inertia::render('ActivityLogs/Index', [
'activities' => [
'data' => $activities->items(),
'meta' => [
'current_page' => $activities->currentPage(),
'last_page' => $activities->lastPage(),
'total' => $activities->total(),
'per_page' => $activities->perPage(),
],
'links' => $activities->linkCollection()->toArray(),
],
'filters' => $request->only(['search', 'log_name', 'event', 'per_page']),
'availableLogNames' => $logNames,
'availableEvents' => $events,
]);
}
public function bulkDelete(Request $request)
{
$this->authorize('user.delete');
$ids = (array) $request->input('ids', []);
Activity::whereIn('id', $ids)->delete();
return back()->with('success', \count($ids) . ' logs deleted successfully.');
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* @group Mobile App Config
*
* Public endpoint for mobile app version checks and maintenance status.
*/
class AppConfigController extends Controller
{
/**
* Get App Config
*
* Returns version info and maintenance status for the given platform.
*
* @unauthenticated
* @queryParam platform string The platform (android|ios). Defaults to android.
*/
public function __invoke(Request $request): JsonResponse
{
$platform = $request->input('platform', 'android');
if (!\in_array($platform, ['android', 'ios'], true)) {
$platform = 'android';
}
$settings = Cache::get('system_settings', function () {
try {
return Setting::pluck('value', 'key')->toArray();
} catch (\Throwable) {
return [];
}
});
if ($platform === 'android') {
$maintenance = filter_var($settings['android_maintenance_mode'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($maintenance) {
return response()->json([
'maintenance' => true,
'message' => 'The app is temporarily under maintenance. Please try again later.',
], 503);
}
return response()->json([
'maintenance' => false,
'latest_version' => $settings['android_latest_version'] ?? '1.0.0',
'min_version' => $settings['android_min_version'] ?? '1.0.0',
'store_url' => $settings['android_playstore_url'] ?? null,
'platform' => 'android',
]);
}
// ios — placeholder using same pattern, expandable when ios settings are added
return response()->json([
'maintenance' => false,
'latest_version' => $settings['ios_latest_version'] ?? '1.0.0',
'min_version' => $settings['ios_min_version'] ?? '1.0.0',
'store_url' => $settings['ios_appstore_url'] ?? null,
'platform' => 'ios',
]);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\Auth\LoginAction;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* @group Authentication
*
* APIs for managing authentication
*/
class AuthController extends Controller
{
/**
* Login
*
* Authenticate a user and return a Sanctum token.
*
* @unauthenticated
*/
public function login(Request $request, LoginAction $action): JsonResponse
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$result = $action->execute($credentials);
return response()->json([
'data' => new UserResource($result['user']),
'token' => $result['token'],
'roles' => $result['roles'],
'permissions' => $result['permissions'],
]);
}
/**
* Get Current User
*
* Return the currently authenticated user's details.
*/
public function me(Request $request): UserResource
{
return new UserResource($request->user());
}
/**
* Logout
*
* Revoke the current user's token.
*/
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out successfully']);
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Actions\Users\CreateUserAction;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* @group User Management
*
* APIs for managing users
*/
class UserController extends Controller
{
/**
* List Users
*
* Get a paginated list of users.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('user.view');
$users = User::query()
->when($request->search, function ($query, $search) {
$query->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
})
->paginate($request->perPage ?? 15);
return UserResource::collection($users);
}
/**
* Create User
*
* Create a new user with roles.
*/
public function store(Request $request, CreateUserAction $action): UserResource
{
$this->authorize('user.create');
$validated = $request->validate([
'firstName' => 'required|string|max:100',
'lastName' => 'required|string|max:100',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8',
'status' => 'string|in:active,inactive,suspended',
'roles' => 'array',
]);
$user = $action->execute($validated);
return new UserResource($user);
}
/**
* Get User
*
* Get details of a specific user.
*/
public function show(User $user): UserResource
{
$this->authorize('user.view');
return new UserResource($user);
}
/**
* Update User
*
* Update a user's details.
*/
public function update(Request $request, User $user): UserResource
{
$this->authorize('user.edit');
$validated = $request->validate([
'firstName' => 'string|max:100',
'lastName' => 'string|max:100',
'email' => 'email|unique:users,email,' . $user->id,
'status' => 'string|in:active,inactive,suspended',
]);
// Mapping camelCase to snake_case for DB
if (isset($validated['firstName'])) $user->first_name = $validated['firstName'];
if (isset($validated['lastName'])) $user->last_name = $validated['lastName'];
if (isset($validated['email'])) $user->email = $validated['email'];
if (isset($validated['status'])) $user->status = $validated['status'];
$user->save();
return new UserResource($user);
}
/**
* Delete User
*
* Soft delete a user.
*/
public function destroy(User $user): JsonResponse
{
$this->authorize('user.delete');
$user->delete();
return response()->json(['message' => 'User deleted successfully']);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Inertia\Response;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): Response
{
return Inertia::render('Auth/Login', [
'canResetPassword' => Route::has('password.request'),
'status' => session('status'),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
$user = Auth::user();
// If user has 2FA enabled, redirect to challenge screen
if ($user->two_factor_confirmed_at && $user->two_factor_secret) {
$request->session()->put('two_factor_user_id', $user->id);
Auth::guard('web')->logout();
$request->session()->forget('password_hash_web');
return redirect()->route('two-factor.challenge');
}
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): Response
{
return Inertia::render('Auth/ConfirmPassword');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|Response
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: Inertia::render('Auth/VerifyEmail', ['status' => session('status')]);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): Response
{
return Inertia::render('Auth/ResetPassword', [
'email' => $request->email,
'token' => $request->route('token'),
]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => 'required',
'email' => 'required|email',
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status == Password::PASSWORD_RESET) {
return redirect()->route('login')->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back();
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): Response
{
return Inertia::render('Auth/ForgotPassword', [
'status' => session('status'),
]);
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => 'required|email',
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
if ($status == Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
throw ValidationException::withMessages([
'email' => [trans($status)],
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): Response
{
$settings = \Illuminate\Support\Facades\Cache::get('system_settings', []);
abort_if(($settings['allow_registration'] ?? '1') === '0', 403, 'Registration is currently disabled.');
return Inertia::render('Auth/Register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$settings = \Illuminate\Support\Facades\Cache::get('system_settings', []);
abort_if(($settings['allow_registration'] ?? '1') === '0', 403, 'Registration is currently disabled.');
$request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'email' => $request->email,
'password' => Hash::make($request->password),
'status' => 'active',
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Carbon\Carbon;
class DashboardController extends Controller
{
public function index()
{
// General Stats
$totalUsers = User::count();
$activeUsers = User::where('status', 'active')->count();
$totalRoles = Role::count();
$recentUsers = User::with('roles')->latest()->take(5)->get();
$driver = DB::connection()->getDriverName();
// Chart Data: User Growth (Last 6 Months)
if ($driver === 'sqlite') {
$monthFormat = "strftime('%Y-%m', created_at)";
} elseif ($driver === 'pgsql') {
$monthFormat = "to_char(created_at, 'YYYY-MM')";
} else {
$monthFormat = "DATE_FORMAT(created_at, '%Y-%m')";
}
$userGrowth = User::select(
DB::raw('count(id) as total'),
DB::raw("$monthFormat as month")
)
->groupBy('month')
->orderBy('month', 'asc')
->take(6)
->get()
->map(function ($item) {
return [
'label' => Carbon::parse($item->month . '-01')->format('M Y'),
'value' => $item->total,
];
});
// Chart Data: Activity Logs (Last 7 Days)
if ($driver === 'sqlite') {
$dateFormat = "strftime('%Y-%m-%d', created_at)";
} elseif ($driver === 'pgsql') {
$dateFormat = "to_char(created_at, 'YYYY-MM-DD')";
} else {
$dateFormat = "DATE(created_at)";
}
$activityStats = [];
if (Schema::hasTable('activity_log')) {
$activityStats = DB::table('activity_log')
->select(
DB::raw('count(id) as total'),
DB::raw("$dateFormat as date")
)
->where('created_at', '>=', Carbon::now()->subDays(7))
->groupBy('date')
->orderBy('date', 'asc')
->get()
->map(function ($item) {
return [
'label' => Carbon::parse($item->date)->format('D, M d'),
'value' => $item->total,
];
});
}
return Inertia::render('Dashboard', [
'stats' => [
'totalUsers' => $totalUsers,
'activeUsers' => $activeUsers,
'totalRoles' => $totalRoles,
'recentUsers' => $recentUsers,
'charts' => [
'userGrowth' => $userGrowth,
'activityStats' => $activityStats,
]
]
]);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DocsController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request)
{
//
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Spatie\Activitylog\Models\Activity;
use Illuminate\Http\Request;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request)
{
$query = $request->input('query');
if (empty($query)) {
return response()->json([]);
}
$results = [];
// Search Users
$users = User::where('first_name', 'like', "%{$query}%")
->orWhere('last_name', 'like', "%{$query}%")
->orWhere('email', 'like', "%{$query}%")
->limit(5)
->get()
->map(fn($u) => [
'type' => 'User',
'title' => "{$u->first_name} {$u->last_name}",
'subtitle' => $u->email,
'url' => route('users.show', $u->id),
'icon' => 'user'
]);
$results = array_merge($results, $users->toArray());
// Search Roles
$roles = Role::where('name', 'like', "%{$query}%")
->limit(3)
->get()
->map(fn($r) => [
'type' => 'Role',
'title' => $r->name,
'subtitle' => "{$r->permissions()->count()} permissions",
'url' => route('roles.index'),
'icon' => 'shield'
]);
$results = array_merge($results, $roles->toArray());
// Search Activity Logs
$logs = Activity::where('description', 'like', "%{$query}%")
->orWhere('log_name', 'like', "%{$query}%")
->latest()
->limit(5)
->get()
->map(fn($l) => [
'type' => 'Log',
'title' => $l->description,
'subtitle' => $l->created_at->diffForHumans(),
'url' => route('activity-logs.index'),
'icon' => 'clock'
]);
$results = array_merge($results, $logs->toArray());
// Search Navigation Pages
$pages = [
['title' => 'Dashboard', 'url' => route('dashboard'), 'icon' => 'clock', 'keywords' => ['home', 'index', 'dashboard']],
['title' => 'User Management', 'url' => route('users.index'), 'icon' => 'user', 'keywords' => ['users', 'people', 'staff', 'accounts']],
['title' => 'Role Management', 'url' => route('roles.index'), 'icon' => 'shield', 'keywords' => ['roles', 'permissions', 'access', 'security']],
['title' => 'Activity Logs', 'url' => route('activity-logs.index'), 'icon' => 'clock', 'keywords' => ['logs', 'audit', 'history', 'events']],
['title' => 'System Settings', 'url' => route('system.settings.index'), 'icon' => 'shield', 'keywords' => ['settings', 'config', 'setup', 'system']],
['title' => 'Profile Settings', 'url' => route('profile.edit'), 'icon' => 'user', 'keywords' => ['profile', 'me', 'account', 'password']],
];
$matchedPages = array_filter($pages, function($page) use ($query) {
$q = strtolower($query);
if (str_contains(strtolower($page['title']), $q)) return true;
foreach ($page['keywords'] as $keyword) {
if (str_contains($keyword, $q)) return true;
}
return false;
});
foreach ($matchedPages as $page) {
$results[] = [
'type' => 'Page',
'title' => $page['title'],
'subtitle' => 'System Navigation',
'url' => $page['url'],
'icon' => $page['icon']
];
}
return response()->json($results);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Log;
class NotificationController extends Controller
{
public function index(Request $request)
{
$logs = NotificationLog::with(['targetUser', 'sender'])
->latest()
->paginate(10);
$users = User::select('id', 'first_name', 'last_name', 'email')
->where('status', 'active')
->get();
return Inertia::render('Notifications/Index', [
'logs' => [
'data' => $logs->items(),
'meta' => [
'current_page' => $logs->currentPage(),
'last_page' => $logs->lastPage(),
'total' => $logs->total(),
'per_page' => $logs->perPage(),
],
'links' => $logs->linkCollection()->toArray(),
],
'users' => $users,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'image_url' => 'nullable|url',
'deep_link' => 'nullable|string',
'target_type' => 'required|in:all,individual',
'target_user_id' => 'required_if:target_type,individual|nullable|exists:users,id',
]);
try {
// Mocking FCM Sending logic
// In production, use Kreait/Firebase-PHP or Laravel-Notification-Channels/WebPush
$status = 'sent';
$errorMessage = null;
// Log the notification
NotificationLog::create([
'title' => $validated['title'],
'body' => $validated['body'],
'image_url' => $validated['image_url'],
'deep_link' => $validated['deep_link'],
'target_type' => $validated['target_type'],
'target_user_id' => $validated['target_user_id'],
'sender_id' => auth()->id(),
'status' => $status,
'error_message' => $errorMessage,
]);
return back()->with('success', 'Notification dispatched successfully.');
} catch (\Exception $e) {
Log::error('FCM Error: ' . $e->getMessage());
return back()->with('error', 'Failed to send notification: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Display the user's profile form (Redirect to consolidated settings).
*/
public function edit(Request $request): RedirectResponse
{
return Redirect::route('settings.index');
}
/**
* Update the user's profile information and avatar.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
$user->fill($request->validated());
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
// Handle Avatar Upload
if ($request->hasFile('avatar_file')) {
// Delete old avatar if exists
if ($user->avatar_url) {
$oldPath = str_replace('/storage/', '', $user->avatar_url);
Storage::disk('public')->delete($oldPath);
}
$path = $request->file('avatar_file')->store('avatars', 'public');
$user->avatar_url = Storage::url($path);
}
$user->save();
return Redirect::route('settings.index')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RoleController extends Controller
{
public function index()
{
$order = ['super-admin' => 0, 'admin' => 1, 'user' => 2];
$roles = Role::where('guard_name', 'web')
->with('permissions')
->get()
->map(function ($role) {
return [
'id' => $role->id,
'name' => $role->name,
'guard_name' => $role->guard_name,
'permissions' => $role->permissions->pluck('name')->toArray(),
'users_count' => $role->users()->count(),
'created_at' => $role->created_at,
];
})
->sortBy(fn ($role) => $order[$role['name']] ?? 99)
->values();
$permissions = Permission::where('guard_name', 'web')
->get()
->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'group' => explode('.', $p->name)[0] ?? 'other',
]);
return Inertia::render('Roles/Index', [
'roles' => $roles,
'permissions' => $permissions,
]);
}
/**
* Update the permissions matrix for a role.
*/
public function updatePermissions(Request $request, Role $role)
{
$validated = $request->validate([
'permissions' => 'required|array',
'permissions.*' => 'string|exists:permissions,name',
]);
// Sync only web guard permissions
$role->syncPermissions($validated['permissions']);
return back()->with('success', "Permissions updated for role '{$role->name}'.");
}
/**
* Store a new role.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:50|unique:roles,name',
]);
Role::create([
'name' => $validated['name'],
'guard_name' => 'web',
]);
return back()->with('success', 'Role created successfully.');
}
/**
* Delete a role.
*/
public function destroy(Role $role)
{
if ($role->name === 'super-admin') {
return back()->withErrors(['error' => 'Cannot delete the super-admin role.']);
}
$role->delete();
return back()->with('success', 'Role deleted successfully.');
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Redirect;
class SettingsController extends Controller
{
/**
* Display the consolidated account settings page.
*/
public function index(Request $request)
{
$user = $request->user();
$twoFactorEnabled = !is_null($user->two_factor_confirmed_at);
$qrCode = null;
$secret = null;
if (!$twoFactorEnabled) {
if (!$user->two_factor_secret) {
$g2fa = new \PragmaRX\Google2FA\Google2FA();
$user->update(['two_factor_secret' => $g2fa->generateSecretKey()]);
}
$secret = $user->fresh()->two_factor_secret;
$g2fa = new \PragmaRX\Google2FA\Google2FA();
$otpUrl = $g2fa->getQRCodeUrl(config('app.name'), $user->email, $secret);
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$qrCode = 'data:image/svg+xml;base64,' . base64_encode((new \BaconQrCode\Writer($renderer))->writeString($otpUrl));
}
return Inertia::render('Settings/Index', [
'mustVerifyEmail' => $user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail,
'status' => session('status'),
'twoFactor' => [
'enabled' => $twoFactorEnabled,
'qr_code' => $qrCode,
'secret' => $secret,
'recovery_codes' => $user->two_factor_recovery_codes
? json_decode($user->two_factor_recovery_codes, true)
: [],
],
]);
}
}
@@ -0,0 +1,179 @@
<?php
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class SystemSettingController extends Controller
{
/**
* Display the system settings page.
*/
public function index()
{
abort_if(!auth()->user()->hasRole('super-admin'), 403, 'Unauthorized. Super-Admin only.');
$settings = Setting::all()->pluck('value', 'key');
$defaultSettings = [
'app_name' => 'biiproject kit v2',
'app_logo' => null,
'app_logo_text' => 'BK',
'app_description' => 'Enterprise Admin Platform',
'allow_registration' => '1',
'require_email_verification' => '0',
'password_minimum_length' => '8',
'password_require_symbols' => '0',
'password_require_numbers' => '0',
'password_require_mixed_case' => '0',
// OAuth
'oauth_google_enabled' => '0',
'oauth_google_client_id' => '',
'oauth_google_client_secret' => '',
'oauth_github_enabled' => '0',
'oauth_github_client_id' => '',
'oauth_github_client_secret' => '',
// SMTP
'mail_host' => 'smtp.mailtrap.io',
'mail_port' => '2525',
'mail_username' => '',
'mail_password' => '',
'mail_encryption' => 'tls',
'mail_from_address' => 'hello@biiproject.com',
'mail_from_name' => 'biiproject kit Admin',
'primary_color' => '#6366f1',
// Mobile App Control
'android_latest_version' => '1.0.0',
'android_min_version' => '1.0.0',
'android_maintenance_mode' => '0',
'android_playstore_url' => '',
];
$mergedSettings = array_merge($defaultSettings, $settings->toArray());
return Inertia::render('SystemSettings/Index', [
'settings' => $mergedSettings,
]);
}
/**
* Update the system settings.
*/
public function update(Request $request)
{
abort_if(!auth()->user()->hasRole('super-admin'), 403, 'Unauthorized. Super-Admin only.');
$validated = $request->validate([
'settings' => 'required|array',
'settings.app_name' => 'required|string|max:255',
'settings.app_logo_text' => 'required|string|max:3',
'settings.app_description' => 'nullable|string',
'settings.allow_registration' => 'boolean',
'settings.require_email_verification' => 'boolean',
'settings.password_minimum_length' => 'integer|min:8',
'settings.password_require_symbols' => 'boolean',
'settings.password_require_numbers' => 'boolean',
'settings.password_require_mixed_case' => 'boolean',
'settings.oauth_google_enabled' => 'boolean',
'settings.oauth_google_client_id' => 'nullable|string',
'settings.oauth_google_client_secret' => 'nullable|string',
'settings.oauth_github_enabled' => 'boolean',
'settings.oauth_github_client_id' => 'nullable|string',
'settings.oauth_github_client_secret' => 'nullable|string',
'settings.mail_host' => 'nullable|string',
'settings.mail_port' => 'nullable|string',
'settings.mail_username' => 'nullable|string',
'settings.mail_password' => 'nullable|string',
'settings.mail_encryption' => 'nullable|string',
'settings.mail_from_address' => 'nullable|email',
'settings.mail_from_name' => 'nullable|string',
'settings.primary_color' => 'required|string',
// Mobile App Control
'settings.android_latest_version' => 'nullable|string',
'settings.android_min_version' => 'nullable|string',
'settings.android_maintenance_mode' => 'boolean',
'settings.android_playstore_url' => 'nullable|url',
'logo_file' => 'nullable|image|max:2048',
]);
$settings = $validated['settings'];
if ($request->hasFile('logo_file')) {
$path = $request->file('logo_file')->store('branding', 'public');
$logoUrl = Storage::url($path);
Setting::updateOrCreate(['key' => 'app_logo'], ['value' => $logoUrl, 'type' => 'string']);
}
foreach ($settings as $key => $value) {
Setting::updateOrCreate(
['key' => $key],
['value' => (string) $value, 'type' => is_bool($value) ? 'boolean' : (is_int($value) ? 'integer' : 'string')]
);
}
Cache::forget('system_settings');
return back()->with('success', 'System configurations updated successfully.');
}
/**
* Send a test email using SMTP details from the request.
*/
public function testEmail(Request $request)
{
abort_if(!auth()->user()->hasRole('super-admin'), 403, 'Unauthorized. Super-Admin only.');
$request->validate([
'recipient' => 'required|email',
'mail_host' => 'nullable|string',
'mail_port' => 'nullable|string',
'mail_username' => 'nullable|string',
'mail_password' => 'nullable|string',
'mail_encryption' => 'nullable|string',
'mail_from_address' => 'nullable|email',
'mail_from_name' => 'nullable|string',
]);
try {
// Apply SMTP settings at runtime
config([
'mail.default' => 'smtp',
'mail.mailers.smtp.transport' => 'smtp',
'mail.mailers.smtp.host' => $request->input('mail_host'),
'mail.mailers.smtp.port' => $request->input('mail_port'),
'mail.mailers.smtp.username' => $request->input('mail_username'),
'mail.mailers.smtp.password' => $request->input('mail_password'),
'mail.mailers.smtp.encryption' => $request->input('mail_encryption') ?: 'tls',
'mail.from.address' => $request->input('mail_from_address'),
'mail.from.name' => $request->input('mail_from_name'),
]);
\Illuminate\Support\Facades\Mail::raw("Halo!\n\nIni adalah email uji coba (test email) dari sistem biiskit.\n\nJika Anda menerima email ini, berarti konfigurasi SMTP Anda berfungsi dengan baik!\n\nDetail Konfigurasi:\n- Host: " . ($request->input('mail_host') ?: '-') . "\n- Port: " . ($request->input('mail_port') ?: '-') . "\n- Sender: " . ($request->input('mail_from_address') ?: '-') . "\n\nSalam,\nAdmin biiskit", function ($message) use ($request) {
$message->to($request->input('recipient'))
->subject("Uji Coba Konfigurasi SMTP - biiskit");
});
return response()->json([
'success' => true,
'message' => 'Email uji coba telah berhasil dikirim ke ' . $request->input('recipient')
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal mengirim email uji coba. Error: ' . $e->getMessage()
]);
}
}
}
@@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class TwoFactorController extends Controller
{
protected Google2FA $google2fa;
public function __construct()
{
$this->google2fa = new Google2FA();
}
/**
* Show 2FA setup page (generate QR code data).
*/
public function show()
{
$user = auth()->user();
// If not yet set up, generate a new secret
if (!$user->two_factor_secret) {
$secret = $this->google2fa->generateSecretKey();
$user->update(['two_factor_secret' => $secret]);
}
$secret = $user->two_factor_secret;
$otpUrl = $this->google2fa->getQRCodeUrl(
config('app.name'),
$user->email,
$secret
);
// Generate QR code as SVG using BaconQrCode
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$qrCode = (new \BaconQrCode\Writer($renderer))->writeString($otpUrl);
$qrCodeBase64 = 'data:image/svg+xml;base64,' . base64_encode($qrCode);
return Inertia::render('TwoFactor/Setup', [
'enabled' => !is_null($user->two_factor_confirmed_at),
'qr_code' => $qrCodeBase64,
'secret' => $secret,
'recovery_codes' => $user->two_factor_recovery_codes
? json_decode($user->two_factor_recovery_codes, true)
: [],
]);
}
/**
* Confirm & enable 2FA.
*/
public function enable(Request $request)
{
$request->validate([
'code' => 'required|string',
]);
$user = auth()->user();
$secret = $user->two_factor_secret;
$valid = $this->google2fa->verifyKey($secret, $request->code);
if (!$valid) {
return back()->withErrors(['code' => 'Invalid authentication code. Please try again.']);
}
// Generate recovery codes
$recoveryCodes = Collection::times(8, fn() => Str::random(10) . '-' . Str::random(10));
$user->update([
'two_factor_confirmed_at' => now(),
'two_factor_recovery_codes' => json_encode($recoveryCodes->toArray()),
]);
return back()->with('success', 'Two-Factor Authentication has been enabled successfully.');
}
/**
* Disable 2FA.
*/
public function disable(Request $request)
{
$request->validate([
'password' => 'required|current_password',
]);
auth()->user()->update([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
]);
return back()->with('success', 'Two-Factor Authentication has been disabled.');
}
/**
* Regenerate recovery codes.
*/
public function regenerateCodes()
{
$recoveryCodes = Collection::times(8, fn() => Str::random(10) . '-' . Str::random(10));
auth()->user()->update([
'two_factor_recovery_codes' => json_encode($recoveryCodes->toArray()),
]);
return back()->with('success', 'Recovery codes have been regenerated.');
}
/**
* Show the 2FA challenge screen (after login).
*/
public function challenge(Request $request)
{
if (!$request->session()->has('two_factor_user_id')) {
return redirect()->route('login');
}
return Inertia::render('TwoFactor/Challenge');
}
/**
* Verify the 2FA challenge code after login.
*/
public function verify(Request $request)
{
$request->validate([
'code' => 'required|string',
]);
$userId = $request->session()->get('two_factor_user_id');
if (!$userId) {
return redirect()->route('login');
}
$user = \App\Models\User::find($userId);
if (!$user || !$user->two_factor_secret) {
$request->session()->forget('two_factor_user_id');
return redirect()->route('login');
}
$code = $request->code;
$valid = $this->google2fa->verifyKey($user->two_factor_secret, $code);
if (!$valid) {
$recoveryCodes = json_decode($user->two_factor_recovery_codes ?? '[]', true);
if (in_array($code, $recoveryCodes)) {
$remaining = array_filter($recoveryCodes, fn($c) => $c !== $code);
$user->update(['two_factor_recovery_codes' => json_encode(array_values($remaining))]);
$valid = true;
}
}
if (!$valid) {
return back()->withErrors(['code' => 'Invalid code. Please try again.']);
}
$request->session()->forget('two_factor_user_id');
\Illuminate\Support\Facades\Auth::login($user);
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers;
use App\Exports\UsersExport;
use App\Imports\UsersImport;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index(Request $request)
{
$this->authorize('viewAny', User::class);
$trashed = $request->input('trashed');
$search = $request->input('search');
$status = $request->input('status');
$role = $request->input('role');
$sortField = $request->input('sort_field', 'created_at');
$sortDir = $request->input('sort_direction', 'desc');
$perPage = (int) $request->input('per_page', 15);
$query = User::with('roles');
if ($trashed === 'only') {
$query->onlyTrashed();
} elseif ($trashed === 'with') {
$query->withTrashed();
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($status) {
$query->where('status', $status);
}
if ($role) {
$query->role($role);
}
$allowedSortFields = ['first_name', 'last_name', 'email', 'status', 'created_at'];
if (!\in_array($sortField, $allowedSortFields, true)) {
$sortField = 'created_at';
}
$sortDir = $sortDir === 'asc' ? 'asc' : 'desc';
$users = $query->orderBy($sortField, $sortDir)
->paginate($perPage)
->withQueryString();
$roles = Role::where('guard_name', 'web')->pluck('name');
return Inertia::render('Users/Index', [
'users' => [
'data' => $users->items(),
'meta' => [
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
'total' => $users->total(),
'per_page' => $users->perPage(),
],
'links' => $users->linkCollection()->toArray(),
],
'filters' => $request->only(['search', 'status', 'role', 'sort_field', 'sort_direction', 'per_page', 'trashed']),
'availableRoles' => $roles,
]);
}
public function show(User $user)
{
$this->authorize('view', $user);
$user->load(['roles', 'permissions']);
return Inertia::render('Users/Show', [
'viewUser' => $user,
]);
}
public function store(Request $request)
{
$this->authorize('create', User::class);
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => ['required', Password::defaults()],
'status' => 'in:active,inactive',
'roles' => 'nullable|array',
'roles.*' => 'string|exists:roles,name',
]);
$user = User::create([
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'status' => $validated['status'] ?? 'active',
]);
if (!empty($validated['roles'])) {
$user->syncRoles($validated['roles']);
}
return back()->with('success', 'User created successfully.');
}
public function update(Request $request, User $user)
{
$this->authorize('update', $user);
$validated = $request->validate([
'first_name' => 'sometimes|string|max:255',
'last_name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $user->id,
'status' => 'sometimes|in:active,inactive',
'roles' => 'nullable|array',
'roles.*' => 'string|exists:roles,name',
]);
$roles = $validated['roles'] ?? null;
unset($validated['roles']);
$user->update($validated);
if ($roles !== null) {
$user->syncRoles($roles);
}
return back()->with('success', 'User updated successfully.');
}
public function destroy(User $user)
{
$this->authorize('delete', $user);
if ($user->id === auth()->id()) {
return back()->withErrors(['error' => 'You cannot delete your own account.']);
}
$user->delete();
return back()->with('success', 'Entity moved to archive.');
}
public function restore(int $id)
{
$user = User::withTrashed()->findOrFail($id);
$this->authorize('restore', $user);
$user->restore();
return back()->with('success', 'Entity restored from archive.');
}
public function forceDelete(int $id)
{
$user = User::withTrashed()->findOrFail($id);
$this->authorize('forceDelete', $user);
if ($user->id === auth()->id()) {
return back()->withErrors(['error' => 'You cannot delete your own account.']);
}
$user->forceDelete();
return back()->with('success', 'Entity permanently purged.');
}
public function bulkArchive(Request $request)
{
$this->authorize('user.delete');
$ids = array_filter(
(array) $request->input('ids', []),
fn ($id) => (int) $id !== auth()->id()
);
User::whereIn('id', $ids)->delete();
return back()->with('success', \count($ids) . ' users archived.');
}
public function bulkRestore(Request $request)
{
$this->authorize('user.delete');
$ids = (array) $request->input('ids', []);
User::withTrashed()->whereIn('id', $ids)->restore();
return back()->with('success', \count($ids) . ' users restored.');
}
public function bulkForceDelete(Request $request)
{
$this->authorize('user.delete');
$ids = array_filter(
(array) $request->input('ids', []),
fn ($id) => (int) $id !== auth()->id()
);
User::withTrashed()->whereIn('id', $ids)->forceDelete();
return back()->with('success', \count($ids) . ' users permanently deleted.');
}
public function export()
{
$this->authorize('viewAny', User::class);
return Excel::download(new UsersExport, 'users-' . now()->format('Y-m-d') . '.xlsx');
}
public function import(Request $request)
{
$this->authorize('create', User::class);
$request->validate([
'file' => 'required|mimes:xlsx,csv,xls|max:5120',
]);
Excel::import(new UsersImport, $request->file('file'));
return back()->with('success', 'Users imported successfully.');
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that is loaded on the first page visit.
*
* @var string
*/
protected $rootView = 'app';
/**
* Determine the current asset version.
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
$settings = \Illuminate\Support\Facades\Cache::rememberForever('system_settings', function() {
return \App\Models\Setting::pluck('value', 'key')->toArray();
});
// Inject default defaults if not in DB
$defaultSettings = [
'app_name' => 'biiskit',
'app_logo' => null,
'app_logo_text' => 'B',
'allow_registration' => '1',
];
$settings = array_merge($defaultSettings, $settings);
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
'roles' => $request->user()?->getRoleNames() ?? [],
],
'system_settings' => $settings,
'unread_notifications' => $request->user()
? \App\Models\NotificationLog::where('status', 'sent')
->where('created_at', '>=', now()->subDays(7))
->count()
: 0,
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'plain_text_token' => $request->session()->get('plain_text_token'),
],
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'bio' => ['nullable', 'string', 'max:1000'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'firstName' => $this->first_name,
'lastName' => $this->last_name,
'fullName' => $this->getFullName(),
'email' => $this->email,
'phone' => $this->phone,
'bio' => $this->bio,
'status' => $this->status,
'avatarUrl' => $this->avatar_url,
'roles' => $this->getRoleNames(),
'permissions' => $this->getAllPermissions()->pluck('name'),
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Imports;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
class UsersImport implements ToModel, WithHeadingRow, WithValidation
{
public function model(array $row)
{
return new User([
'first_name' => $row['first_name'],
'last_name' => $row['last_name'],
'email' => $row['email'],
'status' => $row['status'] ?? 'active',
'password' => Hash::make($row['password'] ?? 'password123'),
]);
}
public function rules(): array
{
return [
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NotificationLog extends Model
{
protected $fillable = [
'title',
'body',
'image_url',
'deep_link',
'target_type',
'target_user_id',
'sender_id',
'status',
'error_message',
];
public function targetUser()
{
return $this->belongsTo(User::class, 'target_user_id');
}
public function sender()
{
return $this->belongsTo(User::class, 'sender_id');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RemoteConfig extends Model
{
protected $fillable = [
'platform',
'latest_version',
'min_version',
'maintenance_mode',
'store_url',
];
protected function casts(): array
{
return [
'maintenance_mode' => 'boolean',
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
use HasFactory;
protected $fillable = ['key', 'value', 'type'];
/**
* Get setting value by key, cast properly.
*/
public static function get(string $key, $default = null)
{
$setting = self::where('key', $key)->first();
if (!$setting) {
return $default;
}
return match($setting->type) {
'boolean' => filter_var($setting->value, FILTER_VALIDATE_BOOLEAN),
'integer' => (int) $setting->value,
'json' => json_decode($setting->value, true),
default => $setting->value,
};
}
/**
* Set a setting value.
*/
public static function set(string $key, $value, string $type = 'string')
{
if (is_array($value)) {
$value = json_encode($value);
$type = 'json';
} elseif (is_bool($value)) {
$value = $value ? '1' : '0';
$type = 'boolean';
}
return self::updateOrCreate(
['key' => $key],
['value' => $value, 'type' => $type]
);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
#[Fillable(['first_name', 'last_name', 'email', 'phone', 'bio', 'password', 'status', 'avatar_url', 'meta', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasRoles, SoftDeletes, LogsActivity, HasApiTokens;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* PHP 8.4 Property Hooks (Polyfill for PHP 8.3 environment)
*/
public function getFullName(): string
{
return "{$this->first_name} {$this->last_name}";
}
public function isActive(): bool
{
return $this->status === 'active' && !$this->deleted_at;
}
/**
* PHP 8.4 Asymmetric Visibility (Polyfill)
*/
protected ?string $avatarUrl = null;
public function getAvatarUrl(): ?string
{
return $this->avatarUrl;
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'meta' => 'array',
'status' => 'string',
];
}
/**
* Sync avatarUrl with avatar_url attribute.
*/
public function setAvatar(string $path): void
{
$this->avatar_url = $path;
$this->avatarUrl = \Illuminate\Support\Facades\Storage::url($path);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $authUser): bool
{
return $authUser->hasPermissionTo('user.view');
}
public function view(User $authUser, User $user): bool
{
return $authUser->hasPermissionTo('user.view');
}
public function create(User $authUser): bool
{
return $authUser->hasPermissionTo('user.create');
}
public function update(User $authUser, User $user): bool
{
return $authUser->hasPermissionTo('user.edit');
}
public function delete(User $authUser, User $user): bool
{
return $authUser->hasPermissionTo('user.delete') && $authUser->id !== $user->id;
}
public function restore(User $authUser, User $user): bool
{
return $authUser->hasPermissionTo('user.delete');
}
public function forceDelete(User $authUser, User $user): bool
{
return $authUser->hasPermissionTo('user.delete') && $authUser->id !== $user->id;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Providers;
use App\Models\Setting;
use App\Models\User;
use App\Policies\UserPolicy;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Passport\Contracts\AuthorizationViewResponse;
use Laravel\Passport\Http\Responses\SimpleViewResponse;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
Passport::ignoreRoutes();
$this->app->bind(\Illuminate\Contracts\Auth\StatefulGuard::class, function () {
return \Illuminate\Support\Facades\Auth::guard('web');
});
$this->app->bind(AuthorizationViewResponse::class, function () {
return new SimpleViewResponse('passport::authorize');
});
}
public function boot(): void
{
Vite::prefetch(concurrency: 3);
Gate::policy(User::class, UserPolicy::class);
// super-admin bypasses all gates
Gate::before(function (User $user, string $ability) {
if ($user->hasRole('super-admin')) {
return true;
}
});
// Dynamic password rules driven by system settings
Password::defaults(function () {
$settings = Cache::get('system_settings', []);
$rule = Password::min((int) ($settings['password_minimum_length'] ?? 8));
if (filter_var($settings['password_require_symbols'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
$rule->symbols();
}
if (filter_var($settings['password_require_numbers'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
$rule->numbers();
}
if (filter_var($settings['password_require_mixed_case'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
$rule->mixedCase();
}
return $rule;
});
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
class ConfigServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
if ($this->app->runningInConsole()) {
return;
}
try {
$settings = Cache::rememberForever('system_settings', function () {
return Setting::all()->pluck('value', 'key')->toArray();
});
} catch (\Throwable $e) {
// DB not ready yet (e.g. first migration)
return;
}
if (empty($settings)) {
return;
}
if (!empty($settings['app_name'])) {
Config::set('app.name', $settings['app_name']);
}
if (!empty($settings['mail_host'])) {
Config::set([
'mail.mailers.smtp.host' => $settings['mail_host'],
'mail.mailers.smtp.port' => $settings['mail_port'] ?? '587',
'mail.mailers.smtp.username' => $settings['mail_username'] ?? '',
'mail.mailers.smtp.password' => $settings['mail_password'] ?? '',
'mail.mailers.smtp.encryption' => $settings['mail_encryption'] ?? 'tls',
'mail.from.address' => $settings['mail_from_address'] ?? 'hello@example.com',
'mail.from.name' => $settings['mail_from_name'] ?? ($settings['app_name'] ?? config('app.name')),
]);
}
if (!empty($settings['oauth_google_client_id'])) {
Config::set([
'services.google.client_id' => $settings['oauth_google_client_id'],
'services.google.client_secret' => $settings['oauth_google_client_secret'] ?? '',
'services.google.redirect' => url('/auth/google/callback'),
]);
}
if (!empty($settings['oauth_github_client_id'])) {
Config::set([
'services.github.client_id' => $settings['oauth_github_client_id'],
'services.github.client_secret' => $settings['oauth_github_client_secret'] ?? '',
'services.github.redirect' => url('/auth/github/callback'),
]);
}
}
}