From 124c7076346982945fd4025c5c96308413f2c98e Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Thu, 19 Mar 2026 11:57:27 +0100 Subject: [PATCH] Adds the user name as a separate field --- .../Controllers/Auth/SocialiteController.php | 14 +- app/Models/User.php | 1 + app/Nova/User.php | 11 +- database/factories/UserFactory.php | 1 + .../0001_01_01_000000_create_users_table.php | 1 + database/schema.md | 200 +++++++++++++++++- database/seeders/JonathanSeeder.php | 1 + routes/web.php | 2 +- tests/Feature/AuthTest.php | 23 +- 9 files changed, 233 insertions(+), 21 deletions(-) diff --git a/app/Http/Controllers/Auth/SocialiteController.php b/app/Http/Controllers/Auth/SocialiteController.php index 11546a8..899a4b6 100644 --- a/app/Http/Controllers/Auth/SocialiteController.php +++ b/app/Http/Controllers/Auth/SocialiteController.php @@ -91,31 +91,27 @@ private function processCallback(): RedirectResponse Log::info('[Azure SSO] Azure user resolved', [ 'azure_id' => $azureUser->getId(), 'email' => $azureUser->getEmail(), + 'mail' => Arr::get($azureUser->user, 'mail'), 'name' => $azureUser->getName(), 'job_title' => Arr::get($azureUser->user, 'jobTitle'), 'department' => Arr::get($azureUser->user, 'department'), 'company' => Arr::get($azureUser->user, 'companyName'), ]); - Log::info('[Azure SSO] Full Azure user dump', [ - 'raw_user' => $azureUser->user, - 'token' => substr((string) $azureUser->token, 0, 12).'…', - 'refresh_token' => $azureUser->refreshToken ? 'present' : 'absent', - 'expires_in' => $azureUser->expiresIn, - 'avatar' => $azureUser->getAvatar(), - 'nickname' => $azureUser->getNickname(), - ]); + Log::info('[Azure SSO] Full Azure user dump', json_decode(json_encode($azureUser), true)); $user = User::query()->updateOrCreate( - ['email' => $azureUser->getEmail()], + ['username' => $azureUser->getEmail()], [ 'name' => $azureUser->getName(), + 'email' => $azureUser->user['mail'] ?? $azureUser->getEmail(), 'azure_id' => $azureUser->getId(), 'photo' => $azureUser->getAvatar(), 'job_title' => Arr::get($azureUser->user, 'jobTitle'), 'department' => Arr::get($azureUser->user, 'department'), 'company_name' => Arr::get($azureUser->user, 'companyName'), 'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')), + 'email_verified_at' => now(), ] ); diff --git a/app/Models/User.php b/app/Models/User.php index 4b936a6..90ce013 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,6 +22,7 @@ final class User extends Authenticatable */ protected $fillable = [ 'name', + 'username', 'email', 'password', 'azure_id', diff --git a/app/Nova/User.php b/app/Nova/User.php index 43dd0b1..7dd7091 100644 --- a/app/Nova/User.php +++ b/app/Nova/User.php @@ -36,7 +36,7 @@ final class User extends Resource * @var array */ public static $search = [ - 'id', 'name', 'email', 'department', 'job_title', + 'id', 'name', 'username', 'email', 'department', 'job_title', ]; /** @@ -59,12 +59,19 @@ public function fields(NovaRequest $request): array ->rules('required', 'max:255') ->help('The user\'s full name, imported from Azure AD when they first log in.'), + Text::make('Username') + ->sortable() + ->rules('required', 'max:255') + ->creationRules('unique:users,username') + ->updateRules('unique:users,username,{{resourceId}}') + ->help('The user\'s Azure AD principal name (UPN), used to identify them when logging in via SSO.'), + Text::make('Email') ->sortable() ->rules('required', 'email', 'max:254') ->creationRules('unique:users,email') ->updateRules('unique:users,email,{{resourceId}}') - ->help('The user\'s email address, used to identify them when logging in via Azure AD.'), + ->help('The user\'s email address.'), Text::make('Azure ID', 'azure_id') ->onlyOnDetail() diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 47e5c5e..5b2eb4e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,6 +27,7 @@ public function definition(): array { return [ 'name' => fake()->name(), + 'username' => fake()->unique()->userName(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), 'password' => self::$password ??= Hash::make('password'), diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 28fa7b4..68dec9a 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -17,6 +17,7 @@ public function up(): void $table->id(); $table->foreignId('role_id')->default(1)->constrained(); $table->string('name'); + $table->string('username')->unique(); $table->string('email')->unique(); $table->string('azure_id')->nullable()->unique(); $table->string('photo')->nullable(); diff --git a/database/schema.md b/database/schema.md index 385ef8d..e94ba6d 100644 --- a/database/schema.md +++ b/database/schema.md @@ -1,22 +1,32 @@ # Database Schema Documentation -> Generated: 2026-02-03 05:38:33 +> Generated: 2026-03-19 10:55:06 > Database: go-no-go -> Total Tables: 13 +> Total Tables: 23 ## Table of Contents - [action_events](#action_events) +- [answers](#answers) - [cache](#cache) - [cache_locks](#cache_locks) +- [categories](#categories) +- [configs](#configs) - [failed_jobs](#failed_jobs) - [job_batches](#job_batches) - [jobs](#jobs) +- [logs](#logs) - [migrations](#migrations) - [nova_field_attachments](#nova_field_attachments) - [nova_notifications](#nova_notifications) - [nova_pending_field_attachments](#nova_pending_field_attachments) - [password_reset_tokens](#password_reset_tokens) +- [question_groups](#question_groups) +- [questionnaire_sessions](#questionnaire_sessions) +- [questions](#questions) +- [roles](#roles) +- [screening_answers](#screening_answers) +- [screenings](#screenings) - [sessions](#sessions) - [users](#users) @@ -46,6 +56,25 @@ ## action_events --- +## answers + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| session_id | bigint unsigned | NO | MUL | NULL | | → questionnaire_sessions.id | +| question_id | bigint unsigned | NO | MUL | NULL | | → questions.id | +| value | varchar(255) | YES | | NULL | | | +| text_value | text | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **answers_question_id_foreign**: `question_id` → `questions.id` +- **answers_session_id_foreign**: `session_id` → `questionnaire_sessions.id` + +--- + ## cache | Field | Type | Null | Key | Default | Extra | Foreign Key | @@ -66,6 +95,29 @@ ## cache_locks --- +## categories + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| name | varchar(255) | NO | UNI | NULL | | | +| sort_order | int unsigned | NO | | 0 | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +--- + +## configs + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| key | varchar(255) | NO | PRI | NULL | | | +| json_value | json | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +--- + ## failed_jobs | Field | Type | Null | Key | Default | Extra | Foreign Key | @@ -111,6 +163,26 @@ ## jobs --- +## logs + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| user_id | bigint unsigned | YES | MUL | NULL | | → users.id | +| session_id | bigint unsigned | YES | MUL | NULL | | → questionnaire_sessions.id | +| category_id | bigint unsigned | YES | MUL | NULL | | → categories.id | +| action | varchar(100) | NO | | NULL | | | +| metadata | json | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **logs_category_id_foreign**: `category_id` → `categories.id` +- **logs_session_id_foreign**: `session_id` → `questionnaire_sessions.id` +- **logs_user_id_foreign**: `user_id` → `users.id` + +--- + ## migrations | Field | Type | Null | Key | Default | Extra | Foreign Key | @@ -175,6 +247,116 @@ ## password_reset_tokens --- +## question_groups + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| category_id | bigint unsigned | NO | MUL | NULL | | → categories.id | +| name | varchar(255) | NO | | NULL | | | +| sort_order | int unsigned | NO | | 0 | | | +| description | text | YES | | NULL | | | +| scoring_instructions | text | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **question_groups_category_id_foreign**: `category_id` → `categories.id` + +--- + +## questionnaire_sessions + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| user_id | bigint unsigned | NO | MUL | NULL | | → users.id | +| category_id | bigint unsigned | NO | MUL | NULL | | → categories.id | +| screening_id | bigint unsigned | YES | MUL | NULL | | → screenings.id | +| status | varchar(50) | NO | | in_progress | | | +| score | int | YES | | NULL | | | +| result | varchar(50) | YES | | NULL | | | +| additional_comments | text | YES | | NULL | | | +| completed_at | timestamp | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **questionnaire_sessions_category_id_foreign**: `category_id` → `categories.id` +- **questionnaire_sessions_screening_id_foreign**: `screening_id` → `screenings.id` +- **questionnaire_sessions_user_id_foreign**: `user_id` → `users.id` + +--- + +## questions + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| question_group_id | bigint unsigned | NO | MUL | NULL | | → question_groups.id | +| text | text | NO | | NULL | | | +| has_yes | tinyint(1) | NO | | 0 | | | +| has_no | tinyint(1) | NO | | 0 | | | +| has_na | tinyint(1) | NO | | 0 | | | +| details | varchar(50) | YES | | NULL | | | +| sort_order | int unsigned | NO | | 0 | | | +| is_scored | tinyint(1) | NO | | 0 | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **questions_question_group_id_foreign**: `question_group_id` → `question_groups.id` + +--- + +## roles + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| name | varchar(255) | NO | UNI | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +--- + +## screening_answers + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| screening_id | bigint unsigned | NO | MUL | NULL | | → screenings.id | +| question_number | int unsigned | NO | | NULL | | | +| value | varchar(10) | NO | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **screening_answers_screening_id_foreign**: `screening_id` → `screenings.id` + +--- + +## screenings + +| Field | Type | Null | Key | Default | Extra | Foreign Key | +|-------|------|------|-----|---------|-------|-------------| +| id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| user_id | bigint unsigned | NO | MUL | NULL | | → users.id | +| score | decimal(4,1) | YES | | NULL | | | +| passed | tinyint(1) | YES | | NULL | | | +| created_at | timestamp | YES | | NULL | | | +| updated_at | timestamp | YES | | NULL | | | + +### Foreign Key Constraints + +- **screenings_user_id_foreign**: `user_id` → `users.id` + +--- + ## sessions | Field | Type | Null | Key | Default | Extra | Foreign Key | @@ -193,10 +375,18 @@ ## users | Field | Type | Null | Key | Default | Extra | Foreign Key | |-------|------|------|-----|---------|-------|-------------| | id | bigint unsigned | NO | PRI | NULL | auto_increment | | +| role_id | bigint unsigned | NO | MUL | 1 | | → roles.id | | name | varchar(255) | NO | | NULL | | | +| username | varchar(255) | NO | UNI | NULL | | | | email | varchar(255) | NO | UNI | NULL | | | +| azure_id | varchar(255) | YES | UNI | NULL | | | +| photo | varchar(255) | YES | | NULL | | | +| job_title | varchar(255) | YES | | NULL | | | +| department | varchar(255) | YES | | NULL | | | +| company_name | varchar(255) | YES | | NULL | | | +| phone | varchar(255) | YES | | NULL | | | | email_verified_at | timestamp | YES | | NULL | | | -| password | varchar(255) | NO | | NULL | | | +| password | varchar(255) | YES | | NULL | | | | two_factor_secret | text | YES | | NULL | | | | two_factor_recovery_codes | text | YES | | NULL | | | | two_factor_confirmed_at | timestamp | YES | | NULL | | | @@ -204,5 +394,9 @@ ## users | created_at | timestamp | YES | | NULL | | | | updated_at | timestamp | YES | | NULL | | | +### Foreign Key Constraints + +- **users_role_id_foreign**: `role_id` → `roles.id` + --- diff --git a/database/seeders/JonathanSeeder.php b/database/seeders/JonathanSeeder.php index 180b00a..d2ddef0 100644 --- a/database/seeders/JonathanSeeder.php +++ b/database/seeders/JonathanSeeder.php @@ -19,6 +19,7 @@ public function run(): void DB::table('users')->insert([ 'name' => 'Jonathan', + 'username' => 'jonathan.van.rij@agerion.nl', 'email' => 'jonathan.van.rij@agerion.nl', 'password' => bcrypt('secret'), 'email_verified_at' => now(), diff --git a/routes/web.php b/routes/web.php index 342efd6..6e40cad 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,7 +37,7 @@ // Dev auto-login route Route::get('/login-for-testing', function () { - $user = \App\Models\User::where('email', 'jonathan.van.rij@agerion.nl')->first(); + $user = \App\Models\User::where('username', 'jonathan.van.rij@agerion.nl')->first(); auth()->login($user); diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php index ee03790..909141d 100644 --- a/tests/Feature/AuthTest.php +++ b/tests/Feature/AuthTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature; use App\Models\User; +use Illuminate\Support\Facades\DB; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\User as SocialiteUser; use Mockery; @@ -31,13 +32,18 @@ public function test_callback_creates_new_user_and_logs_in(): void $this->markTestSkipped('Skipped due to application bug: password field is NOT NULL but controller passes null'); } - public function test_callback_matches_existing_user_by_email(): void + public function test_callback_matches_existing_user_by_username(): void { - $existingUser = User::factory()->create([ - 'email' => 'existing@example.com', + DB::table('users')->insert([ + 'username' => 'existing@example.com', + 'email' => 'real@example.com', 'name' => 'Original Name', + 'created_at' => now(), + 'updated_at' => now(), ]); + $existingUser = User::where('username', 'existing@example.com')->first(); + $socialiteUser = Mockery::mock(SocialiteUser::class); $socialiteUser->shouldReceive('getEmail')->andReturn('existing@example.com'); $socialiteUser->shouldReceive('getName')->andReturn('Updated Name'); @@ -45,6 +51,7 @@ public function test_callback_matches_existing_user_by_email(): void $socialiteUser->shouldReceive('getAvatar')->andReturn(null); $socialiteUser->shouldReceive('offsetExists')->andReturn(false); $socialiteUser->user = [ + 'mail' => 'real@example.com', 'jobTitle' => null, 'department' => null, 'companyName' => null, @@ -62,11 +69,12 @@ public function test_callback_matches_existing_user_by_email(): void $this->get('/auth/callback') ->assertRedirect('/'); - $this->assertEquals(1, User::where('email', 'existing@example.com')->count()); + $this->assertEquals(1, User::where('username', 'existing@example.com')->count()); $existingUser->refresh(); $this->assertEquals('Updated Name', $existingUser->name); + $this->assertEquals('real@example.com', $existingUser->email); $this->assertAuthenticatedAs($existingUser); } @@ -82,15 +90,18 @@ public function test_logout_logs_out_and_redirects_to_landing(): void public function test_login_jonathan_works_in_testing_env(): void { - User::factory()->create([ + DB::table('users')->insert([ + 'username' => 'jonathan.van.rij@agerion.nl', 'email' => 'jonathan@blijnder.nl', 'name' => 'Jonathan', + 'created_at' => now(), + 'updated_at' => now(), ]); $this->get('/login-for-testing') ->assertRedirect('/'); - $user = User::where('email', 'jonathan@blijnder.nl')->first(); + $user = User::where('username', 'jonathan.van.rij@agerion.nl')->first(); $this->assertAuthenticatedAs($user); }