Skip to content

Commit

Permalink
Merge branch 'feature/222-fix-admin-user-creation' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
Marthym committed Oct 19, 2024
2 parents 5065bdd + 812b26b commit 455e940
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 74 deletions.
196 changes: 143 additions & 53 deletions seaside/src/administration/component/usereditor/UserEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,78 +12,105 @@
<form v-if="opened"
class="justify-self-end flex flex-col bg-base-100 text-base-content lg:w-3/4 w-full h-full overflow-auto p-2"
@click.stop @submit.prevent="onSaveUser">
<h2 class="font-sans text-xl border-b border-accent/40 pb-2 w-full">{{ title }}</h2>
<h2 class="font-sans text-xl border-b border-accent/40 pb-2 w-full first-letter:capitalize">{{ title }}</h2>
<div class="flex flex-wrap content-start ">
<div class="grow lg:basis-1/2 h-fit p-4">
<div class="label">
<span class="label-text">Login</span>
<span class="label-text capitalize">{{ t('admin.users.login') }}</span>
</div>
<input v-model="modelValue.login" type="text" placeholder="login"
class="input input-bordered w-full"
:class="{'input-error': errors.has('login')}" @change="onFieldChange('login')"
:disabled="isEditionMode">
<div class="label -mt-1">
<input v-model="modelValue.login" :class="{'input-error': errors.has('login')}" :disabled="isEditionMode"
class="input input-bordered w-full" type="text"
@change="onFieldChange('login')">
<div class="label -mt-1 mb-2">
<span class="label-text-alt">&nbsp;</span>
<span v-if="errors.has('login')" class="label-text-alt">{{ errors.get('login') }}</span>
<span v-if="errors.has('login')" class="label-text-alt text-error-content">{{
errors.get('login')
}}</span>
</div>

<div class="label -mt-6">
<span class="label-text">Username</span>
<span class="label-text capitalize">{{ t('admin.users.username') }}</span>
</div>
<input v-model="modelValue.name" type="text" placeholder="username"
<input v-model="modelValue.name" :class="{'input-error': errors.has('name')}"
class="input input-bordered w-full"
:class="{'input-error': errors.has('name')}" @change="onFieldChange('name')">
<div class="label -mt-1">
type="text" @change="onFieldChange('name')">
<div class="label -mt-1 mb-2">
<span class="label-text-alt">&nbsp;</span>
<span v-if="errors.has('name')" class="label-text-alt">{{ errors.get('name') }}</span>
<span v-if="errors.has('name')" class="label-text-alt text-error-content">{{ errors.get('name') }}</span>
</div>

<div class="label -mt-6">
<span class="label-text">Mail</span>
<span class="label-text capitalize">{{ t('admin.users.mail') }}</span>
</div>
<input v-model="modelValue.mail" type="email" placeholder="mail address"
class="input input-bordered w-full"
:class="{'input-error': errors.has('mail')}" @change="onFieldChange('mail')"
:disabled="isEditionMode">
<div class="label -mt-1">
<input v-model="modelValue.mail" :class="{'input-error': errors.has('mail')}"
class="input input-bordered w-full" type="email"
@change="onFieldChange('mail')">
<div class="label -mt-1 mb-2">
<span class="label-text-alt">&nbsp;</span>
<span v-if="errors.has('mail')" class="label-text-alt">{{ errors.get('mail') }}</span>
<span v-if="errors.has('mail')" class="label-text-alt text-error-content">{{ errors.get('mail') }}</span>
</div>
</div>
<div class="grow lg:basis-1/2 h-fit p-4">
<div class="label">
<span class="label-text">Password</span>
<span class="label-text capitalize">{{ t('admin.users.password') }}</span>
</div>
<div class="join w-full">
<input v-model="modelValue.password" :class="{'input-error': errors.has('password')}"
:type="visible.password?'text':'password'"
class="input input-bordered join-item w-full"
@keyup="onFieldChange('password')"
@blur.stop="onBlurNewPassword">
<button :class="{'input-error': errors.has('password')}"
class="btn btn-neutral input input-bordered border-l-0 join-item focus:outline-none"
@click.prevent.stop="visible.password = !visible.password">
<EyeIcon v-if="!visible.password" class="h-6 w-6 opacity-50"/>
<EyeSlashIcon v-else class="h-6 w-6 opacity-50"/>
</button>
<button class="btn join-item" @click.prevent.stop="onPasswordGenerate">
{{ t('admin.users.editor.button.generate') }}
</button>
</div>
<input v-model="modelValue.password" type="password" class="input input-bordered w-full"
:class="{'input-error': errors.has('password')}" @change="onFieldChange('password')">
<div class="label -mt-1">
<div class="label -mt-1 mb-2">
<span class="label-text-alt">&nbsp;</span>
<span v-if="errors.has('password')" class="label-text-alt">{{
errors.get('password')
}}</span>
<span v-if="errors.has('password')" class="label-text-alt text-error-content">
{{ errors.get('password') }}
</span>
</div>

<div class="label -mt-6">
<span class="label-text">Password Confirmation</span>
<span class="label-text capitalize">{{ t('admin.users.confirmation') }}</span>
</div>
<div class="join w-full">
<input v-model="passwordConfirm" :class="{'input-error': errors.has('confirm')}"
:type="visible.confirm?'text':'password'"
class="input input-bordered join-item w-full"
@blur="onBlurConfirmPassword"
@change="onFieldChange('confirm')">
<button :class="{'input-error': errors.has('confirm')}"
class="btn btn-neutral input input-bordered border-l-0 join-item focus:outline-none"
@click.prevent.stop="visible.confirm = !visible.confirm">
<EyeIcon v-if="!visible.confirm" class="h-6 w-6 opacity-50"/>
<EyeSlashIcon v-else class="h-6 w-6 opacity-50"/>
</button>
</div>
<input v-model="passwordConfirm" type="password" class="input input-bordered w-full"
:class="{'input-error': errors.has('confirm')}" @change="onFieldChange('confirm')">
<div class="label -mt-1">
<div class="label -mt-1 mb-2">
<span class="label-text-alt">&nbsp;</span>
<span v-if="errors.has('confirm')" class="label-text-alt">{{ errors.get('confirm') }}</span>
<span v-if="errors.has('confirm')" class="label-text-alt text-error-content">
{{ errors.get('confirm') }}
</span>
</div>
</div>
<div class="grow lg:basis-1/2 h-fit p-4 border-error rounded-lg"
:class="{'border': errors.has('roles')}">
<div :class="{'border': errors.has('roles')}"
class="grow lg:basis-1/2 h-fit p-4 border-error rounded-lg">
<UserRoleInput :model-value="modelValue.roles" @update:modelValue="onRoleUpdate"/>
<span v-if="errors.has('roles')" class="label-text-alt">{{ errors.get('roles') }}</span>
<span v-if="errors.has('roles')" class="label-text-alt text-error-content">{{ errors.get('roles') }}</span>
</div>
</div>
<span class="grow"></span>
<div>
<button class="btn m-2" @click.prevent.stop="onCancel">Annuler</button>
<button class="btn btn-primary m-2" @click.prevent.stop="onSaveUser" :disabled="!hasValidRoles">
Enregistrer
<button class="btn m-2" @click.prevent.stop="onCancel">{{ t('admin.users.editor.button.cancel') }}</button>
<button :disabled="!hasValidRoles" class="btn btn-primary m-2" @click.prevent.stop="onSaveUser">
{{ t('admin.users.editor.button.save') }}
</button>
</div>
</form>
Expand All @@ -97,63 +124,130 @@ import { Component, Prop, Vue } from 'vue-facing-decorator';
import { User } from '@/security/model/User';
import UserRoleInput from '@/administration/component/usereditor/UserRoleInput.vue';
import { MAIL_PATTERN, ULID_PATTERN } from '@/common/services/RegexPattern';
import { EyeIcon } from '@heroicons/vue/24/outline';
import { useI18n } from 'vue-i18n';
import { EyeSlashIcon } from '@heroicons/vue/24/solid';
import { passwordAnonymousCheckStrength, passwordGenerate } from '@/security/services/PasswordService';
const CANCEL_EVENT: string = 'cancel';
const SUBMIT_EVENT: string = 'submit';
const CHANGE_EVENT: string = 'change';
const FIELD_PASSWORD: string = 'password';
const FIELD_CONFIRM: string = 'confirm';
@Component({
name: 'UserEditor',
components: { UserRoleInput },
components: { EyeSlashIcon, EyeIcon, UserRoleInput },
emits: [CANCEL_EVENT, SUBMIT_EVENT, CHANGE_EVENT],
setup() {
const { t } = useI18n();
return { t };
},
})
export default class UserEditor extends Vue {
@Prop() private modelValue: User;
private readonly t;
private passwordConfirm: string = '';
private title = 'Create new user';
private title: string = '';
private isEditionMode: boolean = false;
private errors: Map<string, string> = new Map<string, string>();
private visible = {
password: false,
confirm: false,
};
private opened: boolean = false;
private closeEvent: typeof SUBMIT_EVENT | typeof CANCEL_EVENT = CANCEL_EVENT;
get hasValidRoles(): boolean {
return this.modelValue.roles.findIndex(r => !ULID_PATTERN.test(r)) == -1;
}
mounted(): void {
this.isEditionMode = '_id' in this.modelValue && this.modelValue._id !== undefined;
this.title = this.isEditionMode ? `Update user ${this.modelValue.login}` : 'Create new user';
this.title = this.isEditionMode
? this.t('admin.users.editor.title.update', { login: this.modelValue.login })
: this.t('admin.users.editor.title.create');
this.$nextTick(() => this.opened = true);
}
private onFieldChange(field: string): void {
this.errors.delete(field);
}
private onPasswordGenerate(): void {
passwordGenerate(20).subscribe({
next: passwords => {
this.errors.delete(FIELD_PASSWORD);
let randomValue = new Uint32Array(1);
crypto.getRandomValues(randomValue);
this.modelValue.password = passwords[randomValue[0] % 19];
this.passwordConfirm = this.modelValue.password;
},
error: err => this.errors.set(FIELD_PASSWORD, err.message),
});
}
private onBlurNewPassword(): void {
if (!this.modelValue.password || this.modelValue.password.length === 0) {
return;
} else if (!this.modelValue.password || this.modelValue.password.length <= 3) {
this.errors.set(FIELD_PASSWORD, this.t('admin.users.editor.message.password_too_short'));
return;
}
if (!this.modelValue.login) {
this.errors.set(FIELD_PASSWORD, this.t('admin.users.editor.message.login_field_required'));
return;
}
passwordAnonymousCheckStrength(this.modelValue).subscribe({
next: evaluation => {
if (evaluation.isSecure) {
this.errors.delete(FIELD_PASSWORD);
} else {
this.errors.set(FIELD_PASSWORD, evaluation.message);
}
},
error: err => this.errors.set(FIELD_PASSWORD, err.message),
});
}
private onBlurConfirmPassword(): void {
console.log(this.passwordConfirm === this.modelValue.password);
if (this.passwordConfirm && this.passwordConfirm.length > 3 && this.passwordConfirm === this.modelValue.password) {
this.errors.delete(FIELD_CONFIRM);
} else {
this.errors.set(FIELD_CONFIRM, this.t('admin.users.editor.message.wrong_confirmation'));
}
}
private onCancel(): void {
this.closeEvent = CANCEL_EVENT;
this.opened = false;
}
private onSaveUser(): void {
console.debug('onSaveUser');
if (!this.modelValue.login) {
this.errors.set('login', 'Login is mandatory !');
this.errors.set('login', this.t('admin.users.editor.message.login_mandatory'));
}
if (!this.modelValue.name) {
this.errors.set('name', 'Name is mandatory !');
this.errors.set('name', this.t('admin.users.editor.message.name_mandatory'));
}
if (!this.modelValue.mail) {
this.errors.set('mail', 'Mail address is mandatory !');
this.errors.set('mail', this.t('admin.users.editor.message.mail_mandatory'));
} else if (!MAIL_PATTERN.test(this.modelValue.mail)) {
this.errors.set('mail', 'Mail address must be syntactically correct !');
this.errors.set('mail', this.t('admin.users.editor.message.mail_incorrect'));
}
if (!this.modelValue.roles || this.modelValue.roles.length === 0) {
this.errors.set('roles', 'Role is mandatory !');
this.errors.set('roles', this.t('admin.users.editor.message.role_mandatory'));
}
if (!this.hasValidRoles) {
this.errors.set('roles', `All role scope must match ${ULID_PATTERN}`);
this.errors.set('roles', this.t('admin.users.editor.message.role_incorrect', { pattern: ULID_PATTERN }));
}
if (!this.isEditionMode) {
if (!this.modelValue.password) {
this.errors.set('password', 'Password is mandatory !');
this.errors.set(FIELD_PASSWORD, this.t('admin.users.editor.message.password_mandatory'));
} else if (this.modelValue.password !== this.passwordConfirm) {
this.errors.set('confirm', 'Password confirmation doesn\'t match !');
this.errors.set(FIELD_CONFIRM, this.t('admin.users.editor.message.wrong_confirmation'));
}
}
Expand All @@ -168,10 +262,6 @@ export default class UserEditor extends Vue {
this.modelValue.roles.splice(0, this.modelValue.roles.length, ...event);
}
get hasValidRoles(): boolean {
return this.modelValue.roles.findIndex(r => !ULID_PATTERN.test(r)) == -1;
}
private onTransitionLeave(): void {
if (this.closeEvent === CANCEL_EVENT) {
this.$emit(this.closeEvent);
Expand Down
47 changes: 27 additions & 20 deletions seaside/src/administration/component/usereditor/UserRoleInput.vue
Original file line number Diff line number Diff line change
@@ -1,42 +1,43 @@
<template>
<div class="overflow-x-auto">
<h3 class="font-sans text-lg border-b border-accent/40 pb-1 mb-2 w-full">User role(s)</h3>
<table class="table table-zebra table-compact" aria-label="User role(s)">
<h3 class="font-sans text-lg border-b border-accent/40 pb-1 mb-2 w-full capitalize">{{ t('admin.users.roles.title') }}</h3>
<table :aria-label="t('admin.users.roles.title')" class="table table-zebra table-compact">
<thead>
<tr>
<th>Name</th>
<th>Scope</th>
<th>Action</th>
<tr class="capitalize">
<th>{{ t('admin.users.roles.name') }}</th>
<th>{{ t('admin.users.roles.scope') }}</th>
<th>{{ t('admin.users.roles.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(role, index) in roles">
<td>
<select v-model="role.name" class="select select-bordered select-sm max-w-xs w-32"
<select v-model="role.name" class="select select-bordered select-sm max-w-xs w-32 w-full"
@change="emitInputEvent">
<option :value="undefined" disabled selected hidden>Choose the user role</option>
<option>USER</option>
<option>MANAGER</option>
<option>ADMIN</option>
<option class="capitalize" :value="undefined" disabled selected>{{ t('admin.users.roles.name.default') }}</option>
<option class="capitalize" value="USER">{{ t('admin.users.roles.name.user') }}</option>
<option class="capitalize" value="MANAGER">{{ t('admin.users.roles.name.manager') }}</option>
<option class="capitalize" value="ADMIN">{{ t('admin.users.roles.name.admin') }}</option>
</select>
</td>
<td class="tooltip-error"
:class="{
<td :class="{
'tooltip': isInvalidScope(role.scope),
'tooltip-bottom': index < (roles.length -1),
}" data-tip="Invalid role scope !">
<input v-model="role.scope" type="text" placeholder="Type here"
}"
:data-tip="t('admin.users.roles.scope.message.invalid')" class="tooltip-error">
<input v-model="role.scope" :class="{'input-error': isInvalidScope(role.scope)}"
:placeholder="t('admin.users.roles.scope.placeholder')"
class="input input-bordered input-sm w-72 placeholder:capitalize"
type="text"
@change="emitInputEvent"
class="input input-bordered input-sm w-72"
:class="{'input-error': isInvalidScope(role.scope)}"
/>
</td>
<td>
<div class="btn-group">
<button class="btn btn-sm" @click.prevent.stop="onAddRole()">
<div class="join">
<button class="btn btn-sm join-item" @click.prevent.stop="onAddRole()">
<PlusCircleIcon class="h-6 w-6 inline"/>
</button>
<button class="btn btn-sm" @click.prevent.stop="onRemoveRole(index)">
<button class="btn btn-sm join-item" @click.prevent.stop="onRemoveRole(index)">
<TrashIcon class="w-6 h-6 inline"/>
</button>
</div>
Expand All @@ -50,6 +51,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-facing-decorator';
import { PlusCircleIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { useI18n } from 'vue-i18n';
const SUBMIT_EVENT: string = 'submit';
const UPDATE_EVENT: string = 'update:modelValue';
Expand All @@ -58,8 +60,13 @@ const UPDATE_EVENT: string = 'update:modelValue';
name: 'UserRoleInput',
components: { PlusCircleIcon, TrashIcon },
emits: [UPDATE_EVENT, SUBMIT_EVENT],
setup() {
const { t } = useI18n();
return { t };
},
})
export default class UserRoleInput extends Vue {
private readonly t;
@Prop({ default: () => [] })
private modelValue!: string[];
private roles: RoleView[] = [];
Expand Down
Loading

0 comments on commit 455e940

Please sign in to comment.