Compare commits
14 Commits
7f380303ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7f1d8b1c | |||
| 17db4962c4 | |||
| 88895872c4 | |||
| 1cb157da42 | |||
| dbafa6c99c | |||
| a373b60750 | |||
| 124c707634 | |||
| a046c017fa | |||
| 6fce8d8436 | |||
| d73064a718 | |||
| c9a2ad0451 | |||
| 20f66dddaa | |||
| 61bd625c07 | |||
| bdc567745a |
BIN
.playwright-mcp/page-2026-03-19T11-19-05-308Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-19T11-19-05-308Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
.playwright-mcp/page-2026-03-19T12-08-19-912Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-19T12-08-19-912Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -8,50 +8,55 @@
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLogger;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
final class SocialiteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Redirect the user to the Azure AD authentication page.
|
||||
* Logs the Azure config (client_id prefix, redirect URI, tenant) and generated redirect URL.
|
||||
*/
|
||||
public function redirect(): RedirectResponse
|
||||
{
|
||||
return Socialite::driver('azure')->redirect();
|
||||
$azureConfig = config('services.azure');
|
||||
|
||||
Log::info('[Azure SSO] Initiating redirect', [
|
||||
'client_id_prefix' => substr((string) Arr::get($azureConfig, 'client_id', ''), 0, 4),
|
||||
'redirect_uri' => Arr::get($azureConfig, 'redirect'),
|
||||
'tenant' => Arr::get($azureConfig, 'tenant'),
|
||||
]);
|
||||
|
||||
$response = Socialite::driver('azure')->redirect();
|
||||
|
||||
Log::info('[Azure SSO] Redirect URL generated', [
|
||||
'redirect_url' => $response->getTargetUrl(),
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the callback from Azure AD after authentication.
|
||||
* Logs request parameters, the resolved Azure user, and the upserted local user.
|
||||
* Wraps the entire flow in a try/catch to capture and log any exceptions.
|
||||
*/
|
||||
public function callback(): RedirectResponse
|
||||
{
|
||||
$azureUser = Socialite::driver('azure')->user();
|
||||
try {
|
||||
return $this->processCallback();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[Azure SSO] Exception during callback', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => $azureUser->getEmail()],
|
||||
[
|
||||
'name' => $azureUser->getName(),
|
||||
'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')),
|
||||
]
|
||||
);
|
||||
|
||||
if ($user->role_id === null) {
|
||||
$user->update(['role_id' => Role::where('name', 'user')->first()->id]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,4 +73,75 @@ public function logout(Request $request): RedirectResponse
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the full Azure AD callback flow: resolve the user, upsert the local record,
|
||||
* assign a default role if needed, log the user in, and record the activity.
|
||||
*/
|
||||
private function processCallback(): RedirectResponse
|
||||
{
|
||||
Log::info('[Azure SSO] Callback received', [
|
||||
'query_code' => substr((string) request()->query('code', ''), 0, 8).'…',
|
||||
'query_state' => request()->query('state'),
|
||||
'query_error' => request()->query('error'),
|
||||
'query_error_description' => request()->query('error_description'),
|
||||
]);
|
||||
|
||||
$azureUser = Socialite::driver('azure')->user();
|
||||
|
||||
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', json_decode(json_encode($azureUser), true));
|
||||
|
||||
try {
|
||||
$user = User::query()->updateOrCreate(
|
||||
['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(),
|
||||
]
|
||||
);
|
||||
|
||||
Log::info('[Azure SSO] Local user upserted', [
|
||||
'user_id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'was_recent' => $user->wasRecentlyCreated,
|
||||
'role_id' => $user->role_id,
|
||||
]);
|
||||
|
||||
if ($user->role_id === null) {
|
||||
$user->update(['role_id' => Role::where('name', 'user')->first()->id]);
|
||||
|
||||
Log::info('[Azure SSO] Default role assigned', [
|
||||
'user_id' => $user->id,
|
||||
'role_id' => $user->role_id,
|
||||
]);
|
||||
}
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
|
||||
} catch (QueryException $e) {
|
||||
Log::error('[Azure SSO] Database error during user upsert', ['message' => $e->getMessage(), 'email' => $azureUser->getEmail()]);
|
||||
|
||||
return redirect('/')->with('error', 'Something went wrong during sign-in. Please try again or contact support.');
|
||||
}
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ final class User extends Authenticatable
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'azure_id',
|
||||
|
||||
@@ -12,16 +12,17 @@
|
||||
final class DownloadExcel extends BaseDownloadExcel
|
||||
{
|
||||
protected $onlyIndexFields = false;
|
||||
|
||||
/**
|
||||
* @param Model|mixed $row
|
||||
*/
|
||||
public function map($row): array
|
||||
{
|
||||
$only = array_map('strval', $this->getOnly());
|
||||
$only = array_map('strval', $this->getOnly());
|
||||
$except = $this->getExcept();
|
||||
|
||||
if ($row instanceof Model) {
|
||||
if (!$this->onlyIndexFields && $except === null && (!is_array($only) || count($only) === 0)) {
|
||||
if (! $this->onlyIndexFields && $except === null && (! is_array($only) || count($only) === 0)) {
|
||||
$except = $row->getHidden();
|
||||
}
|
||||
|
||||
@@ -43,11 +44,11 @@ public function map($row): array
|
||||
protected function replaceFieldValuesWhenOnResource(Model $model, array $only = []): array
|
||||
{
|
||||
$resource = $this->resolveResource($model);
|
||||
$fields = $this->resourceFields($resource);
|
||||
$fields = $this->resourceFields($resource);
|
||||
|
||||
$row = [];
|
||||
foreach ($fields as $field) {
|
||||
if (!$this->isExportableField($field)) {
|
||||
if (! $this->isExportableField($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,16 @@
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
use Laravel\Nova\Fields\BelongsTo;
|
||||
use Laravel\Nova\Fields\DateTime;
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Text;
|
||||
use Laravel\Nova\Fields\Textarea;
|
||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
|
||||
final class AnswerResource extends Resource
|
||||
{
|
||||
|
||||
/**
|
||||
* The model the resource corresponds to.
|
||||
*
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use Laravel\Nova\Fields\DateTime;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
use Laravel\Nova\Fields\HasMany;
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Number;
|
||||
use Laravel\Nova\Fields\Text;
|
||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
|
||||
final class CategoryResource extends Resource
|
||||
{
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
use Laravel\Nova\Fields\BelongsTo;
|
||||
use Laravel\Nova\Fields\Code;
|
||||
use Laravel\Nova\Fields\DateTime;
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Text;
|
||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
|
||||
final class LogResource extends Resource
|
||||
{
|
||||
|
||||
@@ -4,15 +4,14 @@
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
use Laravel\Nova\Fields\BelongsTo;
|
||||
use Laravel\Nova\Fields\DateTime;
|
||||
use Laravel\Nova\Fields\HasMany;
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Number;
|
||||
use Laravel\Nova\Fields\Text;
|
||||
use Laravel\Nova\Fields\Textarea;
|
||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
|
||||
final class QuestionGroupResource extends Resource
|
||||
{
|
||||
|
||||
@@ -19,7 +19,6 @@ public static function perPageViaRelationshipOptions()
|
||||
return [10, 25, 50];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build an "index" query for the given resource.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
use Laravel\Nova\Fields\BelongsTo;
|
||||
use Laravel\Nova\Fields\Boolean;
|
||||
use Laravel\Nova\Fields\DateTime;
|
||||
@@ -11,7 +12,6 @@
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Number;
|
||||
use Laravel\Nova\Http\Requests\NovaRequest;
|
||||
use App\Nova\Actions\DownloadExcel;
|
||||
|
||||
final class ScreeningResource extends Resource
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
use App\Models\User;
|
||||
use App\Nova\CategoryResource;
|
||||
use App\Nova\ConfigResource;
|
||||
use App\Nova\Dashboards\Main;
|
||||
use App\Nova\LogResource;
|
||||
use App\Nova\QuestionGroupResource;
|
||||
use App\Nova\QuestionResource;
|
||||
@@ -30,7 +29,10 @@ public function boot(): void
|
||||
|
||||
Nova::mainMenu(function (Request $request) {
|
||||
return [
|
||||
MenuSection::dashboard(Main::class)->icon('home'),
|
||||
MenuSection::make('Main', [
|
||||
MenuItem::link('Dashboard', '/dashboards/main'),
|
||||
MenuItem::externalLink('To questionnaire ↗', '/'),
|
||||
])->icon('home'),
|
||||
|
||||
MenuSection::make('Questionnaire', [
|
||||
MenuItem::resource(QuestionResource::class),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add the username column to the users table.
|
||||
* Nullable to allow existing users to exist without a username.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->string('username')->unique()->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the username column from the users table.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('username');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Code Style Reviewer
|
||||
|
||||
Reviews code against project standards including Laravel Pint formatting, strict types declarations, final class declarations, proper use of `Illuminate\Support\Arr` for array operations, adherence to naming conventions, and alignment with CLAUDE.md instructions.
|
||||
|
||||
## Purpose
|
||||
|
||||
This agent is launched as the **final step** of every task that involves code changes. It ensures all written code complies with project coding standards.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Cypress Code Writer
|
||||
|
||||
Writes, updates, and debugs Cypress E2E tests for user workflows.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent when writing new Cypress E2E tests, updating existing test files, debugging failing tests, adding test coverage for user workflows, or creating test utilities.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Docs Writer
|
||||
|
||||
Creates and updates documentation files and maintains the documentation index.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent to create new documentation, update existing docs when code changes, and keep `docs/index.md` current with all documentation files.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Laravel Blade Code Writer
|
||||
|
||||
Creates and modifies Laravel Blade templates and views.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent when working with Blade templates in `resources/views/`, including creating new templates, fixing layout issues, implementing UI changes, or refactoring template code.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Laravel Config Code Writer
|
||||
|
||||
Manages database-driven config groups, fields, and the Config Service.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent when creating new Config Field classes, updating the Config Service, modifying the Config Model, updating the Nova Config Resource, creating migrations for new config groups, or debugging config value resolution.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Laravel Nova Code Writer
|
||||
|
||||
Creates and modifies Laravel Nova 5 resources, actions, metrics, and dashboards.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent for ALL Nova-related code work in the `App\Nova` namespace, including resources, fields, actions, metrics, lenses, filters, cards, and dashboards.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Laravel PHP Code Writer
|
||||
|
||||
Writes and refactors PHP code (controllers, models, services, migrations) except Nova resources.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent for all PHP code work outside of Nova resources: controllers, models, services, migrations, middleware, form requests, policies, commands, jobs, and any other PHP files.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# PHPUnit Code Writer
|
||||
|
||||
Creates, runs, and validates PHPUnit tests with mocking support.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent to write new test classes, fix failing tests (test issues only), create test seeders with DB facade, set up mocking routes for external API calls, or validate test coverage for PHP classes.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -1,11 +0,0 @@
|
||||
# Vue Code Writer
|
||||
|
||||
Builds Vue.js components in the Laravel + Inertia.js stack.
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this agent when creating new Vue components, modifying existing ones, implementing Inertia page components, handling form submissions with useForm, integrating with Laravel backend endpoints, or fixing Vue-related bugs.
|
||||
|
||||
## Relevant Documentation
|
||||
|
||||
- `docs/index.md` - Master index of all project documentation
|
||||
@@ -14,29 +14,3 @@ ### Root Level
|
||||
### Frontend
|
||||
|
||||
- `docs/theming-templating-vue.md` - Design tokens, Tailwind config, layout, shared Vue components, RadioButtonGroup pill buttons, icon and scoring color standards
|
||||
|
||||
### Agents
|
||||
|
||||
- `docs/agents/code-style-reviewer.md` - Project-specific notes for the code style review agent
|
||||
- `docs/agents/cypress-code-writer.md` - Project-specific notes for the Cypress E2E test writer agent
|
||||
- `docs/agents/docs-writer.md` - Project-specific notes for the documentation writer agent
|
||||
- `docs/agents/laravel-blade-code-writer.md` - Project-specific notes for the Blade template agent
|
||||
- `docs/agents/laravel-config-code-writer.md` - Project-specific notes for the config management agent
|
||||
- `docs/agents/laravel-nova-code-writer.md` - Project-specific notes for the Nova resource agent
|
||||
- `docs/agents/laravel-php-code-writer.md` - Project-specific notes for the Laravel PHP code agent
|
||||
- `docs/agents/phpunit-code-writer.md` - Project-specific notes for the PHPUnit test agent
|
||||
- `docs/agents/vue-code-writer.md` - Project-specific notes for the Vue.js component agent
|
||||
|
||||
## Available Sub-Agents
|
||||
|
||||
These are the sub-agents available globally that can be used in this project:
|
||||
|
||||
- `code-style-reviewer` - Reviews code against project standards including Laravel Pint, strict types, and naming conventions
|
||||
- `cypress-code-writer` - Writes, updates, and debugs Cypress E2E tests for user workflows
|
||||
- `docs-writer` - Creates and updates documentation files and maintains the documentation index
|
||||
- `laravel-blade-code-writer` - Creates and modifies Laravel Blade templates and views
|
||||
- `laravel-config-code-writer` - Manages database-driven config groups, fields, and the Config Service
|
||||
- `laravel-nova-code-writer` - Creates and modifies Laravel Nova 5 resources, actions, metrics, and dashboards
|
||||
- `laravel-php-code-writer` - Writes and refactors PHP code (controllers, models, services, migrations) except Nova resources
|
||||
- `phpunit-code-writer` - Creates, runs, and validates PHPUnit tests with mocking support
|
||||
- `vue-code-writer` - Builds Vue.js components in the Laravel + Inertia.js stack
|
||||
|
||||
33
docs/updates.md
Normal file
33
docs/updates.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Updates — March 16, 2026
|
||||
|
||||
## New Features
|
||||
|
||||
### Disclaimer Page
|
||||
A new disclaimer page has been added to the application. When a disclaimer text is configured, users must read and accept it before they can start a screening. The disclaimer page renders formatted content (including headings, lists, links, and tables) and is accessible via a link on the landing page.
|
||||
|
||||
- A checkbox appears on the landing page requiring users to agree to the disclaimer before continuing
|
||||
- The disclaimer content can be managed by administrators through the admin panel
|
||||
|
||||
### Admin Settings Panel
|
||||
A new **Settings** section has been added to the admin panel. This provides a central place for administrators to manage application-wide content, starting with the disclaimer text. Settings are stored in the database and take effect immediately — no deployments or restarts needed.
|
||||
|
||||
### "I Don't Know" Answer Option
|
||||
The pre-screening questionnaire now includes a third answer option: **"I don't know"**, in addition to "Yes" and "No". This gives users a more honest way to respond when they are uncertain, and is scored at half a point.
|
||||
|
||||
### Session Duration Tracking
|
||||
The session result page now shows how long it took to complete a questionnaire. The duration is displayed in a human-readable format (e.g. "4m 32s" or "1h 15m").
|
||||
|
||||
## Improvements
|
||||
|
||||
### Navigation
|
||||
- A **Back button** has been added to every page in the application, making it easy to navigate to the previous step at any time
|
||||
- A **sticky Back button** appears in the top-right corner when you scroll down on longer pages, so you never have to scroll back up to navigate away
|
||||
- Visiting a page that doesn't exist now redirects you back to the landing page instead of showing an error
|
||||
|
||||
### Disclaimer Link Behavior
|
||||
The disclaimer link on the landing page now opens in the same tab for a smoother experience.
|
||||
|
||||
### Visual Polish
|
||||
- Removed heavy background panels from question cards and session details for a cleaner, lighter look
|
||||
- Improved the layout of session details on the result page (category, completion date, and duration are now evenly spaced)
|
||||
- Auto-save actions (answers and comments) no longer show a loading indicator, reducing visual noise while filling in questionnaires
|
||||
118
resources/js/Components/FlashNotification.vue
Normal file
118
resources/js/Components/FlashNotification.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/20/solid'
|
||||
|
||||
const page = usePage()
|
||||
|
||||
const visible = ref(false)
|
||||
const message = ref('')
|
||||
const type = ref('success') // 'success' | 'error'
|
||||
|
||||
let autoDismissTimer = null
|
||||
|
||||
function clearTimer() {
|
||||
if (autoDismissTimer) {
|
||||
clearTimeout(autoDismissTimer)
|
||||
autoDismissTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
clearTimer()
|
||||
}
|
||||
|
||||
function showFlash(flash) {
|
||||
clearTimer()
|
||||
|
||||
if (flash?.error) {
|
||||
message.value = flash.error
|
||||
type.value = 'error'
|
||||
visible.value = true
|
||||
// Error messages stay until manually dismissed
|
||||
} else if (flash?.success) {
|
||||
message.value = flash.success
|
||||
type.value = 'success'
|
||||
visible.value = true
|
||||
// Auto-dismiss success after 8 seconds
|
||||
autoDismissTimer = setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 8000)
|
||||
} else {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for flash prop changes on each Inertia page visit
|
||||
watch(
|
||||
() => page.props.flash,
|
||||
(flash) => showFlash(flash),
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="-translate-y-full opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="-translate-y-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
:class="[
|
||||
'relative z-40 w-full shadow-lg',
|
||||
type === 'error'
|
||||
? 'bg-rose-600'
|
||||
: 'bg-emerald-600',
|
||||
]"
|
||||
role="alert"
|
||||
:aria-live="type === 'error' ? 'assertive' : 'polite'"
|
||||
>
|
||||
<div class="px-6 py-3 flex items-center gap-3 max-w-7xl mx-auto">
|
||||
<!-- Icon -->
|
||||
<component
|
||||
:is="type === 'error' ? ExclamationTriangleIcon : CheckCircleIcon"
|
||||
class="w-5 h-5 text-white/90 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="flex-1 text-sm font-medium text-white leading-snug">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- Dismiss button -->
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto shrink-0 rounded p-1 text-white/70 hover:text-white hover:bg-white/10 transition-colors focus:outline-none focus:ring-2 focus:ring-white/40"
|
||||
aria-label="Dismiss notification"
|
||||
@click="dismiss"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for success auto-dismiss -->
|
||||
<div
|
||||
v-if="type === 'success'"
|
||||
class="absolute bottom-0 left-0 h-[2px] bg-white/30 animate-[shrink_8s_linear_forwards]"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
</style>
|
||||
@@ -18,15 +18,41 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Array of option values that should be individually disabled.
|
||||
// An already-selected option is never disabled via this prop.
|
||||
disabledOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// Tooltip text shown when hovering over a disabled option.
|
||||
disabledTooltip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* Returns true when an option should be treated as disabled.
|
||||
* An option that is already selected (checked) is never individually disabled
|
||||
* via disabledOptions — it remains selectable so the user can keep it.
|
||||
*/
|
||||
const isOptionDisabled = (value) => {
|
||||
if (props.disabled) return true
|
||||
if (props.modelValue === value) return false
|
||||
return props.disabledOptions.includes(value)
|
||||
}
|
||||
|
||||
const handleChange = (value) => {
|
||||
// Safety guard: do not emit for disabled options
|
||||
if (isOptionDisabled(value)) return
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const getSegmentClasses = (index) => {
|
||||
const getSegmentClasses = (option, index) => {
|
||||
const optionDisabled = isOptionDisabled(option.value)
|
||||
|
||||
const classes = [
|
||||
'inline-flex',
|
||||
'items-center',
|
||||
@@ -40,11 +66,6 @@ const getSegmentClasses = (index) => {
|
||||
'duration-200',
|
||||
'bg-white/5',
|
||||
'text-gray-400',
|
||||
'cursor-pointer',
|
||||
'hover:bg-white/10',
|
||||
'hover:text-gray-200',
|
||||
'peer-checked:hover:bg-primary-dark',
|
||||
'peer-checked:hover:text-gray-900',
|
||||
'peer-checked:bg-primary',
|
||||
'peer-checked:text-gray-900',
|
||||
'peer-checked:font-semibold',
|
||||
@@ -54,16 +75,25 @@ const getSegmentClasses = (index) => {
|
||||
'peer-focus-visible:ring-offset-surface',
|
||||
]
|
||||
|
||||
if (optionDisabled) {
|
||||
// Disabled: visually subtle, no pointer, no hover effects
|
||||
classes.push('opacity-35', 'cursor-not-allowed')
|
||||
} else {
|
||||
// Enabled: pointer cursor and hover effects
|
||||
classes.push(
|
||||
'cursor-pointer',
|
||||
'hover:bg-white/10',
|
||||
'hover:text-gray-200',
|
||||
'peer-checked:hover:bg-primary-dark',
|
||||
'peer-checked:hover:text-gray-900',
|
||||
)
|
||||
}
|
||||
|
||||
// All except last: add divider
|
||||
if (index < props.options.length - 1) {
|
||||
classes.push('border-r', 'border-white/10')
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
if (props.disabled) {
|
||||
classes.push('disabled:opacity-50', 'disabled:cursor-not-allowed')
|
||||
}
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
</script>
|
||||
@@ -80,12 +110,12 @@ const getSegmentClasses = (index) => {
|
||||
:name="name"
|
||||
:value="option.value"
|
||||
:checked="modelValue === option.value"
|
||||
:disabled="disabled"
|
||||
:disabled="isOptionDisabled(option.value)"
|
||||
:data-cy="option.value === 'not_applicable' ? 'na' : option.value"
|
||||
@change="handleChange(option.value)"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<span :class="getSegmentClasses(index)">
|
||||
<span :class="getSegmentClasses(option, index)" :title="isOptionDisabled(option.value) ? disabledTooltip : ''">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import PageHeader from '@/Components/PageHeader.vue'
|
||||
import FlashNotification from '@/Components/FlashNotification.vue'
|
||||
|
||||
const page = usePage()
|
||||
|
||||
@@ -13,6 +14,7 @@ const pageTitle = computed(() => {
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<PageHeader :title="pageTitle" />
|
||||
<FlashNotification />
|
||||
|
||||
<!-- Growth symbol watermark -->
|
||||
<img
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { Head, router, usePage } from '@inertiajs/vue3'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/20/solid'
|
||||
import { ArrowRightStartOnRectangleIcon } from '@heroicons/vue/20/solid'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import AppButton from '@/Components/AppButton.vue'
|
||||
|
||||
@@ -43,6 +43,8 @@ const userInfo = computed(() => {
|
||||
const handleContinue = () => {
|
||||
router.post('/screening')
|
||||
}
|
||||
|
||||
const handleLogout = () => router.post('/logout')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,11 +110,9 @@ const handleContinue = () => {
|
||||
I have read and agree to the
|
||||
<a
|
||||
href="/disclaimer"
|
||||
target="_blank"
|
||||
class="text-primary underline underline-offset-2 inline-flex items-center gap-0.5 hover:text-primary-dark transition-colors duration-150"
|
||||
class="text-primary underline underline-offset-2 hover:text-primary-dark transition-colors duration-150"
|
||||
>
|
||||
disclaimer
|
||||
<ArrowTopRightOnSquareIcon class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
@@ -127,6 +127,15 @@ const handleContinue = () => {
|
||||
>
|
||||
Continue
|
||||
</AppButton>
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
type="button"
|
||||
class="mt-3 flex items-center gap-1.5 mx-auto text-sm text-gray-500 hover:text-gray-300 transition-colors duration-150 cursor-pointer"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<ArrowRightStartOnRectangleIcon class="w-4 h-4" />
|
||||
Log out
|
||||
</button>
|
||||
<AppButton v-else size="lg" href="/login" external>
|
||||
Log in
|
||||
</AppButton>
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Head, useForm } from '@inertiajs/vue3'
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/outline'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import AppButton from '@/Components/AppButton.vue'
|
||||
import RadioButtonGroup from '@/Components/RadioButtonGroup.vue'
|
||||
|
||||
defineOptions({ layout: AppLayout })
|
||||
|
||||
const MAX_MAYBE_ANSWERS = 8
|
||||
|
||||
const props = defineProps({
|
||||
screening: {
|
||||
type: Object,
|
||||
@@ -49,6 +52,14 @@ const allAnswered = computed(() => {
|
||||
return Object.values(form.answers).every(v => v !== null)
|
||||
})
|
||||
|
||||
const unknownCount = computed(() => {
|
||||
return Object.values(form.answers).filter(v => v === 'unknown').length
|
||||
})
|
||||
|
||||
const maybeLimitReached = computed(() => {
|
||||
return unknownCount.value >= MAX_MAYBE_ANSWERS
|
||||
})
|
||||
|
||||
const bottomBackButtonRef = ref(null)
|
||||
const showStickyBack = ref(false)
|
||||
|
||||
@@ -92,6 +103,11 @@ onUnmounted(() => {
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Pre-Screening Questions</h1>
|
||||
<p class="text-gray-400 mb-8">Please answer all questions to proceed.</p>
|
||||
|
||||
<p v-if="maybeLimitReached" class="text-amber-400/80 text-sm mb-6 flex items-center gap-2">
|
||||
<ExclamationTriangleIcon class="h-4 w-4 shrink-0" />
|
||||
You've used all {{ MAX_MAYBE_ANSWERS }} "I don't know" answers. Please choose Yes or No for the remaining questions.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4 mb-8">
|
||||
<div
|
||||
v-for="(question, index) in questions"
|
||||
@@ -111,6 +127,8 @@ onUnmounted(() => {
|
||||
{ value: 'unknown', label: 'I don\'t know' },
|
||||
{ value: 'no', label: 'No' },
|
||||
]"
|
||||
:disabled-options="maybeLimitReached && form.answers[index + 1] !== 'unknown' ? ['unknown'] : []"
|
||||
disabled-tooltip="You can only select "I don't know" for a maximum of 8 questions"
|
||||
/>
|
||||
<p v-if="form.errors[`answers.${index + 1}`]" class="text-red-500 text-sm mt-1">
|
||||
{{ form.errors[`answers.${index + 1}`] }}
|
||||
|
||||
@@ -35,11 +35,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Dev auto-login route
|
||||
Route::get('/login-for-testing', function () {
|
||||
$user = \App\Models\User::where('email', 'jonathan.van.rij@agerion.nl')->first();
|
||||
// Dev auto-login route (local and testing environments only)
|
||||
if (app()->environment(['local', 'testing'])) {
|
||||
Route::get('/login-jonathan', function () {
|
||||
$user = \App\Models\User::where('username', 'jonathan.van.rij@agerion.nl')->first();
|
||||
|
||||
auth()->login($user);
|
||||
auth()->login($user);
|
||||
|
||||
return redirect('/');
|
||||
});
|
||||
return redirect('/');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
$this->get('/login-jonathan')
|
||||
->assertRedirect('/');
|
||||
|
||||
$user = User::where('email', 'jonathan@blijnder.nl')->first();
|
||||
$user = User::where('username', 'jonathan.van.rij@agerion.nl')->first();
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user