first init

This commit is contained in:
ISMAIL MASSERAN
2026-06-08 11:37:14 +08:00
commit 94ecbe5887
1058 changed files with 87732 additions and 0 deletions
@@ -0,0 +1,91 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use App\Traits\NotifiesAdmins;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Modules\Auth\Entities\User;
use Modules\Role\Entities\Role;
use Modules\User\Notifications\UserActivationNotification;
use Modules\Auth\Services\EmailVerificationOtpService;
use Exception;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, NotifiesAdmins;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'ic_number' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:8'],
])->validate();
$user = User::create([
'name' => $input['name'],
'uuid' => Str::uuid(),
'email' => $input['email'],
'password' => Hash::make($input['password']),
'ic_number' => $input['ic_number'],
'status' => 'pending',
]);
// Assign role using Spatie permissions
$role = Role::where('name', 'Anggota')->first();
if ($role) {
$user->assignRole($role);
}
app(EmailVerificationOtpService::class)->send($user);
// Send notification to admins if user requires activation
if ($user->status === 'pending') {
$this->notifyAdminsForActivation($user);
}
return $user;
}
/**
* Notify admins about new user requiring activation
*/
private function notifyAdminsForActivation(User $newUser): void
{
try {
$adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT'];
// Get users with specific roles plus admins (PENTADBIR and DEVELOPER)
$adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles);
$sender = auth()->user() ?? $newUser; // Use current user as sender, or new user if no auth
foreach ($adminUsers as $admin) {
try {
$admin->notify(new UserActivationNotification($newUser, $sender));
} catch (Exception $e) {
Log::error('Failed to send user activation notification: '.$e->getMessage());
}
}
} catch (Exception $e) {
Log::error('Failed to notify admins for user activation: '.$e->getMessage());
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\LoginResponse as ContractsLoginResponse;
use Modules\Auth\Services\AuthSessionService;
use Modules\Auth\Services\EmailVerificationOtpService;
class LoginResponse implements ContractsLoginResponse
{
public function __construct(
protected AuthSessionService $authSession,
protected EmailVerificationOtpService $otpService,
) {}
public function toResponse($request): JsonResponse
{
$user = $request->user();
if (! $user->hasVerifiedEmail()) {
$this->otpService->send($user);
Auth::guard(config('fortify.guard'))->logout();
return response()->json([
'success' => true,
'message' => 'Sila semak e-mel anda untuk kod pengesahan 6 digit.',
'data' => [
'email' => $user->email,
'requires_email_verification' => true,
],
]);
}
return $this->authSession->createAuthResponse(
$user,
'Login successful'
);
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use App\Support\AuthCookie;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract;
class LogoutResponse implements LogoutResponseContract
{
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
*/
public function toResponse($request): JsonResponse
{
if ($request->cookie(AuthCookie::originalUserCookieName())) {
return response()->json([
'success' => false,
'message' => 'Sila tamatkan penyamaran sebelum log keluar.',
], 400);
}
$user = $request->user();
if ($user) {
// Delete all tokens for this user to ensure clean logout
$user->tokens()->delete();
}
$response = response()->json([
'success' => true,
'message' => 'Logout successful',
], 200);
return AuthCookie::clearAuthCookies($response);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request): JsonResponse
{
$email = $request->user()?->email;
Auth::guard(config('fortify.guard'))->logout();
return response()->json([
'success' => true,
'message' => 'Pendaftaran berjaya. Sila semak e-mel anda untuk kod pengesahan 6 digit.',
'data' => [
'email' => $email,
'requires_email_verification' => true,
],
], 201);
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
use Modules\Auth\Entities\User;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
use Modules\Auth\Entities\User;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
// 'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
// 'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}
@@ -0,0 +1,58 @@
<?php
namespace Modules\Auth\Actions\Fortify;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Modules\Auth\Entities\User;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
View File
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Auth',
];
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Auth\Database\Seeders;
use Illuminate\Database\Seeder;
class AuthDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// $this->call([]);
}
}
View File
@@ -0,0 +1,35 @@
<?php
namespace Modules\Auth\Emails;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class EmailVerificationOtpEmail extends Notification
{
use Queueable;
public function __construct(
protected string $otp
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$minutes = (int) config('auth.email_verification.expiry_minutes', 10);
return (new MailMessage)
->subject('Pengesahan E-mel - Kod OTP')
->markdown('auth::emails.verification-otp', [
'name' => $notifiable->name,
'otp' => $this->otp,
'minutes' => $minutes,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Modules\Auth\Entities;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailVerificationOtp extends Model
{
use HasUuids;
protected $table = 'email_verification_otps';
protected $fillable = [
'user_id',
'code',
'expires_at',
'attempts',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'attempts' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function hasExceededMaxAttempts(): bool
{
return $this->attempts >= (int) config('auth.email_verification.max_attempts', 5);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace Modules\Auth\Entities;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Permission\Contracts\Permission;
use Spatie\Permission\PermissionRegistrar;
use Spatie\Permission\Traits\HasPermissions;
use Spatie\Permission\Traits\HasRoles;
use App\Services\ActiveRoleService;
use App\Traits\HasVisibility;
use Lab404\Impersonate\Models\Impersonate;
use Modules\Role\Entities\Role;
class User extends Authenticatable
{
/** @use HasFactory<\Modules\Auth\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasPermissions, HasRoles, HasUuids, Impersonate, LogsActivity, Notifiable, SoftDeletes, HasVisibility;
protected $table = 'users';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'ic_number',
'position',
'phone_number',
'image_url',
'status',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'status' => 'string',
];
}
/**
* Role used for permission checks in the current session (Sanctum token).
*/
public function activeRole(): ?Role
{
return ActiveRoleService::getActiveRole($this);
}
public function hasPermissionAction($action): bool
{
$role = $this->activeRole();
return $role && $role->permission_actions &&
in_array($action, $role->permission_actions, true);
}
public function getActivityLogOptions(): LogOptions
{
return LogOptions::defaults()
->logAll()
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn (string $eventName) => "User {$this->name} was {$eventName}");
}
/**
* Override hasPermissionTo to bypass all permission checks for DEVELOPER role
*/
public function hasPermissionTo($permission, $guardName = null): bool
{
$activeRole = $this->activeRole();
if ($activeRole?->name === 'DEVELOPER') {
return true;
}
static $isCheckingPermission = false;
if ($isCheckingPermission) {
return false;
}
$isCheckingPermission = true;
try {
$permissionClass = app(PermissionRegistrar::class)->getPermissionClass();
if (is_string($permission)) {
$permission = $permissionClass::findByName($permission, $guardName ?? 'api');
}
if (is_int($permission)) {
$permission = $permissionClass::findById($permission, $guardName ?? 'api');
}
if (! $permission instanceof Permission) {
return false;
}
if ($this->permissions->contains('id', $permission->id)) {
return true;
}
if (! $activeRole) {
return false;
}
$activeRole->loadMissing('permissions');
return $activeRole->permissions->contains('id', $permission->id);
} catch (\Exception $e) {
return false;
} finally {
$isCheckingPermission = false;
}
}
/**
* Scope to exclude current user unless they have developer role
*/
public function scopeExcludeCurrentUserUnlessDeveloper($query, $currentUser = null)
{
$currentUser = $currentUser ?? auth()->user();
if ($currentUser && ! $currentUser->hasRole('DEVELOPER')) {
return $query->where('id', '!=', $currentUser->id);
}
return $query;
}
/**
* Scope to exclude users with DEVELOPER role unless current user is DEVELOPER
*/
public function scopeExcludeDevelopersUnlessDeveloper($query, $currentUser = null)
{
$currentUser = $currentUser ?? auth()->user();
if ($currentUser && ! $currentUser->hasRole('DEVELOPER')) {
return $query->whereDoesntHave('roles', function ($q) {
$q->where('name', 'DEVELOPER');
});
}
return $query;
}
/**
* Check if the user can impersonate another user
*/
public function canImpersonate(): bool
{
// Check permission instead of hardcoded roles
return $this->hasPermissionTo('menyamar pengguna');
}
/**
* Check if the user can be impersonated
*/
public function canBeImpersonated(): bool
{
// DEVELOPER cannot be impersonated by anyone
if ($this->hasRole('DEVELOPER')) {
return false;
}
// Check if the current user has permission to impersonate
$currentUser = auth()->user();
if (!$currentUser) {
return false;
}
// Users with permission can impersonate other users (except DEVELOPER)
return $currentUser->hasPermissionTo('menyamar pengguna');
}
public function hasVerifiedEmail(): bool
{
return $this->email_verified_at !== null;
}
/**
* Check if user can login based on their status
*/
public function canLogin(): bool
{
return $this->status === 'active';
}
/**
* Whether credentials are valid for issuing a session (includes verified pending users).
*/
public function canAuthenticate(): bool
{
if ($this->canLogin()) {
return true;
}
return $this->hasVerifiedEmail() && $this->status === 'pending';
}
/**
* Get the login restriction message based on user status
*/
public function getLoginRestrictionMessage(): ?string
{
switch ($this->status) {
case 'pending':
return 'Akaun anda sedang menunggu pengaktifan dari pentadbir sistem. Sila hubungi pentadbir sistem.';
case 'inactive':
return 'Akaun anda tidak aktif. Sila hubungi pentadbir sistem.';
default:
return 'Akaun anda tidak dapat mengakses sistem. Sila hubungi pentadbir sistem.';
}
}
}
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Auth\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Auth\Entities\User;
use Modules\Auth\Services\AuthSessionService;
use Modules\Auth\Services\EmailVerificationOtpService;
class EmailVerificationController extends Controller
{
public function __construct(
protected EmailVerificationOtpService $otpService,
protected AuthSessionService $authSession
) {}
public function verify(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
'otp' => ['required', 'string', 'digits:6'],
]);
$user = User::where('email', $validated['email'])->first();
if (! $user) {
return response()->json([
'success' => false,
'message' => 'Kod OTP tidak sah.',
], 422);
}
$this->otpService->verify($user, $validated['otp']);
$user->refresh();
return $this->authSession->createAuthResponse(
$user,
'E-mel anda telah berjaya disahkan. Akaun anda sedang menunggu pengaktifan daripada pentadbir sistem.'
);
}
public function resend(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'email'],
]);
$user = User::where('email', $validated['email'])->first();
if ($user && ! $user->hasVerifiedEmail()) {
$this->otpService->send($user);
}
return response()->json([
'success' => true,
'message' => 'Jika e-mel wujud dan belum disahkan, kod OTP baharu telah dihantar.',
]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Modules\Auth\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Services\ActiveRoleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Auth\Transformers\UserResource;
class SessionController extends Controller
{
/**
* Current authenticated user with active role metadata.
*/
public function currentUser(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json([
'success' => false,
'message' => 'Unauthenticated',
], 401);
}
$user->load(['roles.permissions']);
return response()->json([
'success' => true,
'data' => new UserResource($user),
...ActiveRoleService::sessionMeta($user),
]);
}
/**
* Switch active role for the current session token.
*/
public function switchRole(Request $request): JsonResponse
{
$validated = $request->validate([
'role_id' => ['required', 'uuid', 'exists:roles,id'],
]);
$user = $request->user();
$user->load(['roles.permissions']);
$role = ActiveRoleService::switchRole($user, $validated['role_id']);
if (! $role) {
return response()->json([
'success' => false,
'message' => 'Peranan tidak ditetapkan untuk pengguna ini.',
], 403);
}
return response()->json([
'success' => true,
'message' => 'Peranan aktif dikemas kini.',
'data' => new UserResource($user),
'active_role' => ActiveRoleService::formatRole($role),
'can_switch_role' => $user->roles->count() > 1,
'redirect_path' => ActiveRoleService::redirectPathForRole($role),
]);
}
}
View File
View File
@@ -0,0 +1,171 @@
<?php
namespace Modules\Auth\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
use Modules\Auth\Actions\Fortify\CreateNewUser;
use Modules\Auth\Actions\Fortify\ResetUserPassword;
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class AuthServiceProvider extends ServiceProvider
{
use PathNamespace;
protected string $name = 'Auth';
protected string $nameLower = 'auth';
/**
* Boot the application events.
*/
public function boot(): void
{
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->loadMigrationsFrom(module_path($this->name, 'database/migrations'));
$this->registerFortify();
}
/**
* Register the service provider.
*/
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
}
/**
* Register commands in the format of Command::class
*/
protected function registerCommands(): void
{
// $this->commands([]);
}
/**
* Register command Schedules.
*/
protected function registerCommandSchedules(): void
{
// $this->app->booted(function () {
// $schedule = $this->app->make(Schedule::class);
// $schedule->command('inspire')->hourly();
// });
}
/**
* Register translations.
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.$this->nameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->nameLower);
$this->loadJsonTranslationsFrom($langPath);
} else {
$this->loadTranslationsFrom(module_path($this->name, 'lang'), $this->nameLower);
$this->loadJsonTranslationsFrom(module_path($this->name, 'lang'));
}
}
/**
* Register config.
*/
protected function registerConfig(): void
{
$configPath = module_path($this->name, config('modules.paths.generator.config.path'));
if (is_dir($configPath)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config);
$segments = explode('.', $this->nameLower.'.'.$config_key);
// Remove duplicated adjacent segments
$normalized = [];
foreach ($segments as $segment) {
if (end($normalized) !== $segment) {
$normalized[] = $segment;
}
}
$key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized);
$this->publishes([$file->getPathname() => config_path($config)], 'config');
$this->merge_config_from($file->getPathname(), $key);
}
}
}
}
/**
* Merge config from the given path recursively.
*/
protected function merge_config_from(string $path, string $key): void
{
$existing = config($key, []);
$module_config = require $path;
config([$key => array_replace_recursive($existing, $module_config)]);
}
/**
* Register views.
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->nameLower);
$sourcePath = module_path($this->name, 'resources/views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower);
Blade::componentNamespace(config('modules.namespace').'\\'.$this->name.'\\View\\Components', $this->nameLower);
}
/**
* Get the services provided by the provider.
*/
public function provides(): array
{
return [];
}
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (config('view.paths') as $path) {
if (is_dir($path.'/modules/'.$this->nameLower)) {
$paths[] = $path.'/modules/'.$this->nameLower;
}
}
return $paths;
}
/**
* Register Fortify actions.
*/
protected function registerFortify(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Auth\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Auth\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected $moduleNamespace = 'Modules\Auth\Http\Controllers';
public function boot()
{
parent::boot();
}
public function map()
{
$this->mapApiRoutes();
}
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Auth', '/Routes/web.php'));
}
protected function mapApiRoutes()
{
Route::middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Auth', '/Routes/api.php'));
}
}
View File
+27
View File
@@ -0,0 +1,27 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Auth\Http\Controllers\EmailVerificationController;
use Modules\Auth\Http\Controllers\SessionController;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
// Auth
Route::post('/register', [RegisteredUserController::class, 'store']);
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
Route::post('/verify-email', [EmailVerificationController::class, 'verify'])
->middleware('throttle:email-verification');
Route::post('/verify-email/resend', [EmailVerificationController::class, 'resend'])
->middleware('throttle:email-verification-resend');
Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
// get current user
Route::get('/me', [SessionController::class, 'currentUser']);
// switch active role
Route::post('/active-role/switch', [SessionController::class, 'switchRole']);
// logout
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']);
});
View File
@@ -0,0 +1,49 @@
<?php
namespace Modules\Auth\Services;
use App\Services\ActiveRoleService;
use App\Support\AuthCookie;
use Illuminate\Http\JsonResponse;
use Modules\Auth\Entities\User;
use Modules\Auth\Transformers\UserResource;
class AuthSessionService
{
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
{
if (config('app.env') !== 'local') {
$user->tokens()->delete();
}
$token = $user->createToken(
name: 'authToken',
abilities: ['*'],
expiresAt: now()->addMinutes((int) config('auth_cookie.lifetime_minutes', 720))
);
$user->load(['roles.permissions']);
ActiveRoleService::assignDefaultToToken($user, $token->accessToken);
$payload = [
'success' => true,
'message' => $message,
'data' => [
'user' => new UserResource($user),
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'expires_at' => $token->accessToken->expires_at,
],
...ActiveRoleService::sessionMeta($user),
];
if (AuthCookie::shouldExposeTokenInResponse()) {
$payload['data']['token'] = $token->plainTextToken;
}
$response = response()->json($payload, $status);
return AuthCookie::attachAuthToken($response, $token->plainTextToken);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Modules\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Modules\Auth\Entities\EmailVerificationOtp;
use Modules\Auth\Entities\User;
use Modules\Auth\Emails\EmailVerificationOtpEmail;
class EmailVerificationOtpService
{
public function send(User $user): void
{
if ($user->hasVerifiedEmail()) {
return;
}
$otp = $this->generateOtp();
EmailVerificationOtp::query()
->where('user_id', $user->id)
->delete();
EmailVerificationOtp::create([
'user_id' => $user->id,
'code' => Hash::make($otp),
'expires_at' => now()->addMinutes($this->expiryMinutes()),
'attempts' => 0,
]);
$user->notify(new EmailVerificationOtpEmail($otp));
}
public function verify(User $user, string $otp): void
{
if ($user->hasVerifiedEmail()) {
throw ValidationException::withMessages([
'email' => ['E-mel anda telah disahkan.'],
]);
}
$record = EmailVerificationOtp::query()
->where('user_id', $user->id)
->latest()
->first();
if (! $record) {
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'],
]);
}
if ($record->isExpired()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'],
]);
}
if ($record->hasExceededMaxAttempts()) {
$record->delete();
throw ValidationException::withMessages([
'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'],
]);
}
if (! Hash::check($otp, $record->code)) {
$record->increment('attempts');
if ($record->fresh()->hasExceededMaxAttempts()) {
$record->delete();
}
throw ValidationException::withMessages([
'otp' => ['Kod OTP tidak sah.'],
]);
}
$user->forceFill(['email_verified_at' => now()])->save();
EmailVerificationOtp::query()
->where('user_id', $user->id)
->delete();
}
protected function generateOtp(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
protected function expiryMinutes(): int
{
return (int) config('auth.email_verification.expiry_minutes', 10);
}
}
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AuthResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'user' => new UserResource($this->resource),
'token' => $this->token ?? null,
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Modules\Position\Transformers\PositionResource;
use Modules\Rank\Transformers\RankResource;
use Modules\Unit\Transformers\UnitResource;
class SSOUserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'uuid' => $this->uuid,
'name' => $this->name,
'email' => $this->email,
'army_number' => $this->army_number,
'unit_id' => $this->unit_id,
'rank_id' => $this->rank_id,
'position_id' => $this->position_id,
'phone_number' => $this->phone_number,
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
'status' => $this->status,
'token' => $this->when(isset($this->token), $this->token),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'roles' => $this->whenLoaded('roles', function () {
return $this->roles->map(function ($role) {
return [
'id' => $role->id,
'name' => $role->name,
'guard_name' => $role->guard_name,
'permissions' => $role->permissions ? $role->permissions->map(function ($permission) {
return [
'id' => $permission->id,
'name' => $permission->name,
'guard_name' => $permission->guard_name,
'route_name' => $permission->route_name ?? null,
'created_at' => $permission->created_at,
'updated_at' => $permission->updated_at,
];
}) : [],
'created_at' => $role->created_at,
'updated_at' => $role->updated_at,
];
});
}),
'unit' => new UnitResource($this->whenLoaded('unit')),
'rank' => new RankResource($this->whenLoaded('rank')),
'position' => new PositionResource($this->whenLoaded('position')),
];
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Auth\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'ic_number' => $this->ic_number,
'position' => $this->position,
'phone_number' => $this->phone_number,
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
'status' => $this->status,
'two_factor_secret' => $this->two_factor_secret,
'two_factor_recovery_codes' => $this->two_factor_recovery_codes,
'two_factor_confirmed_at' => $this->two_factor_confirmed_at,
'email_verified_at' => $this->email_verified_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
'roles' => $this->whenLoaded('roles', function () {
return $this->roles->map(function ($role) {
return [
'id' => $role->id,
'name' => $role->name,
'fullname' => $role->fullname ?? null,
'context' => $role->context ?? 'member',
'guard_name' => $role->guard_name,
'permissions' => $this->when($role->relationLoaded('permissions'), function () use ($role) {
return $role->permissions->map(function ($permission) {
return [
'id' => $permission->id,
'name' => $permission->name,
'guard_name' => $permission->guard_name,
'route_name' => $permission->route_name,
'created_at' => $permission->created_at,
'updated_at' => $permission->updated_at,
];
});
}),
'created_at' => $role->created_at,
'updated_at' => $role->updated_at,
];
});
}),
];
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "nwidart/auth",
"description": "",
"authors": [
{
"name": "Nicolas Widart",
"email": "n.widart@gmail.com"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\Auth\\": "App",
"Modules\\Auth\\Database\\Factories\\": "database/factories/",
"Modules\\Auth\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Auth\\Tests\\": "tests/"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "Auth",
"alias": "auth",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Auth\\Providers\\AuthServiceProvider"
],
"files": []
}
+15
View File
@@ -0,0 +1,15 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"axios": "^1.1.2",
"laravel-vite-plugin": "^0.7.5",
"sass": "^1.69.5",
"postcss": "^8.3.7",
"vite": "^4.0.0"
}
}
@@ -0,0 +1,26 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Pengesahan E-mel
Assalamualaikum **{{ $name }}**,
Terima kasih kerana mendaftar. Gunakan kod OTP di bawah untuk mengesahkan alamat e-mel anda.
@component('mail::panel')
<div style="text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 6px;">
{{ $otp }}
</div>
@endcomponent
Kod ini akan tamat tempoh dalam **{{ $minutes }} minit**.
Jika anda tidak membuat pendaftaran ini, abaikan e-mel ini.
Terima kasih,<br>
{{ config('app.name') }}
@endcomponent