adds roles

This commit is contained in:
2026-02-16 11:19:06 +01:00
parent ebaeb1722d
commit 4dc64c22cb
29 changed files with 495 additions and 89 deletions

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
final class DevMenuCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'menu';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Developer tools menu';
public function handle(): int
{
if (! in_array(app()->environment(), ['local', 'testing'])) {
$this->error('This command can only be run in local or testing environments.');
return Command::FAILURE;
}
$this->info('');
$this->info(' ╔═══════════════════════════════╗');
$this->info(' ║ Go No Go — Dev Tools ║');
$this->info(' ╚═══════════════════════════════╝');
$this->info('');
$choice = $this->choice('Select an action', [
0 => 'Exit',
1 => 'Fresh migrate, seed & build',
]);
if ($choice === 'Exit') {
$this->info('Bye!');
return Command::SUCCESS;
}
if ($choice === 'Fresh migrate, seed & build') {
$this->freshMigrateAndBuild();
}
return Command::SUCCESS;
}
/**
* Runs migrate:fresh with seeding, then runs npm build.
*
* Displays output from both processes and confirms success or failure.
*/
private function freshMigrateAndBuild(): void
{
$this->info('');
$this->comment('Running migrate:fresh --seed...');
$this->call('migrate:fresh', ['--seed' => true]);
$this->info('');
$this->comment('Running npm run build...');
$process = new Process(['npm', 'run', 'build']);
$process->setWorkingDirectory(base_path());
$process->setTimeout(120);
$process->run(function (string $type, string $output): void {
$this->output->write($output);
});
if ($process->isSuccessful()) {
$this->info('');
$this->info('Environment rebuilt successfully.');
} else {
$this->error('Build failed.');
}
}
}

View File

@@ -5,6 +5,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Role;
use App\Models\User;
use App\Services\ActivityLogger;
use Illuminate\Http\RedirectResponse;
@@ -29,17 +30,25 @@ public function callback(): RedirectResponse
{
$azureUser = Socialite::driver('azure')->user();
$user = User::query()->firstOrCreate(
$user = User::query()->updateOrCreate(
['email' => $azureUser->getEmail()],
[
'name' => $azureUser->getName(),
'password' => null,
'azure_id' => $azureUser->getId(),
'photo' => $azureUser->getAvatar(),
'job_title' => Arr::get($azureUser->user, 'jobTitle'),
'department' => Arr::get($azureUser->user, 'department'),
'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]);
}
auth()->login($user);
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser, 'companyName')]);
ActivityLogger::log('login', $user->id, metadata: ['email' => $user->email, 'firm_name' => Arr::get($azureUser->user, 'companyName')]);
return redirect('/');
}

View File

@@ -68,10 +68,6 @@ public function update(UpdateSessionRequest $request, Session $session): Redirec
{
$validated = $request->validated();
if (Arr::has($validated, 'basic_info')) {
$session->update(['basic_info' => Arr::get($validated, 'basic_info')]);
}
if (Arr::has($validated, 'answers')) {
$this->saveAnswers($session, Arr::get($validated, 'answers'));
}

View File

@@ -6,7 +6,9 @@
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate;
use Inertia\Middleware;
use Laravel\Nova\Nova;
final class HandleInertiaRequests extends Middleware
{
@@ -32,6 +34,7 @@ public function share(Request $request): array
...parent::share($request),
'auth' => [
'user' => $this->getAuthenticatedUser(),
'logo_href' => $this->getLogoHref(),
],
'flash' => [
'success' => fn () => Arr::get($request->session()->all(), 'success'),
@@ -57,4 +60,18 @@ private function getAuthenticatedUser(): ?array
'email' => $user->email,
];
}
/**
* Determine logo href based on user Nova access.
*/
private function getLogoHref(): string
{
$user = auth()->user();
if ($user !== null && Gate::allows('viewNova', $user)) {
return Nova::path();
}
return '/';
}
}

View File

@@ -22,11 +22,6 @@ public function authorize(): bool
public function rules(): array
{
return [
'basic_info' => ['sometimes', 'required', 'array'],
'basic_info.client_name' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.client_contact' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.lead_firm_name' => ['required_with:basic_info', 'string', 'max:255'],
'basic_info.lead_firm_contact' => ['required_with:basic_info', 'string', 'max:255'],
'answers' => ['sometimes', 'array'],
'answers.*.value' => ['nullable', 'string', 'in:yes,no,not_applicable'],
'answers.*.text_value' => ['nullable', 'string', 'max:10000'],
@@ -41,20 +36,6 @@ public function rules(): array
public function messages(): array
{
return [
'basic_info.required' => 'Basic information is required.',
'basic_info.array' => 'Basic information must be a valid data structure.',
'basic_info.client_name.required_with' => 'The client name is required.',
'basic_info.client_name.string' => 'The client name must be text.',
'basic_info.client_name.max' => 'The client name cannot exceed 255 characters.',
'basic_info.client_contact.required_with' => 'The client contact is required.',
'basic_info.client_contact.string' => 'The client contact must be text.',
'basic_info.client_contact.max' => 'The client contact cannot exceed 255 characters.',
'basic_info.lead_firm_name.required_with' => 'The lead firm name is required.',
'basic_info.lead_firm_name.string' => 'The lead firm name must be text.',
'basic_info.lead_firm_name.max' => 'The lead firm name cannot exceed 255 characters.',
'basic_info.lead_firm_contact.required_with' => 'The lead firm contact is required.',
'basic_info.lead_firm_contact.string' => 'The lead firm contact must be text.',
'basic_info.lead_firm_contact.max' => 'The lead firm contact cannot exceed 255 characters.',
'answers.array' => 'Answers must be a valid data structure.',
'answers.*.value.in' => 'Answer value must be yes, no, or not_applicable.',
'answers.*.text_value.string' => 'Answer text must be text.',

View File

@@ -7,6 +7,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Question extends Model
{
@@ -48,4 +49,12 @@ public function questionGroup(): BelongsTo
{
return $this->belongsTo(QuestionGroup::class);
}
/**
* Get all answers for this question.
*/
public function answers(): HasMany
{
return $this->hasMany(Answer::class);
}
}

23
app/Models/Role.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Role extends Model
{
protected $fillable = [
'name',
];
/**
* Get all users with this role.
*/
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

@@ -25,7 +25,6 @@ final class Session extends Model
'status',
'score',
'result',
'basic_info',
'additional_comments',
'completed_at',
];
@@ -40,7 +39,6 @@ protected function casts(): array
'category_id' => 'integer',
'screening_id' => 'integer',
'score' => 'integer',
'basic_info' => 'array',
'completed_at' => 'datetime',
];
}

View File

@@ -5,6 +5,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -23,6 +24,12 @@ final class User extends Authenticatable
'name',
'email',
'password',
'azure_id',
'photo',
'job_title',
'department',
'phone',
'role_id',
];
/**
@@ -33,6 +40,7 @@ final class User extends Authenticatable
protected $hidden = [
'password',
'remember_token',
'azure_id',
];
/**
@@ -45,9 +53,18 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role_id' => 'integer',
];
}
/**
* Get the role assigned to this user.
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Get all sessions for this user.
*/

View File

@@ -49,6 +49,22 @@ final class AnswerResource extends Resource
*/
public static $with = ['session', 'question'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Answers';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Answer';
}
/**
* Get the fields displayed by the resource.
*
@@ -76,6 +92,7 @@ public function fields(NovaRequest $request): array
->rules('nullable', 'max:255'),
Textarea::make('Text Value')
->alwaysShow()
->rules('nullable'),
DateTime::make('Created At')

View File

@@ -42,6 +42,22 @@ final class CategoryResource extends Resource
*/
public static $displayInNavigation = false;
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Categories';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Category';
}
/**
* Get the fields displayed by the resource.
*

View File

@@ -56,6 +56,22 @@ final class LogResource extends Resource
*/
public static $with = ['user', 'session', 'category'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Logs';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Log';
}
/**
* Get the fields displayed by the resource.
*

View File

@@ -44,6 +44,22 @@ final class QuestionGroupResource extends Resource
*/
public static $displayInNavigation = false;
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Question Groups';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Question Group';
}
/**
* Get the fields displayed by the resource.
*

View File

@@ -7,6 +7,7 @@
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\Boolean;
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;
@@ -51,6 +52,22 @@ final class QuestionResource extends Resource
*/
public static $group = 'Questionnaire';
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Questions';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Question';
}
/**
* Get the fields displayed by the resource.
*
@@ -111,6 +128,8 @@ public function fields(NovaRequest $request): array
->exceptOnForms()
->sortable()
->filterable(),
HasMany::make('Answers', 'answers', AnswerResource::class),
];
}

75
app/Nova/RoleResource.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Nova;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\HasMany;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
final class RoleResource extends Resource
{
public static string $model = \App\Models\Role::class;
public static $title = 'name';
public static $search = ['id', 'name'];
public static $displayInNavigation = false;
public static function label(): string
{
return 'Roles';
}
public static function singularLabel(): string
{
return 'Role';
}
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
Text::make('Name')
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
DateTime::make('Created At')
->exceptOnForms()
->sortable(),
DateTime::make('Updated At')
->exceptOnForms()
->sortable(),
HasMany::make('Users', 'users', User::class),
];
}
public function cards(NovaRequest $request): array
{
return [];
}
public function filters(NovaRequest $request): array
{
return [];
}
public function lenses(NovaRequest $request): array
{
return [];
}
public function actions(NovaRequest $request): array
{
return [];
}
}

View File

@@ -57,6 +57,22 @@ final class ScreeningResource extends Resource
*/
public static $with = ['user'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Screenings';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Screening';
}
/**
* Get the fields displayed by the resource.
*

View File

@@ -5,12 +5,11 @@
namespace App\Nova;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\Code;
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\Select;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;
use Maatwebsite\LaravelNovaExcel\Actions\DownloadExcel;
@@ -59,6 +58,22 @@ final class SessionResource extends Resource
*/
public static $with = ['user', 'category', 'screening'];
/**
* Get the displayable label of the resource.
*/
public static function label(): string
{
return 'Sessions';
}
/**
* Get the displayable singular label of the resource.
*/
public static function singularLabel(): string
{
return 'Session';
}
/**
* Get the fields displayed by the resource.
*
@@ -85,11 +100,16 @@ public function fields(NovaRequest $request): array
->filterable()
->rules('nullable'),
Text::make('Status')
Select::make('Status')
->options([
'in_progress' => 'In Progress',
'completed' => 'Completed',
'abandoned' => 'Abandoned',
])
->displayUsingLabels()
->sortable()
->filterable()
->copyable()
->rules('required', 'max:255'),
->readonly(),
Number::make('Score')
->sortable()
@@ -97,15 +117,16 @@ public function fields(NovaRequest $request): array
->copyable()
->rules('nullable', 'integer'),
Text::make('Result')
Select::make('Result')
->options([
'go' => 'Go',
'no_go' => 'No Go',
'consult_leadership' => 'Consult Leadership',
])
->displayUsingLabels()
->sortable()
->filterable()
->copyable()
->rules('nullable', 'max:255'),
Code::make('Basic Info', 'basic_info')
->json()
->rules('nullable'),
->readonly(),
Textarea::make('Additional Comments')
->rules('nullable'),

View File

@@ -6,6 +6,7 @@
use Illuminate\Http\Request;
use Laravel\Nova\Auth\PasswordValidationRules;
use Laravel\Nova\Fields\BelongsTo;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Password;
use Laravel\Nova\Fields\Text;
@@ -35,7 +36,7 @@ final class User extends Resource
* @var array
*/
public static $search = [
'id', 'name', 'email',
'id', 'name', 'email', 'department', 'job_title',
];
/**
@@ -48,6 +49,10 @@ public function fields(NovaRequest $request): array
return [
ID::make()->sortable(),
BelongsTo::make('Role', 'role', RoleResource::class)
->sortable()
->filterable(),
Text::make('Name')
->sortable()
->rules('required', 'max:255'),
@@ -58,6 +63,31 @@ public function fields(NovaRequest $request): array
->creationRules('unique:users,email')
->updateRules('unique:users,email,{{resourceId}}'),
Text::make('Azure ID', 'azure_id')
->onlyOnDetail()
->copyable(),
Text::make('Photo', 'photo')
->onlyOnDetail()
->copyable(),
Text::make('Job Title', 'job_title')
->sortable()
->filterable()
->copyable()
->readonly(),
Text::make('Department')
->sortable()
->filterable()
->copyable()
->readonly(),
Text::make('Phone')
->sortable()
->copyable()
->readonly(),
Password::make('Password')
->onlyOnForms()
->creationRules($this->passwordRules())

View File

@@ -3,8 +3,16 @@
namespace App\Providers;
use App\Models\User;
use App\Nova\Dashboards\Main;
use App\Nova\LogResource;
use App\Nova\QuestionResource;
use App\Nova\ScreeningResource;
use App\Nova\SessionResource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Laravel\Fortify\Features;
use Laravel\Nova\Menu\MenuItem;
use Laravel\Nova\Menu\MenuSection;
use Laravel\Nova\Nova;
use Laravel\Nova\NovaApplicationServiceProvider;
@@ -17,7 +25,25 @@ public function boot(): void
{
parent::boot();
//
Nova::mainMenu(function (Request $request) {
return [
MenuSection::dashboard(Main::class)->icon('home'),
MenuSection::make('Questionnaire', [
MenuItem::resource(QuestionResource::class),
MenuItem::resource(ScreeningResource::class),
MenuItem::resource(SessionResource::class),
])->icon('clipboard-document-list')->collapsible(),
MenuSection::make('Logs', [
MenuItem::resource(LogResource::class),
])->icon('chart-bar')->collapsible(),
MenuSection::make('Users', [
MenuItem::resource(\App\Nova\User::class),
])->icon('users')->collapsible(),
];
});
}
/**