From 8a87fd37bfe4610963dd6388aea44177f9fad0a9 Mon Sep 17 00:00:00 2001 From: Lupu Gheorghe Date: Wed, 7 Oct 2026 11:25:58 +0300 Subject: [PATCH] Add approved MAI and ONG accounts with restricted resource access. Co-authored-by: Cursor --- app/Actions/ApproveAccountRequest.php | 31 +++ app/Actions/Fortify/CreateNewUser.php | 40 ++-- app/Console/Commands/ExpireAccounts.php | 36 ++++ app/Enums/AccountApprovalStatus.php | 15 ++ app/Enums/ResourceAccess.php | 25 +++ app/Enums/UserRole.php | 5 +- .../AccountRequestResource.php | 56 ++++++ .../Pages/ListAccountRequests.php | 11 ++ .../Tables/AccountRequestsTable.php | 41 ++++ .../Resources/Schemas/ResourceForm.php | 7 + .../Controllers/AccountAccessController.php | 45 +++++ app/Http/Responses/LoginResponse.php | 4 + app/Http/Responses/RegisterResponse.php | 5 +- app/Models/Resource.php | 31 +++ app/Models/User.php | 46 +++-- app/Notifications/AccountApproved.php | 33 ++++ app/Notifications/AccountRejected.php | 25 +++ app/Notifications/AccountRenewal.php | 33 ++++ app/Notifications/AccountRequested.php | 33 ++++ app/Providers/FortifyServiceProvider.php | 14 +- database/factories/UserFactory.php | 13 ++ ...nt_approval_and_resource_access_levels.php | 44 +++++ lang/en/admin.php | 8 + lang/en/auth.php | 37 +++- lang/en/enums.php | 12 ++ lang/ro/admin.php | 8 + lang/ro/auth.php | 35 +++- lang/ro/enums.php | 12 ++ resources/views/account/password.blade.php | 23 +++ .../views/components/layout/header.blade.php | 22 +++ .../\342\232\241account-requested.blade.php" | 16 ++ .../pages/\342\232\241account.blade.php" | 108 +++++++++++ .../views/pages/\342\232\241home.blade.php" | 2 +- .../pages/\342\232\241register.blade.php" | 65 +++---- .../\342\232\241resources-index.blade.php" | 3 +- .../\342\232\241resources-show.blade.php" | 4 +- routes/console.php | 3 + routes/web.php | 15 +- tests/Feature/AccountAccessTest.php | 178 ++++++++++++++++++ tests/Feature/AuthTest.php | 34 +--- 40 files changed, 1061 insertions(+), 117 deletions(-) create mode 100644 app/Actions/ApproveAccountRequest.php create mode 100644 app/Console/Commands/ExpireAccounts.php create mode 100644 app/Enums/AccountApprovalStatus.php create mode 100644 app/Enums/ResourceAccess.php create mode 100644 app/Filament/Resources/AccountRequests/AccountRequestResource.php create mode 100644 app/Filament/Resources/AccountRequests/Pages/ListAccountRequests.php create mode 100644 app/Filament/Resources/AccountRequests/Tables/AccountRequestsTable.php create mode 100644 app/Http/Controllers/AccountAccessController.php create mode 100644 app/Notifications/AccountApproved.php create mode 100644 app/Notifications/AccountRejected.php create mode 100644 app/Notifications/AccountRenewal.php create mode 100644 app/Notifications/AccountRequested.php create mode 100644 database/migrations/2026_10_07_082117_add_account_approval_and_resource_access_levels.php create mode 100644 resources/views/account/password.blade.php create mode 100644 "resources/views/pages/\342\232\241account-requested.blade.php" create mode 100644 "resources/views/pages/\342\232\241account.blade.php" create mode 100644 tests/Feature/AccountAccessTest.php diff --git a/app/Actions/ApproveAccountRequest.php b/app/Actions/ApproveAccountRequest.php new file mode 100644 index 0000000..8ec7de7 --- /dev/null +++ b/app/Actions/ApproveAccountRequest.php @@ -0,0 +1,31 @@ +update([ + 'approval_status' => AccountApprovalStatus::Approved, + 'verified_at' => now(), + 'expires_at' => now()->addYear(), + ]); + + $user->notify(new AccountApproved); + } + + public function reject(User $user): void + { + $user->update([ + 'approval_status' => AccountApprovalStatus::Rejected, + ]); + + $user->notify(new AccountRejected); + } +} diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 4d5aa34..7ae2c9e 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,22 +2,21 @@ namespace App\Actions\Fortify; -use App\Enums\ProfessionalRole; +use App\Enums\AccountApprovalStatus; use App\Enums\UserRole; use App\Models\User; +use App\Notifications\AccountRequested; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Laravel\Fortify\Contracts\CreatesNewUsers; class CreateNewUser implements CreatesNewUsers { - use PasswordValidationRules; - /** - * Validate and create a newly registered user. - * - * @param array $input + * @param array $input * * @throws ValidationException */ @@ -25,29 +24,32 @@ public function create(array $input): User { Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], - 'email' => [ - 'required', - 'string', - 'email', - 'max:255', - Rule::unique(User::class), - ], + 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)], 'organization' => ['required', 'string', 'max:255'], - 'professional_role' => ['required', Rule::enum(ProfessionalRole::class)], - 'password' => $this->passwordRules(), + 'reference_phone' => ['required', 'string', 'max:50'], + 'account_role' => ['required', Rule::in([UserRole::Mai->value, UserRole::Ngo->value])], 'terms' => ['accepted'], 'locale' => ['nullable', 'string', Rule::in(config('cpc.supported_locales', ['ro', 'en']))], ])->validate(); - return User::create([ + $user = User::query()->create([ 'name' => $input['name'], 'email' => $input['email'], - 'password' => $input['password'], + 'password' => Str::password(32), 'organization' => $input['organization'], - 'professional_role' => $input['professional_role'], - 'role' => UserRole::Professional, + 'reference_phone' => $input['reference_phone'], + 'professional_role' => null, + 'role' => $input['account_role'], + 'approval_status' => AccountApprovalStatus::Pending, 'verified_at' => null, 'locale' => $input['locale'] ?? session('locale', config('cpc.default_locale', 'ro')), ]); + + Notification::send( + User::query()->where('role', UserRole::Admin)->get(), + new AccountRequested($user), + ); + + return $user; } } diff --git a/app/Console/Commands/ExpireAccounts.php b/app/Console/Commands/ExpireAccounts.php new file mode 100644 index 0000000..4dcc2c9 --- /dev/null +++ b/app/Console/Commands/ExpireAccounts.php @@ -0,0 +1,36 @@ +whereIn('role', [UserRole::Mai, UserRole::Ngo]) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()) + ->whereNull('renewal_notified_at') + ->each(function (User $user): void { + $user->notify(new AccountRenewal); + $user->update(['renewal_notified_at' => now()]); + }); + + User::query() + ->whereIn('role', [UserRole::Mai, UserRole::Ngo]) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()->subMonth()) + ->delete(); + + return self::SUCCESS; + } +} diff --git a/app/Enums/AccountApprovalStatus.php b/app/Enums/AccountApprovalStatus.php new file mode 100644 index 0000000..971f5e6 --- /dev/null +++ b/app/Enums/AccountApprovalStatus.php @@ -0,0 +1,15 @@ +value); + } +} diff --git a/app/Enums/ResourceAccess.php b/app/Enums/ResourceAccess.php new file mode 100644 index 0000000..82a750f --- /dev/null +++ b/app/Enums/ResourceAccess.php @@ -0,0 +1,25 @@ +value); + } + + /** + * @return array + */ + public static function options(): array + { + return collect(self::cases()) + ->mapWithKeys(fn (self $access): array => [$access->value => $access->label()]) + ->all(); + } +} diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php index 610c8a7..bbff7b9 100644 --- a/app/Enums/UserRole.php +++ b/app/Enums/UserRole.php @@ -7,6 +7,8 @@ enum UserRole: string case Admin = 'admin'; case Editor = 'editor'; case Professional = 'professional'; + case Mai = 'mai'; + case Ngo = 'ngo'; public function label(): string { @@ -18,8 +20,7 @@ public function label(): string */ public static function options(): array { - return collect(self::cases()) - ->reject(fn (self $role): bool => $role === self::Professional) + return collect([self::Admin, self::Editor]) ->mapWithKeys(fn (self $role): array => [$role->value => $role->label()]) ->all(); } diff --git a/app/Filament/Resources/AccountRequests/AccountRequestResource.php b/app/Filament/Resources/AccountRequests/AccountRequestResource.php new file mode 100644 index 0000000..7c6be93 --- /dev/null +++ b/app/Filament/Resources/AccountRequests/AccountRequestResource.php @@ -0,0 +1,56 @@ +components([]); + } + + public static function table(Table $table): Table + { + return AccountRequestsTable::configure($table); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->where('approval_status', AccountApprovalStatus::Pending); + } + + public static function getPages(): array + { + return [ + 'index' => ListAccountRequests::route('/'), + ]; + } + + protected static function translationKey(): string + { + return 'account_requests'; + } +} diff --git a/app/Filament/Resources/AccountRequests/Pages/ListAccountRequests.php b/app/Filament/Resources/AccountRequests/Pages/ListAccountRequests.php new file mode 100644 index 0000000..2126ee1 --- /dev/null +++ b/app/Filament/Resources/AccountRequests/Pages/ListAccountRequests.php @@ -0,0 +1,11 @@ +columns([ + TextColumn::make('name')->searchable(), + TextColumn::make('email')->searchable(), + TextColumn::make('organization')->searchable(), + TextColumn::make('reference_phone'), + TextColumn::make('role') + ->formatStateUsing(function (UserRole|string $state): string { + $role = $state instanceof UserRole ? $state : UserRole::from($state); + + return $role->label(); + }), + TextColumn::make('created_at')->dateTime()->sortable(), + ]) + ->recordActions([ + Action::make('approve') + ->label(__('admin.fields.approve')) + ->action(fn (User $record) => app(ApproveAccountRequest::class)->approve($record)), + Action::make('reject') + ->label(__('admin.fields.reject')) + ->color('danger') + ->action(fn (User $record) => app(ApproveAccountRequest::class)->reject($record)), + ]) + ->defaultSort('created_at', 'desc'); + } +} diff --git a/app/Filament/Resources/Resources/Schemas/ResourceForm.php b/app/Filament/Resources/Resources/Schemas/ResourceForm.php index bf9d15b..d0d5c40 100644 --- a/app/Filament/Resources/Resources/Schemas/ResourceForm.php +++ b/app/Filament/Resources/Resources/Schemas/ResourceForm.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Resources\Schemas; +use App\Enums\ResourceAccess; use App\Enums\ResourceStatus; use App\Enums\ResourceType; use Filament\Forms\Components\DateTimePicker; @@ -41,6 +42,12 @@ public static function configure(Schema $schema): Schema Select::make('type') ->options(ResourceType::options()) ->required(), + Select::make('access_levels') + ->label(__('admin.fields.access_levels')) + ->options(ResourceAccess::options()) + ->multiple() + ->required() + ->default([ResourceAccess::Public->value]), Select::make('resource_category_id') ->relationship('resourceCategory', 'name_ro') ->searchable() diff --git a/app/Http/Controllers/AccountAccessController.php b/app/Http/Controllers/AccountAccessController.php new file mode 100644 index 0000000..c919a30 --- /dev/null +++ b/app/Http/Controllers/AccountAccessController.php @@ -0,0 +1,45 @@ + $user, + ]); + } + + public function storePassword(Request $request, string $locale, User $user): RedirectResponse + { + $validated = $request->validate([ + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $user->update([ + 'password' => $validated['password'], + ]); + + Auth::login($user); + + return redirect(localized_route('account.index', [], $user->locale)); + } + + public function renew(Request $request, string $locale, User $user): RedirectResponse + { + $user->update([ + 'expires_at' => now()->addYear(), + 'renewal_notified_at' => null, + ]); + + return redirect(localized_route('home', [], $user->locale)) + ->with('status', __('auth.renewed')); + } +} diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php index 2a75a48..377efe4 100644 --- a/app/Http/Responses/LoginResponse.php +++ b/app/Http/Responses/LoginResponse.php @@ -19,6 +19,10 @@ public function toResponse($request): Response ?? session('locale') ?? config('cpc.default_locale', 'ro'); + if ($user?->isApprovedOrganizationAccount()) { + return redirect()->intended(localized_route('account.index', [], $locale)); + } + if ($user?->isProfessional() && $user->verified_at === null) { return redirect()->intended(localized_route('auth.pending', [], $locale)); } diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php index 566628f..859a193 100644 --- a/app/Http/Responses/RegisterResponse.php +++ b/app/Http/Responses/RegisterResponse.php @@ -3,6 +3,7 @@ namespace App\Http\Responses; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Auth; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,8 @@ public function toResponse($request): Response ?? session('locale') ?? config('cpc.default_locale', 'ro'); - return redirect(localized_route('auth.pending', [], $locale)); + Auth::logout(); + + return redirect(localized_route('auth.requested', [], $locale)); } } diff --git a/app/Models/Resource.php b/app/Models/Resource.php index 2cf07e6..bb94702 100644 --- a/app/Models/Resource.php +++ b/app/Models/Resource.php @@ -3,8 +3,10 @@ namespace App\Models; use App\Concerns\HasTranslations; +use App\Enums\ResourceAccess; use App\Enums\ResourceStatus; use App\Enums\ResourceType; +use App\Enums\UserRole; use Database\Factories\ResourceFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; @@ -27,6 +29,7 @@ 'download_url', 'video_url', 'featured', + 'access_levels', 'status', 'published_at', ])] @@ -45,6 +48,7 @@ protected function casts(): array 'status' => ResourceStatus::class, 'tags' => 'array', 'featured' => 'boolean', + 'access_levels' => 'array', 'published_at' => 'datetime', ]; } @@ -76,6 +80,33 @@ public function scopeFeatured(Builder $query): Builder return $query->where('featured', true); } + /** + * @param Builder $query + * @return Builder + */ + public function scopeVisibleTo(Builder $query, ?User $user): Builder + { + if ($user?->isAdmin() || $user?->isEditor()) { + return $query; + } + + $levels = [ResourceAccess::Public->value]; + + if ($user?->isApprovedOrganizationAccount()) { + $levels[] = $user->role === UserRole::Mai + ? ResourceAccess::Mai->value + : ResourceAccess::Ngo->value; + } + + return $query->where(function (Builder $query) use ($levels): void { + $query->whereNull('access_levels'); + + foreach ($levels as $level) { + $query->orWhereJsonContains('access_levels', $level); + } + }); + } + public function getRouteKeyName(): string { return 'slug'; diff --git a/app/Models/User.php b/app/Models/User.php index c15a97c..7a7ed40 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AccountApprovalStatus; use App\Enums\ProfessionalRole; use App\Enums\UserRole; use Database\Factories\UserFactory; @@ -17,19 +18,19 @@ use Illuminate\Support\Str; /** - * @property int $id - * @property string $name - * @property string $email - * @property Carbon|null $email_verified_at - * @property string $password - * @property UserRole $role - * @property string|null $organization + * @property int $id + * @property string $name + * @property string $email + * @property Carbon|null $email_verified_at + * @property string $password + * @property UserRole $role + * @property string|null $organization * @property ProfessionalRole|null $professional_role - * @property Carbon|null $verified_at - * @property string $locale - * @property string|null $remember_token - * @property Carbon|null $created_at - * @property Carbon|null $updated_at + * @property Carbon|null $verified_at + * @property string $locale + * @property string|null $remember_token + * @property Carbon|null $created_at + * @property Carbon|null $updated_at */ #[Fillable([ 'name', @@ -37,8 +38,12 @@ 'password', 'role', 'organization', + 'reference_phone', 'professional_role', 'verified_at', + 'approval_status', + 'expires_at', + 'renewal_notified_at', 'locale', ])] #[Hidden(['password', 'remember_token'])] @@ -46,6 +51,7 @@ class User extends Authenticatable implements FilamentUser { /** @use HasFactory */ use HasFactory; + use Notifiable; /** @@ -58,6 +64,9 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'verified_at' => 'datetime', + 'expires_at' => 'datetime', + 'renewal_notified_at' => 'datetime', + 'approval_status' => AccountApprovalStatus::class, 'password' => 'hashed', 'role' => UserRole::class, 'professional_role' => ProfessionalRole::class, @@ -95,6 +104,17 @@ public function isVerifiedProfessional(): bool return $this->isProfessional() && $this->verified_at !== null; } + public function isOrganizationAccount(): bool + { + return in_array($this->role, [UserRole::Mai, UserRole::Ngo], true); + } + + public function isApprovedOrganizationAccount(): bool + { + return $this->isOrganizationAccount() + && $this->approval_status === AccountApprovalStatus::Approved; + } + /** * Get the user's initials. */ @@ -103,7 +123,7 @@ public function initials(): string $initials = Str::initials($this->name, true); return Str::length($initials) > 1 - ? Str::substr($initials, 0, 1) . Str::substr($initials, -1) + ? Str::substr($initials, 0, 1).Str::substr($initials, -1) : $initials; } } diff --git a/app/Notifications/AccountApproved.php b/app/Notifications/AccountApproved.php new file mode 100644 index 0000000..634f7d2 --- /dev/null +++ b/app/Notifications/AccountApproved.php @@ -0,0 +1,33 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + $url = URL::temporarySignedRoute( + 'account.password', + now()->addDays(7), + ['locale' => $notifiable->locale, 'user' => $notifiable], + ); + + return (new MailMessage) + ->subject(__('auth.approved_subject', [], $notifiable->locale)) + ->line(__('auth.approved_body', [], $notifiable->locale)) + ->action(__('auth.approved_action', [], $notifiable->locale), $url); + } +} diff --git a/app/Notifications/AccountRejected.php b/app/Notifications/AccountRejected.php new file mode 100644 index 0000000..d6d7cb4 --- /dev/null +++ b/app/Notifications/AccountRejected.php @@ -0,0 +1,25 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('auth.rejected_subject', [], $notifiable->locale)) + ->line(__('auth.rejected_body', [], $notifiable->locale)); + } +} diff --git a/app/Notifications/AccountRenewal.php b/app/Notifications/AccountRenewal.php new file mode 100644 index 0000000..546d7b6 --- /dev/null +++ b/app/Notifications/AccountRenewal.php @@ -0,0 +1,33 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(User $notifiable): MailMessage + { + $url = URL::temporarySignedRoute( + 'account.renew', + now()->addMonth(), + ['locale' => $notifiable->locale, 'user' => $notifiable], + ); + + return (new MailMessage) + ->subject(__('auth.renewal_subject', [], $notifiable->locale)) + ->line(__('auth.renewal_body', [], $notifiable->locale)) + ->action(__('auth.renewal_action', [], $notifiable->locale), $url); + } +} diff --git a/app/Notifications/AccountRequested.php b/app/Notifications/AccountRequested.php new file mode 100644 index 0000000..b1a496e --- /dev/null +++ b/app/Notifications/AccountRequested.php @@ -0,0 +1,33 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('auth.request_admin_subject')) + ->line(__('auth.request_admin_body', [ + 'name' => $this->account->name, + 'email' => $this->account->email, + 'organization' => $this->account->organization, + 'role' => $this->account->role->label(), + ])) + ->action(__('auth.request_admin_action'), url('/admin/account-requests')); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index a731bea..230f6a1 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -6,6 +6,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; +use App\Enums\AccountApprovalStatus; use App\Enums\UserRole; use App\Http\Responses\LoginResponse; use App\Http\Responses\RegisterResponse; @@ -46,11 +47,12 @@ public function boot(): void ->where('email', $request->input(Fortify::username())) ->first(); - if ( - $user - && $user->role === UserRole::Professional - && Hash::check((string) $request->input('password'), $user->password) - ) { + $canUsePublicLogin = $user !== null && ( + $user->role === UserRole::Professional + || ($user->isOrganizationAccount() && $user->approval_status === AccountApprovalStatus::Approved) + ); + + if ($canUsePublicLogin && Hash::check((string) $request->input('password'), $user->password)) { return $user; } @@ -58,7 +60,7 @@ public function boot(): void }); RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip()); + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); return Limit::perMinute(5)->by($throttleKey); }); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 83d9ed3..1f33f87 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\AccountApprovalStatus; use App\Enums\ProfessionalRole; use App\Enums\UserRole; use App\Models\User; @@ -86,6 +87,18 @@ public function verifiedProfessional(): static ]); } + public function organizationAccount(UserRole $role = UserRole::Ngo): static + { + return $this->state(fn (array $attributes) => [ + 'role' => $role, + 'approval_status' => AccountApprovalStatus::Approved, + 'professional_role' => null, + 'verified_at' => now(), + 'expires_at' => now()->addYear(), + 'reference_phone' => '0210000000', + ]); + } + public function unverifiedProfessional(): static { return $this->professional()->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_10_07_082117_add_account_approval_and_resource_access_levels.php b/database/migrations/2026_10_07_082117_add_account_approval_and_resource_access_levels.php new file mode 100644 index 0000000..d943345 --- /dev/null +++ b/database/migrations/2026_10_07_082117_add_account_approval_and_resource_access_levels.php @@ -0,0 +1,44 @@ +string('reference_phone')->nullable()->after('organization'); + $table->string('approval_status')->default('approved')->after('verified_at'); + $table->timestamp('expires_at')->nullable()->after('approval_status'); + $table->timestamp('renewal_notified_at')->nullable()->after('expires_at'); + }); + + Schema::table('resources', function (Blueprint $table) { + $table->json('access_levels')->nullable()->after('featured'); + }); + + DB::table('resources')->whereNull('access_levels')->update([ + 'access_levels' => json_encode(['public']), + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['reference_phone', 'approval_status', 'expires_at', 'renewal_notified_at']); + }); + + Schema::table('resources', function (Blueprint $table) { + $table->dropColumn('access_levels'); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index e2d170f..ae15685 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -19,6 +19,9 @@ 'title' => 'Title', 'reviewed_by' => 'Reviewed by', 'resource' => 'Resource', + 'access_levels' => 'Access level', + 'approve' => 'Accept', + 'reject' => 'Reject', ], 'stats' => [ 'published_resources' => 'Published resources', @@ -73,6 +76,11 @@ 'label' => 'County', 'plural' => 'Counties', ], + 'account_requests' => [ + 'navigation' => 'Account requests', + 'label' => 'Account request', + 'plural' => 'Account requests', + ], 'consultations' => [ 'navigation' => 'Consultations', 'label' => 'Consultation', diff --git a/lang/en/auth.php b/lang/en/auth.php index d577d11..aedb81a 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -3,8 +3,8 @@ return [ 'login_title' => 'Professional Login', 'login_subtitle' => 'Access the professional portal and exclusive resources', - 'register_title' => 'Professional Registration', - 'register_subtitle' => 'Create an account to access exclusive professional resources', + 'register_title' => 'Create an account', + 'register_subtitle' => 'Request an MAI or NGO account. An administrator will review the request.', 'email' => 'Email Address', 'email_placeholder' => 'professional@organization.org', 'password' => 'Password', @@ -28,7 +28,7 @@ 'register_here' => 'Register here', 'have_account' => 'Already have an account?', 'sign_in_here' => 'Sign in here', - 'terms_agree' => 'I agree to the :terms and confirm that I am a verified professional working in child protection.', + 'terms_agree' => 'I agree to the :terms and to the processing of my personal data by ANITP.', 'terms_link' => 'Terms and Conditions', 'security_notice' => 'By logging in, you agree to our :terms and :cookies.', 'cookie_policy' => 'Cookie Policy', @@ -36,7 +36,8 @@ 'pending_body' => 'Your professional account has been created and is awaiting approval from an administrator. You will receive an email once your account is verified.', 'pending_logout' => 'Sign out', 'back_home' => 'Back to home', - 'login_nav' => 'Login', + 'login_nav' => 'Log in', + 'register_nav' => 'Sign up', 'portal_nav' => 'Portal', 'logout' => 'Logout', 'failed' => 'These credentials do not match our records.', @@ -46,4 +47,32 @@ 'verified_email_body' => 'Your professional account on the Child Protection Resource Center has been verified. You can now access the professional portal.', 'verified_email_action' => 'Open portal', 'verified_email_thanks' => 'Thank you for supporting child protection professionals.', + 'institution' => 'Institution', + 'reference_phone' => 'Contact phone', + 'account_role' => 'Role', + 'account_role_placeholder' => 'Select a role', + 'request_sent_title' => 'Request sent', + 'request_sent_body' => 'Your account request was sent. You will receive an email after an administrator accepts or rejects it.', + 'request_admin_subject' => 'New account request', + 'request_admin_body' => ':name (:email), :organization, role :role requested an account.', + 'request_admin_action' => 'Open requests', + 'approved_subject' => 'Your account was approved', + 'approved_body' => 'Your request was accepted. Set a password to sign in.', + 'approved_action' => 'Set password', + 'rejected_subject' => 'Account request rejected', + 'rejected_body' => 'Your account request was rejected.', + 'set_password_body' => 'Hello :name, choose a password for your account.', + 'renewal_subject' => 'Your account needs renewal', + 'renewal_body' => 'Your account has expired. Renew it within one month or it will be deleted.', + 'renewal_action' => 'Renew account', + 'renewed' => 'The account was renewed for another year.', + 'account_title' => 'My account', + 'expires_on' => 'Valid until :date', + 'update_email' => 'Update email', + 'update_password' => 'Update password', + 'current_password' => 'Current password', + 'email_updated' => 'Email updated.', + 'password_updated' => 'Password updated.', + 'delete_account' => 'Delete account', + 'delete_account_body' => 'Deleting the account is permanent. Confirm with your current password.', ]; diff --git a/lang/en/enums.php b/lang/en/enums.php index 3d376a1..b628436 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -5,6 +5,8 @@ 'admin' => 'Administrator', 'editor' => 'Editor', 'professional' => 'Professional', + 'mai' => 'MAI', + 'ngo' => 'NGO', ], 'professional_role' => [ 'social_worker' => 'Social Worker', @@ -23,6 +25,16 @@ 'printable' => 'Printables', 'online' => 'Online / social media', ], + 'account_approval_status' => [ + 'pending' => 'Pending', + 'approved' => 'Approved', + 'rejected' => 'Rejected', + ], + 'resource_access' => [ + 'public' => 'Public', + 'ngo' => 'NGO', + 'mai' => 'MAI', + ], 'resource_status' => ['draft' => 'Draft', 'published' => 'Published'], 'organization_type' => [ 'public_institution' => 'Public institutions', diff --git a/lang/ro/admin.php b/lang/ro/admin.php index f4e773a..e86d248 100644 --- a/lang/ro/admin.php +++ b/lang/ro/admin.php @@ -19,6 +19,9 @@ 'title' => 'Titlu', 'reviewed_by' => 'Revizuit de', 'resource' => 'Resursă', + 'access_levels' => 'Nivel de acces', + 'approve' => 'Acceptă', + 'reject' => 'Respinge', ], 'stats' => [ 'published_resources' => 'Resurse publicate', @@ -73,6 +76,11 @@ 'label' => 'Județ', 'plural' => 'Județe', ], + 'account_requests' => [ + 'navigation' => 'Cereri de cont', + 'label' => 'Cerere de cont', + 'plural' => 'Cereri de cont', + ], 'consultations' => [ 'navigation' => 'Consultații', 'label' => 'Consultație', diff --git a/lang/ro/auth.php b/lang/ro/auth.php index 489f934..e9dfbc4 100644 --- a/lang/ro/auth.php +++ b/lang/ro/auth.php @@ -3,8 +3,8 @@ return [ 'login_title' => 'Autentificare profesioniști', 'login_subtitle' => 'Accesează portalul profesioniștilor și resursele exclusive', - 'register_title' => 'Înregistrare profesioniști', - 'register_subtitle' => 'Creează un cont pentru a accesa resurse exclusive pentru profesioniști', + 'register_title' => 'Creează un cont', + 'register_subtitle' => 'Solicită un cont MAI sau ONG. Un administrator va verifica cererea.', 'email' => 'Adresă de email', 'email_placeholder' => 'profesional@organizatie.org', 'password' => 'Parolă', @@ -28,7 +28,7 @@ 'register_here' => 'Înregistrează-te aici', 'have_account' => 'Ai deja un cont?', 'sign_in_here' => 'Autentifică-te aici', - 'terms_agree' => 'Sunt de acord cu :terms și confirm că sunt un profesionist verificat care lucrează în protecția copilului.', + 'terms_agree' => 'Sunt de acord cu :terms și cu prelucrarea datelor mele cu caracter personal de către ANITP.', 'terms_link' => 'Termenii și condițiile', 'security_notice' => 'Prin autentificare, ești de acord cu :terms și :cookies.', 'cookie_policy' => 'Politica de cookie-uri', @@ -37,6 +37,7 @@ 'pending_logout' => 'Deconectare', 'back_home' => 'Înapoi la pagina principală', 'login_nav' => 'Autentificare', + 'register_nav' => 'Înregistrare', 'portal_nav' => 'Portal', 'logout' => 'Deconectare', 'failed' => 'Datele de autentificare nu coincid cu înregistrările noastre.', @@ -46,4 +47,32 @@ 'verified_email_body' => 'Contul tău profesional pe Centrul de Resurse pentru Protecția Copilului a fost verificat. Poți accesa acum portalul profesioniștilor.', 'verified_email_action' => 'Deschide portalul', 'verified_email_thanks' => 'Îți mulțumim pentru sprijinul acordat profesioniștilor din protecția copilului.', + 'institution' => 'Instituție', + 'reference_phone' => 'Telefon de contact', + 'account_role' => 'Rol', + 'account_role_placeholder' => 'Selectează rolul', + 'request_sent_title' => 'Cererea a fost trimisă', + 'request_sent_body' => 'Cererea ta de cont a fost trimisă. Vei primi un email după ce un administrator o acceptă sau o respinge.', + 'request_admin_subject' => 'Cerere nouă de cont', + 'request_admin_body' => ':name (:email), :organization, rol :role a solicitat un cont.', + 'request_admin_action' => 'Deschide cererile', + 'approved_subject' => 'Contul tău a fost aprobat', + 'approved_body' => 'Cererea ta a fost acceptată. Setează o parolă pentru a te autentifica.', + 'approved_action' => 'Setează parola', + 'rejected_subject' => 'Cererea de cont a fost respinsă', + 'rejected_body' => 'Cererea ta de cont a fost respinsă.', + 'set_password_body' => 'Bună :name, alege o parolă pentru contul tău.', + 'renewal_subject' => 'Contul tău trebuie reînnoit', + 'renewal_body' => 'Contul tău a expirat. Reînnoiește-l în termen de o lună, altfel va fi șters.', + 'renewal_action' => 'Reînnoiește contul', + 'renewed' => 'Contul a fost reînnoit pentru încă un an.', + 'account_title' => 'Contul meu', + 'expires_on' => 'Valabil până la :date', + 'update_email' => 'Actualizează emailul', + 'update_password' => 'Actualizează parola', + 'current_password' => 'Parola curentă', + 'email_updated' => 'Emailul a fost actualizat.', + 'password_updated' => 'Parola a fost actualizată.', + 'delete_account' => 'Șterge contul', + 'delete_account_body' => 'Ștergerea contului este definitivă. Confirmă cu parola curentă.', ]; diff --git a/lang/ro/enums.php b/lang/ro/enums.php index 2495450..852288d 100644 --- a/lang/ro/enums.php +++ b/lang/ro/enums.php @@ -5,6 +5,8 @@ 'admin' => 'Administrator', 'editor' => 'Editor', 'professional' => 'Profesionist', + 'mai' => 'MAI', + 'ngo' => 'ONG', ], 'professional_role' => [ 'social_worker' => 'Asistent social', @@ -23,6 +25,16 @@ 'printable' => 'Printabile', 'online' => 'Materiale Online/Social-media', ], + 'account_approval_status' => [ + 'pending' => 'În așteptare', + 'approved' => 'Aprobat', + 'rejected' => 'Respins', + ], + 'resource_access' => [ + 'public' => 'Public', + 'ngo' => 'ONG', + 'mai' => 'MAI', + ], 'resource_status' => ['draft' => 'Schiță', 'published' => 'Publicat'], 'organization_type' => [ 'public_institution' => 'Instituții publice', diff --git a/resources/views/account/password.blade.php b/resources/views/account/password.blade.php new file mode 100644 index 0000000..08ce406 --- /dev/null +++ b/resources/views/account/password.blade.php @@ -0,0 +1,23 @@ + + + + + + {{ __('auth.approved_action') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ +

{{ __('auth.approved_action') }}

+

{{ __('auth.set_password_body', ['name' => $user->name]) }}

+
+ @csrf + + + {{ __('auth.approved_action') }} + +
+
+ + diff --git a/resources/views/components/layout/header.blade.php b/resources/views/components/layout/header.blade.php index c5e22a7..1392553 100644 --- a/resources/views/components/layout/header.blade.php +++ b/resources/views/components/layout/header.blade.php @@ -56,6 +56,10 @@ {{ __('auth.pending_title') }} + @elseif (auth()->user()->isApprovedOrganizationAccount()) + + {{ __('auth.account_title') }} + @endif
@@ -64,6 +68,13 @@ {{ __('auth.logout') }}
+ @else + + {{ __('auth.login_nav') }} + + + {{ __('auth.register_nav') }} + @endauth @@ -101,6 +112,10 @@ class="md:hidden p-2 rounded-lg hover:bg-primary/40 text-white" {{ __('auth.pending_title') }} + @elseif (auth()->user()->isApprovedOrganizationAccount()) + + {{ __('auth.account_title') }} + @endif
@csrf @@ -108,6 +123,13 @@ class="md:hidden p-2 rounded-lg hover:bg-primary/40 text-white" {{ __('auth.logout') }}
+ @else + + {{ __('auth.login_nav') }} + + + {{ __('auth.register_nav') }} + @endauth
diff --git "a/resources/views/pages/\342\232\241account-requested.blade.php" "b/resources/views/pages/\342\232\241account-requested.blade.php" new file mode 100644 index 0000000..c3c31cc --- /dev/null +++ "b/resources/views/pages/\342\232\241account-requested.blade.php" @@ -0,0 +1,16 @@ + + +
+ +

{{ __('auth.request_sent_title') }}

+

{{ __('auth.request_sent_body') }}

+ {{ __('auth.back_home') }} +
+
diff --git "a/resources/views/pages/\342\232\241account.blade.php" "b/resources/views/pages/\342\232\241account.blade.php" new file mode 100644 index 0000000..d5074a8 --- /dev/null +++ "b/resources/views/pages/\342\232\241account.blade.php" @@ -0,0 +1,108 @@ +user()?->isApprovedOrganizationAccount(), 403); + + $this->email = (string) auth()->user()->email; + } + + public function updateEmail(): void + { + $user = auth()->user(); + + $validated = $this->validate([ + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + ]); + + $user->update(['email' => $validated['email']]); + session()->flash('status', __('auth.email_updated')); + } + + public function updatePassword(): void + { + $user = auth()->user(); + + $this->validate([ + 'currentPassword' => ['required', 'current_password'], + 'password' => ['required', 'string', 'min:8', 'same:passwordConfirmation'], + ]); + + $user->update(['password' => $this->password]); + $this->reset('currentPassword', 'password', 'passwordConfirmation'); + session()->flash('status', __('auth.password_updated')); + } + + public function deleteAccount(): void + { + $this->validate([ + 'deletePassword' => ['required', 'current_password'], + ]); + + $user = auth()->user(); + Auth::logout(); + $user->delete(); + + $this->redirect(localized_route('home')); + } +}; +?> + +
+ +
+ @if (session('status')) + + @endif + + +

{{ auth()->user()->name }}

+

{{ auth()->user()->organization }} · {{ auth()->user()->role->label() }}

+ @if (auth()->user()->expires_at) +

{{ __('auth.expires_on', ['date' => auth()->user()->expires_at->translatedFormat('j F Y')]) }}

+ @endif +
+ + +
+ + {{ __('auth.update_email') }} + +
+ + +
+ + + + {{ __('auth.update_password') }} + +
+ + +
+

{{ __('auth.delete_account_body') }}

+ + {{ __('auth.delete_account') }} + +
+
+
diff --git "a/resources/views/pages/\342\232\241home.blade.php" "b/resources/views/pages/\342\232\241home.blade.php" index e4346c5..761446a 100644 --- "a/resources/views/pages/\342\232\241home.blade.php" +++ "b/resources/views/pages/\342\232\241home.blade.php" @@ -19,7 +19,7 @@ /** @return Collection */ public function featuredResources(): Collection { - return Resource::featured()->published()->latest('published_at')->limit(6)->get(); + return Resource::featured()->published()->visibleTo(auth()->user())->latest('published_at')->limit(6)->get(); } }; ?> diff --git "a/resources/views/pages/\342\232\241register.blade.php" "b/resources/views/pages/\342\232\241register.blade.php" index 5018388..2c393b7 100644 --- "a/resources/views/pages/\342\232\241register.blade.php" +++ "b/resources/views/pages/\342\232\241register.blade.php" @@ -1,6 +1,6 @@ */ - public function professionalRoles(): array + /** + * @return array + */ + public function accountRoles(): array { - return ProfessionalRole::options(); + return [ + UserRole::Mai->value => UserRole::Mai->label(), + UserRole::Ngo->value => UserRole::Ngo->label(), + ]; } }; ?> @@ -79,50 +85,37 @@ public function professionalRoles(): array id="organization" name="organization" type="text" - label="{{ __('auth.organization') }} *" + label="{{ __('auth.institution') }} *" value="{{ old('organization') }}" placeholder="{{ __('auth.organization_placeholder') }}" required :error="$errors->first('organization')" /> - - @foreach ($this->professionalRoles() as $value => $label) - - @endforeach - -
- -
- -
+ + @foreach ($this->accountRoles() as $value => $label) + + @endforeach + +