From f00f67ae49882986720a245723f893c380bd415b Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 21 May 2026 12:19:47 +0200 Subject: [PATCH] feat: :sparkles: add sites and gitlab users relations to user model --- app/Http/Controllers/Back/UserController.php | 62 ++++++++ .../Users/Concerns/HasUserRequestRules.php | 77 ++++++++++ .../Requests/Back/Users/StoreUserRequest.php | 48 ++++++ .../Requests/Back/Users/UpdateUserRequest.php | 48 ++++++ app/Models/GitlabUser.php | 39 +++++ app/Models/User.php | 48 +++++- config/uwil-backend.php | 16 +- ...05_21_101233_create_gitlab_users_table.php | 30 ++++ lang/fr/back.php | 18 ++- resources/ts/back/Pages/Users/Form.tsx | 106 +++++++++++-- resources/ts/back/Pages/Users/Show.tsx | 141 +++++++++++++++++- resources/ts/back/types/global.d.ts | 4 + resources/ts/back/types/project.d.ts | 8 + routes/back/users.php | 6 + 14 files changed, 620 insertions(+), 31 deletions(-) create mode 100644 app/Http/Controllers/Back/UserController.php create mode 100644 app/Http/Requests/Back/Users/Concerns/HasUserRequestRules.php create mode 100644 app/Http/Requests/Back/Users/StoreUserRequest.php create mode 100644 app/Http/Requests/Back/Users/UpdateUserRequest.php create mode 100644 app/Models/GitlabUser.php create mode 100644 database/migrations/2026_05_21_101233_create_gitlab_users_table.php create mode 100644 routes/back/users.php diff --git a/app/Http/Controllers/Back/UserController.php b/app/Http/Controllers/Back/UserController.php new file mode 100644 index 0000000..93111ee --- /dev/null +++ b/app/Http/Controllers/Back/UserController.php @@ -0,0 +1,62 @@ +validated(); + $user->sites()->syncWithoutDetaching(Arr::get($validatedData, 'sites')); + if (Arr::get($validatedData, 'gitlab_id')) { + $user->gitlabUser()->create([ + 'gitlab_id' => Arr::get($validatedData, 'gitlab_id'), + ]); + } + }); + } + + /** + * Update the specified resource. + * + * @param \Illuminate\Http\Request $request + * @param mixed $user + * @param mixed $callback + * @return \Illuminate\Http\RedirectResponse + */ + public function update( + Request $request, + mixed $user, + mixed $callback = null, + ): \Illuminate\Http\RedirectResponse { + return parent::update($request, $user, function ($request, $user) { + $validatedData = $request->validated(); + $user->sites()->sync(Arr::get($validatedData, 'sites')); + if (Arr::get($validatedData, 'gitlab_id')) { + $user->gitlabUser()->updateOrCreate( + ['user_id' => $user->id], + ['gitlab_id' => $validatedData['gitlab_id']], + ); + } else { + $user->gitlabUser()->delete(); + } + }); + } +} diff --git a/app/Http/Requests/Back/Users/Concerns/HasUserRequestRules.php b/app/Http/Requests/Back/Users/Concerns/HasUserRequestRules.php new file mode 100644 index 0000000..9a9fac3 --- /dev/null +++ b/app/Http/Requests/Back/Users/Concerns/HasUserRequestRules.php @@ -0,0 +1,77 @@ +input('role'); + return [ + 'isClient' => $role === UserRoleRegistry::get('client')->value, + 'isProjectManager' => $role === UserRoleRegistry::get('project-manager')->value, + 'isDevelopper' => $role === UserRoleRegistry::get('developper')->value, + ]; + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function extraPrepareForValidation(): void + { + [ + 'isClient' => $isClient, + 'isProjectManager' => $isProjectManager, + 'isDevelopper' => $isDevelopper, + ] = $this->resolveRoleFlags(); + + $this->merge([ + 'gitlab_id' => $isProjectManager || $isDevelopper ? $this->input('gitlab_id') : null, + 'sites' => $isClient ? $this->input('sites') : null, + ]); + } + + /** + * Get the extra validation rules. + * + * @return array + */ + protected function extraRules(): array + { + [ + 'isClient' => $isClient, + 'isProjectManager' => $isProjectManager, + 'isDevelopper' => $isDevelopper, + ] = $this->resolveRoleFlags(); + + return [ + 'gitlab_id' => $isProjectManager || $isDevelopper ? 'required|integer|min:1' : 'nullable|integer|min:1', + 'sites' => $isClient ? 'required|array' : 'nullable|array', + 'sites.*' => 'integer|exists:sites,id|distinct', + ]; + } + + /** + * Get the extra attributes. + * + * @return array + */ + protected function extraAttributes(): array + { + return [ + 'gitlab_id' => trans('validation.custom.gitlab_id'), + 'sites' => trans_choice('models.classes.sites', \INF), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Back/Users/StoreUserRequest.php b/app/Http/Requests/Back/Users/StoreUserRequest.php new file mode 100644 index 0000000..c1ea2ee --- /dev/null +++ b/app/Http/Requests/Back/Users/StoreUserRequest.php @@ -0,0 +1,48 @@ +extraPrepareForValidation(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + #[\Override] + public function rules(): array + { + return array_merge(parent::rules(), $this->extraRules()); + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + #[\Override] + public function attributes(): array + { + return array_merge(parent::attributes(), $this->extraAttributes()); + } +} diff --git a/app/Http/Requests/Back/Users/UpdateUserRequest.php b/app/Http/Requests/Back/Users/UpdateUserRequest.php new file mode 100644 index 0000000..573cfd6 --- /dev/null +++ b/app/Http/Requests/Back/Users/UpdateUserRequest.php @@ -0,0 +1,48 @@ +extraPrepareForValidation(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + #[\Override] + public function rules(): array + { + return array_merge(parent::rules(), $this->extraRules()); + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + #[\Override] + public function attributes(): array + { + return array_merge(parent::attributes(), $this->extraAttributes()); + } +} diff --git a/app/Models/GitlabUser.php b/app/Models/GitlabUser.php new file mode 100644 index 0000000..a20f297 --- /dev/null +++ b/app/Models/GitlabUser.php @@ -0,0 +1,39 @@ + + */ + #[\Override] + protected $fillable = [ + 'user_id', + 'gitlab_id', + ]; + + // * RELATIONSHIPS. + + /** + * Get user of the gitlab id. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\User,\App\Models\GitlabUser> + */ + public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 521d520..b0e1a72 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,14 +3,18 @@ namespace App\Models; use App\Mails\Auth\LinkResetPasswordMail; +use App\Models\GitlabUser; use App\Models\UserPassword; +use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Facades\Mail; use Uwil\LaravelBackend\Models\User as BackendUser; /** - * Extends the package User model with password-based authentication support. + * Extends the package User model with some additionnal data. * - * @property-read \App\Models\UserPassword|null $userPassword Associated password. + * @property-read \App\Models\UserPassword|null $userPassword User's associated password. + * @property-read \App\Models\GitlabUser|null $gitlabUser User's associated gitlab id. + * @property-read \App\Models\Site|null $sites User's associated sites. * @see \Uwil\LaravelBackend\Models\User For all other properties and methods. */ class User extends BackendUser @@ -20,11 +24,37 @@ class User extends BackendUser * * @var list */ + #[\Override] public static $cacheRelations = [ - 'userPassword', + 'gitlabUser', 'sites', ]; + /** + * Except some fields to the user model fill. + * + * @var list + */ + #[\Override] + public static $exceptFromFill = [ + 'gitlab_id', + 'sites', + ]; + + /** + * Relations should be share to views. + * + * @return array + */ + #[\Override] + public static function propsRelations(): array + { + return [ + 'gitlabUser', + 'sites' => fn($q) => $q->withoutGlobalScope(SoftDeletingScope::class), + ]; + } + /** * Get the password for authentication. * Retrieves the hashed password from the related user_passwords table. @@ -70,7 +100,17 @@ class User extends BackendUser } /** - * Get sites associated to the brand. + * Get the user's associated gitlab id. + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne<\App\Models\GitlabUser,\App\Models\User> + */ + public function gitlabUser(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(GitlabUser::class); + } + + /** + * Get sites associated to the user. * * @return \Illuminate\Database\Eloquent\Relations\MorphToMany<\App\Models\Site> */ diff --git a/config/uwil-backend.php b/config/uwil-backend.php index 1c9453f..0c678ac 100644 --- a/config/uwil-backend.php +++ b/config/uwil-backend.php @@ -32,14 +32,20 @@ return [ /** *-------------------------------------------------------------------------- - * Models + * User *-------------------------------------------------------------------------- * - * This option may be used when overriding the User model. - * - user: class. + * This options may be used when overriding the User model. + * - model: class. + * - store: class. + * - update: class. */ - 'models' => [ - 'user' => \App\Models\User::class, + 'user' => [ + 'model' => \App\Models\User::class, + 'requests' => [ + 'store' => \App\Http\Requests\Back\Users\StoreUserRequest::class, + 'update' => \App\Http\Requests\Back\Users\UpdateUserRequest::class, + ], ], ]; diff --git a/database/migrations/2026_05_21_101233_create_gitlab_users_table.php b/database/migrations/2026_05_21_101233_create_gitlab_users_table.php new file mode 100644 index 0000000..44bd612 --- /dev/null +++ b/database/migrations/2026_05_21_101233_create_gitlab_users_table.php @@ -0,0 +1,30 @@ +id()->comment('Id of the gitlab user.'); + $table->unsignedBigInteger('gitlab_id')->unique()->comment('Gitlab id of the user.'); + $table->foreignId('user_id')->unique()->comment('Id of the user.')->constrained()->cascadeOnDelete(); + $table->timestamp('created_at')->nullable()->comment('Date the gitlab user was created.'); + $table->timestamp('updated_at')->nullable()->comment('Date the gitlab user was updated.'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('gitlab_users'); + } +}; diff --git a/lang/fr/back.php b/lang/fr/back.php index bcb5faa..d82addd 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -3,11 +3,15 @@ declare(strict_types=1); return [ - "your_field" => "Votre :field", - "model_archived" => ":model est actuellement archivé|:model est actuellement archivée", - "access_url" => "Accéder à l'url", - "form_title_urls" => "URLs d'accès", - "form_subtitle_urls" => "Renseigner les liens de recette et de production liés à ce modèle de données.", - "select_element" => "Sélectionner :element", - "urls" => "URLs", + "your_field" => "Votre :field", + "model_archived" => ":model est actuellement archivé|:model est actuellement archivée", + "access_url" => "Accéder à l'url", + "form_title_urls" => "URLs d'accès", + "form_subtitle_urls" => "Renseigner les liens de recette et de production liés à ce modèle de données.", + "select_element" => "Sélectionner :element", + "urls" => "URLs", + "form_title_sites" => "Associations", + "form_subtitle_sites" => "Modifier les sites accessibles à ce modèle de données.", + "form_title_git" => "Intégration", + "form_subtitle_git" => "Modifier l'identifiant de l'utilisateur Gitlab.", ]; diff --git a/resources/ts/back/Pages/Users/Form.tsx b/resources/ts/back/Pages/Users/Form.tsx index 9a6167b..af0b542 100644 --- a/resources/ts/back/Pages/Users/Form.tsx +++ b/resources/ts/back/Pages/Users/Form.tsx @@ -1,10 +1,12 @@ // * Packages import { useForm, usePage } from "@inertiajs/react"; -import { BookTextIcon, EyeIcon } from "lucide-react"; +import { BookTextIcon, CableIcon, EyeIcon, GitGraphIcon } from "lucide-react"; // * Other import SimpleString from "@/back/Components/Inputs/SimpleString"; import SimpleSwitch from "@/back/Components/Inputs/SimpleSwitch"; +import SimpleNumber from "@/back/Components/Inputs/SimpleNumber"; import EnumSelect from "@/back/Components/Inputs/EnumSelect"; +import { ModelSelect } from "@/back/Components/Inputs/ModelSelect"; import FieldsetWrapper from "@/back/Components/FieldsetWrapper"; import useTrans from "@/hooks/use-translations"; import str from "@/hooks/use-string"; @@ -12,6 +14,7 @@ import InputError from "@/back/Components/InputError"; import { AuthProp, UserModel } from "@/back/types/global"; import SubmitButton from "@/back/Components/SubmitButton"; import useCrudSubmit from "@/back/hooks/use-crud-submit"; +import { extractIdsFromDatabasePivotRelation } from "@/lib/mapPivotOptions"; export default function Form({ model, @@ -28,7 +31,9 @@ export default function Form({ }) { const userModel = (usePage().props.auth as AuthProp).user; const enumUserRole = usePage().props.enumUserRole as UserModel["role"][]; - const isConceptor = enumUserRole.find((role) => role.name === "conceptor"); + const getEnumRoleValue = (name: string) => + enumUserRole.find((role) => role.name === name)?.value; + const filterRoles = (value: number) => { return ( Object.entries(enumUserRole) @@ -43,9 +48,11 @@ export default function Form({ first_name: model?.first_name, last_name: model?.last_name, email: model?.email, + role: model && model.role ? model.role.value : undefined, + sites: extractIdsFromDatabasePivotRelation(model?.sites), + gitlab_id: model?.gitlab_user?.gitlab_id, published: model?.published ?? false, published_at: model?.published_at, - role: model && model.role ? model.role.value : undefined, }); const submit = useCrudSubmit({ @@ -140,26 +147,105 @@ export default function Form({ .ucFirst() .value()} placeholder="ex: john.doe@gmail.com" - disabled={!isConceptor!.value} - wordCounter={Boolean(isConceptor!.value)} - value={!isConceptor!.value ? model?.email : data.email} + disabled={!(userModel.role.value === getEnumRoleValue("conceptor"))} + wordCounter={userModel.role.value === getEnumRoleValue("conceptor")} + value={ + userModel.role.value === getEnumRoleValue("conceptor") + ? model?.email + : data.email + } setData={setData} /> - {Boolean(isConceptor!.value) && ( + {userModel.role.value === getEnumRoleValue("conceptor") && ( setData(name, value?.value ?? value)} + setData={(name, value) => { + setData(name, value?.value ?? value); + // Reset sites if not client. + if (value?.value !== getEnumRoleValue("client")) { + setData("sites", []); + } + // Reset gitlab_id if not project-manager or developper. + if ( + value?.value !== getEnumRoleValue("project-manager") && + value?.value !== getEnumRoleValue("developper") + ) { + setData("gitlab_id", undefined); + } + }} /> )} - {userModel.role.value === isConceptor!.value && + {userModel.role.value === getEnumRoleValue("conceptor") && + data.role === getEnumRoleValue("client") && ( + } + description={useTrans("back.form_subtitle_sites")} + > + + + )} + {userModel.role.value === getEnumRoleValue("conceptor") && + (data.role === getEnumRoleValue("project-manager") || + data.role === getEnumRoleValue("developper")) && ( + } + description={useTrans("back.form_subtitle_git")} + inline + > + + + )} + {userModel.role.value === getEnumRoleValue("conceptor") && userModel.email !== model?.email && ( { - return [ +const getColumns = ( + appEnv: AppEnv, + viewAnySites: boolean, + enumUserRole: UserModel["role"][], + model: UserModel, +) => { + const getEnumRoleValue = (name: string) => + enumUserRole.find((role) => role.name === name)?.value; + + const isClient = model.role?.value === getEnumRoleValue("client"); + const isGitlabUser = + model.role?.value === getEnumRoleValue("project-manager") || + model.role?.value === getEnumRoleValue("developper"); + const columns = [ { accessorKey: "first_name", header: str( @@ -61,6 +87,101 @@ const getColumns = () => { ), }, + { + accessorKey: "sites", + header: str(GetTranslation("models.classes.sites", { choice: 2 })) + .ucFirst() + .value(), + cell: ({ value }: { value: Array }) => { + if (value.length) { + const projectData = (siteModel: SiteModel) => ( + {siteModel.name[appEnv.locale]} + ); + return ( +
+ {value.map((siteModel) => { + let content = ( +
+ {projectData(siteModel)} + {siteModel.deleted_at !== null && ( + + + + + + {GetTranslation("back.model_archived", { + params: { + model: str( + GetTranslation( + "models.classes.gitlab-projects", + { + choice: 1, + }, + ), + ) + .ucFirst() + .value(), + }, + choice: 1, + })} + + + )} +
+ ); + if (viewAnySites && siteModel.deleted_at === null) { + content = ( + + + {projectData(siteModel)} + + } + tooltipContent={ +

+ {GetTranslation("uwil-backend::back.access_model", { + params: { + model: GetTranslation("models.classes.sites", { + choice: 1, + }), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + return ( +
+ {content} +
+ ); + })} +
+ ); + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "gitlab_user", + header: str(GetTranslation("validation.custom.gitlab_id")) + .ucFirst() + .value(), + cell: ({ value }: { value: GitlabUser }) => value.gitlab_id, + }, { accessorKey: "published", header: str(GetTranslation("uwil-backend::validation.custom.published")) @@ -124,6 +245,13 @@ const getColumns = () => { ), }, ]; + + return columns.filter((column) => { + if (column.accessorKey === "role" && isClient) return false; + if (column.accessorKey === "sites" && !isClient) return false; + if (column.accessorKey === "gitlab_user" && !isGitlabUser) return false; + return true; + }); }; export default function Show({ @@ -133,13 +261,16 @@ export default function Show({ data: SingleResource; modelName: string; }) { - const { auth } = usePage().props as unknown as { + const { auth, appEnv, enumUserRole } = usePage().props as unknown as { auth: AuthProp; + appEnv: AppEnv; + enumUserRole: UserModel["role"][]; }; const { viewAny } = auth.policies.users; + const { viewAny: viewAnySites } = auth.policies.sites; const model = data.data as UserModel; const actionModels = [Views.EDITION, Views.DUPLICATION, Views.ARCHIVATION]; - const columns = getColumns(); + const columns = getColumns(appEnv, viewAnySites, enumUserRole, model); // Translations. const transTitle = useTrans( diff --git a/resources/ts/back/types/global.d.ts b/resources/ts/back/types/global.d.ts index bb6ff30..c9d3912 100644 --- a/resources/ts/back/types/global.d.ts +++ b/resources/ts/back/types/global.d.ts @@ -1,6 +1,8 @@ // * Packages import { AxiosInstance } from "axios"; import { ElementType } from "react"; +// * Other +import { GitlabUser, SiteModel } from "@/back/types/project"; // Policy related types. type ModelPolicy = { @@ -25,6 +27,8 @@ type UserModel = DefaultModel & { last_name: string; email: string; role: { value: number; label: string; name: string }; + sites: Array; + gitlab_user: GitlabUser; published: boolean; published_at: Date; }; diff --git a/resources/ts/back/types/project.d.ts b/resources/ts/back/types/project.d.ts index 7e1ff23..0b37fd2 100644 --- a/resources/ts/back/types/project.d.ts +++ b/resources/ts/back/types/project.d.ts @@ -18,6 +18,14 @@ type InvoiceCompanyModel = DefaultModel & { name: TranslationField; }; +type GitlabUser = { + id: number; + gitlab_id: number; + user_id: number; + created_at: Date; + updated_at: Date; +} + type GitlabProjectModel = DefaultModel & { name: TranslationField; url: string; diff --git a/routes/back/users.php b/routes/back/users.php new file mode 100644 index 0000000..80f4926 --- /dev/null +++ b/routes/back/users.php @@ -0,0 +1,6 @@ +