Adds the user name as a separate field

This commit is contained in:
2026-03-19 11:57:27 +01:00
parent a046c017fa
commit 124c707634
9 changed files with 233 additions and 21 deletions

View File

@@ -91,31 +91,27 @@ private function processCallback(): RedirectResponse
Log::info('[Azure SSO] Azure user resolved', [ Log::info('[Azure SSO] Azure user resolved', [
'azure_id' => $azureUser->getId(), 'azure_id' => $azureUser->getId(),
'email' => $azureUser->getEmail(), 'email' => $azureUser->getEmail(),
'mail' => Arr::get($azureUser->user, 'mail'),
'name' => $azureUser->getName(), 'name' => $azureUser->getName(),
'job_title' => Arr::get($azureUser->user, 'jobTitle'), 'job_title' => Arr::get($azureUser->user, 'jobTitle'),
'department' => Arr::get($azureUser->user, 'department'), 'department' => Arr::get($azureUser->user, 'department'),
'company' => Arr::get($azureUser->user, 'companyName'), 'company' => Arr::get($azureUser->user, 'companyName'),
]); ]);
Log::info('[Azure SSO] Full Azure user dump', [ Log::info('[Azure SSO] Full Azure user dump', json_decode(json_encode($azureUser), true));
'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(),
]);
$user = User::query()->updateOrCreate( $user = User::query()->updateOrCreate(
['email' => $azureUser->getEmail()], ['username' => $azureUser->getEmail()],
[ [
'name' => $azureUser->getName(), 'name' => $azureUser->getName(),
'email' => $azureUser->user['mail'] ?? $azureUser->getEmail(),
'azure_id' => $azureUser->getId(), 'azure_id' => $azureUser->getId(),
'photo' => $azureUser->getAvatar(), 'photo' => $azureUser->getAvatar(),
'job_title' => Arr::get($azureUser->user, 'jobTitle'), 'job_title' => Arr::get($azureUser->user, 'jobTitle'),
'department' => Arr::get($azureUser->user, 'department'), 'department' => Arr::get($azureUser->user, 'department'),
'company_name' => Arr::get($azureUser->user, 'companyName'), 'company_name' => Arr::get($azureUser->user, 'companyName'),
'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')), 'phone' => Arr::get($azureUser->user, 'mobilePhone', Arr::get($azureUser->user, 'businessPhones.0')),
'email_verified_at' => now(),
] ]
); );

View File

@@ -22,6 +22,7 @@ final class User extends Authenticatable
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
'username',
'email', 'email',
'password', 'password',
'azure_id', 'azure_id',

View File

@@ -36,7 +36,7 @@ final class User extends Resource
* @var array * @var array
*/ */
public static $search = [ 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') ->rules('required', 'max:255')
->help('The user\'s full name, imported from Azure AD when they first log in.'), ->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') Text::make('Email')
->sortable() ->sortable()
->rules('required', 'email', 'max:254') ->rules('required', 'email', 'max:254')
->creationRules('unique:users,email') ->creationRules('unique:users,email')
->updateRules('unique:users,email,{{resourceId}}') ->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') Text::make('Azure ID', 'azure_id')
->onlyOnDetail() ->onlyOnDetail()

View File

@@ -27,6 +27,7 @@ public function definition(): array
{ {
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'username' => fake()->unique()->userName(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => self::$password ??= Hash::make('password'), 'password' => self::$password ??= Hash::make('password'),

View File

@@ -17,6 +17,7 @@ public function up(): void
$table->id(); $table->id();
$table->foreignId('role_id')->default(1)->constrained(); $table->foreignId('role_id')->default(1)->constrained();
$table->string('name'); $table->string('name');
$table->string('username')->unique();
$table->string('email')->unique(); $table->string('email')->unique();
$table->string('azure_id')->nullable()->unique(); $table->string('azure_id')->nullable()->unique();
$table->string('photo')->nullable(); $table->string('photo')->nullable();

View File

@@ -1,22 +1,32 @@
# Database Schema Documentation # Database Schema Documentation
> Generated: 2026-02-03 05:38:33 > Generated: 2026-03-19 10:55:06
> Database: go-no-go > Database: go-no-go
> Total Tables: 13 > Total Tables: 23
## Table of Contents ## Table of Contents
- [action_events](#action_events) - [action_events](#action_events)
- [answers](#answers)
- [cache](#cache) - [cache](#cache)
- [cache_locks](#cache_locks) - [cache_locks](#cache_locks)
- [categories](#categories)
- [configs](#configs)
- [failed_jobs](#failed_jobs) - [failed_jobs](#failed_jobs)
- [job_batches](#job_batches) - [job_batches](#job_batches)
- [jobs](#jobs) - [jobs](#jobs)
- [logs](#logs)
- [migrations](#migrations) - [migrations](#migrations)
- [nova_field_attachments](#nova_field_attachments) - [nova_field_attachments](#nova_field_attachments)
- [nova_notifications](#nova_notifications) - [nova_notifications](#nova_notifications)
- [nova_pending_field_attachments](#nova_pending_field_attachments) - [nova_pending_field_attachments](#nova_pending_field_attachments)
- [password_reset_tokens](#password_reset_tokens) - [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) - [sessions](#sessions)
- [users](#users) - [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 ## cache
| Field | Type | Null | Key | Default | Extra | Foreign Key | | 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 ## failed_jobs
| Field | Type | Null | Key | Default | Extra | Foreign Key | | 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 ## migrations
| Field | Type | Null | Key | Default | Extra | Foreign Key | | 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 ## sessions
| Field | Type | Null | Key | Default | Extra | Foreign Key | | Field | Type | Null | Key | Default | Extra | Foreign Key |
@@ -193,10 +375,18 @@ ## users
| Field | Type | Null | Key | Default | Extra | Foreign Key | | Field | Type | Null | Key | Default | Extra | Foreign Key |
|-------|------|------|-----|---------|-------|-------------| |-------|------|------|-----|---------|-------|-------------|
| id | bigint unsigned | NO | PRI | NULL | auto_increment | | | id | bigint unsigned | NO | PRI | NULL | auto_increment | |
| role_id | bigint unsigned | NO | MUL | 1 | | → roles.id |
| name | varchar(255) | NO | | NULL | | | | name | varchar(255) | NO | | NULL | | |
| username | varchar(255) | NO | UNI | NULL | | |
| email | 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 | | | | email_verified_at | timestamp | YES | | NULL | | |
| password | varchar(255) | NO | | NULL | | | | password | varchar(255) | YES | | NULL | | |
| two_factor_secret | text | YES | | NULL | | | | two_factor_secret | text | YES | | NULL | | |
| two_factor_recovery_codes | text | YES | | NULL | | | | two_factor_recovery_codes | text | YES | | NULL | | |
| two_factor_confirmed_at | timestamp | YES | | NULL | | | | two_factor_confirmed_at | timestamp | YES | | NULL | | |
@@ -204,5 +394,9 @@ ## users
| created_at | timestamp | YES | | NULL | | | | created_at | timestamp | YES | | NULL | | |
| updated_at | timestamp | YES | | NULL | | | | updated_at | timestamp | YES | | NULL | | |
### Foreign Key Constraints
- **users_role_id_foreign**: `role_id``roles.id`
--- ---

View File

@@ -19,6 +19,7 @@ public function run(): void
DB::table('users')->insert([ DB::table('users')->insert([
'name' => 'Jonathan', 'name' => 'Jonathan',
'username' => 'jonathan.van.rij@agerion.nl',
'email' => 'jonathan.van.rij@agerion.nl', 'email' => 'jonathan.van.rij@agerion.nl',
'password' => bcrypt('secret'), 'password' => bcrypt('secret'),
'email_verified_at' => now(), 'email_verified_at' => now(),

View File

@@ -37,7 +37,7 @@
// Dev auto-login route // Dev auto-login route
Route::get('/login-for-testing', function () { 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); auth()->login($user);

View File

@@ -5,6 +5,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser; use Laravel\Socialite\Two\User as SocialiteUser;
use Mockery; 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'); $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([ DB::table('users')->insert([
'email' => 'existing@example.com', 'username' => 'existing@example.com',
'email' => 'real@example.com',
'name' => 'Original Name', 'name' => 'Original Name',
'created_at' => now(),
'updated_at' => now(),
]); ]);
$existingUser = User::where('username', 'existing@example.com')->first();
$socialiteUser = Mockery::mock(SocialiteUser::class); $socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getEmail')->andReturn('existing@example.com'); $socialiteUser->shouldReceive('getEmail')->andReturn('existing@example.com');
$socialiteUser->shouldReceive('getName')->andReturn('Updated Name'); $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('getAvatar')->andReturn(null);
$socialiteUser->shouldReceive('offsetExists')->andReturn(false); $socialiteUser->shouldReceive('offsetExists')->andReturn(false);
$socialiteUser->user = [ $socialiteUser->user = [
'mail' => 'real@example.com',
'jobTitle' => null, 'jobTitle' => null,
'department' => null, 'department' => null,
'companyName' => null, 'companyName' => null,
@@ -62,11 +69,12 @@ public function test_callback_matches_existing_user_by_email(): void
$this->get('/auth/callback') $this->get('/auth/callback')
->assertRedirect('/'); ->assertRedirect('/');
$this->assertEquals(1, User::where('email', 'existing@example.com')->count()); $this->assertEquals(1, User::where('username', 'existing@example.com')->count());
$existingUser->refresh(); $existingUser->refresh();
$this->assertEquals('Updated Name', $existingUser->name); $this->assertEquals('Updated Name', $existingUser->name);
$this->assertEquals('real@example.com', $existingUser->email);
$this->assertAuthenticatedAs($existingUser); $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 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', 'email' => 'jonathan@blijnder.nl',
'name' => 'Jonathan', 'name' => 'Jonathan',
'created_at' => now(),
'updated_at' => now(),
]); ]);
$this->get('/login-for-testing') $this->get('/login-for-testing')
->assertRedirect('/'); ->assertRedirect('/');
$user = User::where('email', 'jonathan@blijnder.nl')->first(); $user = User::where('username', 'jonathan.van.rij@agerion.nl')->first();
$this->assertAuthenticatedAs($user); $this->assertAuthenticatedAs($user);
} }