From 7096e2233990bc5a7251436572aa6be9ab0c818d Mon Sep 17 00:00:00 2001 From: Tajim Date: Tue, 1 Sep 2026 22:49:36 +0600 Subject: [PATCH 1/3] feat(user): introduce is_verified column as single source of truth for verification --- app/Http/Controllers/BlogController.php | 6 +- app/Http/Controllers/ChatController.php | 6 +- app/Http/Controllers/ForumController.php | 10 ++-- app/Http/Controllers/NodeController.php | 2 +- app/Http/Controllers/ResourceController.php | 2 +- .../Controllers/UserProfileController.php | 11 ++-- app/Http/Requests/User/StoreUserRequest.php | 1 + app/Http/Requests/User/UpdateUserRequest.php | 1 + app/Models/ChatMessage.php | 2 +- app/Models/User.php | 11 +--- database/factories/UserFactory.php | 11 ++++ ..._224500_add_is_verified_to_users_table.php | 49 ++++++++++++++++ database/seeders/AdminSeeder.php | 1 + resources/js/components/UserListItem.vue | 6 +- resources/js/components/admin/UserRow.vue | 3 + resources/js/pages/Profile.vue | 8 +-- resources/js/pages/User/Show.vue | 7 +-- .../js/pages/admin/users/CreateOrEdit.vue | 56 ++++++++++++++++++- tests/Feature/AdminUserListFilterTest.php | 45 +++++++++++++++ tests/Feature/UserProfileTest.php | 37 +++++------- 20 files changed, 205 insertions(+), 70 deletions(-) create mode 100644 database/migrations/2026_09_01_224500_add_is_verified_to_users_table.php diff --git a/app/Http/Controllers/BlogController.php b/app/Http/Controllers/BlogController.php index eefa734d..d458f46a 100644 --- a/app/Http/Controllers/BlogController.php +++ b/app/Http/Controllers/BlogController.php @@ -56,13 +56,13 @@ public function show(Blog $blog) { abort_unless($blog->is_published, 404); - $blog->load('user:id,name,username,image_path'); + $blog->load('user:id,name,username,image_path,is_verified'); $blog->increment('views'); $reactionsCount = $blog->reactions()->count(); $reactors = $blog->reactions() - ->with(['user:id,name,username,image_path,institution']) + ->with(['user:id,name,username,image_path,institution,is_verified']) ->latest('id') ->limit(50) ->get() @@ -71,7 +71,7 @@ public function show(Blog $blog) ->values(); $comments = $blog->comments() - ->with(['user:id,name,username,image_path,institution']) + ->with(['user:id,name,username,image_path,institution,is_verified']) ->latest() ->get(); diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index cddfb34c..5be50636 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -55,9 +55,9 @@ public function index(Request $request) // Fetch last X messages in chronological order $messages = ChatMessage::with([ - 'user:id,name,username,image_path,institution', + 'user:id,name,username,image_path,institution,is_verified', 'user.roles:id,name', - 'reactions.user:id,name,username,image_path,institution', + 'reactions.user:id,name,username,image_path,institution,is_verified', 'reactions.user.roles:id,name', ]) ->latest('id') @@ -243,7 +243,7 @@ public function store(Request $request) // Log without failing response } - $message->loadMissing(['user:id,name,username,image_path,institution', 'user.roles:id,name']); + $message->loadMissing(['user:id,name,username,image_path,institution,is_verified', 'user.roles:id,name']); return response()->json([ 'id' => $message->id, diff --git a/app/Http/Controllers/ForumController.php b/app/Http/Controllers/ForumController.php index 7b19f775..595bf0ec 100644 --- a/app/Http/Controllers/ForumController.php +++ b/app/Http/Controllers/ForumController.php @@ -42,7 +42,7 @@ public function index(Request $request): Response $postsQuery = ForumPost::query() ->when(! ($myPosts && auth()->check()), fn ($q) => $q->approved()) ->with([ - 'user:id,name,username,image_path,institution', + 'user:id,name,username,image_path,institution,is_verified', 'subject:id,name,course,slug', 'node:id,name,slug', ]) @@ -93,16 +93,16 @@ public function show(Request $request, ForumPost $post): Response } $post->load([ - 'user:id,name,username,image_path,institution', + 'user:id,name,username,image_path,institution,is_verified', 'subject:id,name,course,slug', 'node:id,name,slug', ]); $answers = $post->directAnswers() ->with([ - 'user:id,name,username,image_path,institution', + 'user:id,name,username,image_path,institution,is_verified', 'replies' => fn ($q) => $q->with([ - 'user:id,name,username,image_path,institution', + 'user:id,name,username,image_path,institution,is_verified', 'parent:id,user_id', 'parent.user:id,name,username', ])->orderBy('created_at', 'asc'), @@ -149,7 +149,7 @@ public function show(Request $request, ForumPost $post): Response $upvoters = ForumVote::where('voteable_type', ForumPost::class) ->where('voteable_id', $post->id) ->where('value', 1) - ->with('user:id,name,username,image_path,institution') + ->with('user:id,name,username,image_path,institution,is_verified') ->latest() ->get() ->pluck('user') diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 60197bea..0eb7861d 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -51,7 +51,7 @@ public function show(Subject $subject, $path) : null; $upvoters = $node->upvotes() - ->with(['user:id,name,username,image_path,institution', 'user.roles:id,name']) + ->with('user:id,name,username,image_path,institution,is_verified') ->latest('id') ->limit(50) ->get() diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index bf4cf797..900d8819 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -38,7 +38,7 @@ public function show($id) $completionsCount = ResourceCompletion::where('resource_id', $id)->count(); $completers = ResourceCompletion::where('resource_id', $id) - ->with(['user:id,name,username,image_path,institution']) + ->with(['user:id,name,username,image_path,institution,is_verified']) ->latest() ->take(10) ->get() diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index a54c87be..17b3731f 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -53,13 +53,13 @@ public function show(string $username) : false; $appreciators = $user->appreciators() - ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution', 'users.is_verified']) ->inRandomOrder() ->take(30) ->get(); $appreciating = $user->appreciatingUsers() - ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution', 'users.is_verified']) ->inRandomOrder() ->take(30) ->get(); @@ -180,8 +180,8 @@ public function show(string $username) // Suggested / Discover community members: 2 contributors + 2 general users $contributorUsers = User::where('id', '!=', $user->id) ->whereNotNull('username') - ->whereHas('roles') - ->select(['id', 'name', 'username', 'institution', 'image_path', 'about']) + ->where('is_verified', true) + ->select(['id', 'name', 'username', 'institution', 'image_path', 'about', 'is_verified']) ->inRandomOrder() ->take(2) ->get(); @@ -191,7 +191,7 @@ public function show(string $username) $randomUsers = User::whereNotIn('id', $excludedIds) ->whereNotNull('username') - ->select(['id', 'name', 'username', 'institution', 'image_path', 'about']) + ->select(['id', 'name', 'username', 'institution', 'image_path', 'about', 'is_verified']) ->inRandomOrder() ->take($remainingNeeded) ->get(); @@ -211,7 +211,6 @@ public function show(string $username) 'github' => $user->github, 'created_at' => $user->created_at?->format('M Y') ?? '2026', 'is_verified' => $user->is_verified, - 'is_staff' => $user->is_verified, ], 'stats' => [ 'questionsCount' => $questionsCount, diff --git a/app/Http/Requests/User/StoreUserRequest.php b/app/Http/Requests/User/StoreUserRequest.php index ab4a6a5d..82d38140 100644 --- a/app/Http/Requests/User/StoreUserRequest.php +++ b/app/Http/Requests/User/StoreUserRequest.php @@ -28,6 +28,7 @@ public function rules(): array 'name' => ['required', 'string', 'max:255', new CleanText], 'username' => ['nullable', 'string', 'min:3', 'max:30', 'regex:/^[a-zA-Z0-9_]+$/', 'unique:users,username', new CleanText], 'email' => ['required', 'email', 'unique:users,email'], + 'is_verified' => ['sometimes', 'boolean'], 'role' => ['nullable', 'string'], 'permissions' => ['nullable', 'array'], 'permissions.*' => ['string', 'exists:permissions,name'], diff --git a/app/Http/Requests/User/UpdateUserRequest.php b/app/Http/Requests/User/UpdateUserRequest.php index dba159e7..060bf0c3 100644 --- a/app/Http/Requests/User/UpdateUserRequest.php +++ b/app/Http/Requests/User/UpdateUserRequest.php @@ -45,6 +45,7 @@ public function rules(): array 'facebook' => ['sometimes', 'nullable', 'string', 'max:255'], 'instagram' => ['sometimes', 'nullable', 'string', 'max:255'], 'github' => ['sometimes', 'nullable', 'string', 'max:255'], + 'is_verified' => ['sometimes', 'boolean'], 'role' => ['sometimes', 'nullable', 'string'], 'permissions' => ['sometimes', 'nullable', 'array'], 'permissions.*' => ['string', 'exists:permissions,name'], diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php index 3e2bf149..fb21b13c 100644 --- a/app/Models/ChatMessage.php +++ b/app/Models/ChatMessage.php @@ -71,7 +71,7 @@ public function getFormattedReactions(?int $currentUserId = null): array 'image_url' => $r->user->image_url, 'image_path' => $r->user->image_path, 'institution' => $r->user->institution, - 'is_verified' => (bool) ($r->user->is_verified || ($r->user->relationLoaded('roles') && $r->user->roles->isNotEmpty())), + 'is_verified' => (bool) ($r->user->is_verified ?? false), 'roles' => $r->user->relationLoaded('roles') ? $r->user->roles->pluck('name')->toArray() : [], ]; })->filter()->values()->toArray(), diff --git a/app/Models/User.php b/app/Models/User.php index ed25af7b..866a5966 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -18,6 +18,7 @@ * @property string $name * @property string $email * @property Carbon|null $email_verified_at + * @property bool $is_verified * @property string $password * @property string|null $remember_token * @property Carbon|null $created_at @@ -41,6 +42,7 @@ class User extends Authenticatable 'username', 'email', 'receive_emails', + 'is_verified', 'google_id', 'email_verified_at', 'banned_until', @@ -65,17 +67,9 @@ protected static function booted(): void protected $appends = [ 'image_url', - 'is_verified', 'is_banned', ]; - public function getIsVerifiedAttribute(): bool - { - return $this->relationLoaded('roles') - ? $this->roles->isNotEmpty() - : $this->roles()->exists(); - } - public function isBanned(): bool { return $this->banned_until !== null && $this->banned_until->isFuture(); @@ -109,6 +103,7 @@ protected function casts(): array 'banned_until' => 'datetime', 'password' => 'hashed', 'receive_emails' => 'boolean', + 'is_verified' => 'boolean', ]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 30be883e..1185f4d6 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -23,10 +23,21 @@ public function definition(): array 'email' => fake()->unique()->safeEmail(), 'google_id' => fake()->unique()->uuid(), 'email_verified_at' => now(), + 'is_verified' => false, 'remember_token' => Str::random(10), ]; } + /** + * Indicate that the user is verified. + */ + public function verified(): static + { + return $this->state(fn (array $attributes) => [ + 'is_verified' => true, + ]); + } + /** * Indicate that the model's email address should be unverified. */ diff --git a/database/migrations/2026_09_01_224500_add_is_verified_to_users_table.php b/database/migrations/2026_09_01_224500_add_is_verified_to_users_table.php new file mode 100644 index 00000000..cc4cddaa --- /dev/null +++ b/database/migrations/2026_09_01_224500_add_is_verified_to_users_table.php @@ -0,0 +1,49 @@ +boolean('is_verified')->default(false)->after('email_verified_at'); + }); + + $modelHasRolesTable = config('permission.table_names.model_has_roles', 'model_has_roles'); + $modelMorphKey = config('permission.column_names.model_morph_key', 'model_id'); + + if (Schema::hasTable($modelHasRolesTable)) { + $userIdsWithRoles = DB::table($modelHasRolesTable) + ->where(function ($query) { + $query->where('model_type', User::class) + ->orWhere('model_type', 'App\Models\User'); + }) + ->distinct() + ->pluck($modelMorphKey); + + if ($userIdsWithRoles->isNotEmpty()) { + DB::table('users') + ->whereIn('id', $userIdsWithRoles) + ->update(['is_verified' => true]); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_verified'); + }); + } +}; diff --git a/database/seeders/AdminSeeder.php b/database/seeders/AdminSeeder.php index 92abb518..ecaf4a89 100644 --- a/database/seeders/AdminSeeder.php +++ b/database/seeders/AdminSeeder.php @@ -14,6 +14,7 @@ public function run(): void [ 'name' => config('app.admin_name', 'ADMIN'), 'email_verified_at' => now(), + 'is_verified' => true, ] ); diff --git a/resources/js/components/UserListItem.vue b/resources/js/components/UserListItem.vue index 9495d9ef..e18ac111 100644 --- a/resources/js/components/UserListItem.vue +++ b/resources/js/components/UserListItem.vue @@ -14,7 +14,6 @@ const props = withDefaults( institution?: string | null; title?: string | null; is_verified?: boolean; - roles?: Array<{ id?: number; name: string }>; }; theme?: 'indigo' | 'emerald' | 'rose'; subtitle?: string | null; @@ -30,10 +29,7 @@ const profileUrl = computed(() => { }); const isVerified = computed(() => { - return Boolean( - props.user.is_verified ?? - (props.user.roles && props.user.roles.length > 0), - ); + return Boolean(props.user.is_verified); }); const avatarBgClass = computed(() => { diff --git a/resources/js/components/admin/UserRow.vue b/resources/js/components/admin/UserRow.vue index 735cde9b..e0f24acb 100644 --- a/resources/js/components/admin/UserRow.vue +++ b/resources/js/components/admin/UserRow.vue @@ -4,6 +4,7 @@ import { LogIn, Pencil, Trash2, Ban } from 'lucide-vue-next'; import { ref } from 'vue'; import ChatBanModal from '@/components/ChatBanModal.vue'; import StatusBadge from '@/components/StatusBadge.vue'; +import VerifiedBadge from '@/components/VerifiedBadge.vue'; import { usePermissions } from '@/lib/usePermissions'; const { can } = usePermissions(); @@ -81,6 +82,8 @@ const deleteUser = (id: number) => { {{ user.name }} + + props.user || page.props.auth?.user); const hasNoRole = computed(() => { - if (typeof user.value?.is_verified === 'boolean') { - return !user.value.is_verified; - } - - const roles = user.value?.roles; - - return !roles || roles.length === 0; + return !user.value?.is_verified; }); const showAdvancedSettings = ref(false); diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index b9d842ef..1d81e326 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -44,8 +44,6 @@ const props = defineProps<{ github: string | null; created_at: string; is_verified?: boolean; - is_staff?: boolean; - roles?: string[]; }; stats: { questionsCount: number; @@ -386,10 +384,7 @@ const timeAgo = formatTimeAgo; diff --git a/resources/js/pages/admin/users/CreateOrEdit.vue b/resources/js/pages/admin/users/CreateOrEdit.vue index bad9a8de..e3c2d71b 100644 --- a/resources/js/pages/admin/users/CreateOrEdit.vue +++ b/resources/js/pages/admin/users/CreateOrEdit.vue @@ -1,6 +1,6 @@