Skip to content

Commit

Permalink
Feature/system attrs in user dlg (#591)
Browse files Browse the repository at this point in the history
* Started reendering custom attrs in UserDialog

* Fixed q-select in schema item

* Added integer schema field and corrected some warnings

---------

Co-authored-by: Ramin Haeri Azad <[email protected]>
  • Loading branch information
kazoompa and Ramin Haeri Azad authored Jan 15, 2025
1 parent 1d85cb0 commit 0547372
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 31 deletions.
25 changes: 16 additions & 9 deletions agate-ui/src/components/SchemaForm.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
<template>
<div v-if="schema" :class="{ 'o-border-negative rounded-borders': !isFormValid }">
<form autocomplete="off">
<div v-if="schema.title" class="text-help">{{ schema.title }}</div>
<div v-if="schema.description" class="text-hint q-mb-sm">{{ schema.description }}</div>
<div v-for="item in schema.items" :key="item.key">
<schema-form-item v-model="data[item.key]" :field="item" :disable="disable" @update:model-value="onUpdate" />
</div>
</form>
<div>
<div v-if="schema" :class="{ 'o-border-negative rounded-borders': !isFormValid }">
<form autocomplete="off">
<div v-if="schema.title" class="text-help">{{ schema.title }}</div>
<div v-if="schema.description" class="text-hint q-mb-sm">{{ schema.description }}</div>
<div v-for="item in schema.items" :key="item.key">
<schema-form-item
v-model="data[item.key]"
:field="item"
:disable="disable"
@update:model-value="onUpdate()"
/>
</div>
</form>
</div>
<span v-if="!isFormValid" class="text-negative text-caption">{{ t('missing_required_fields') }}</span>
</div>
<span v-if="!isFormValid" class="text-negative text-caption">{{ t('validation.missing_required_fields') }}</span>
</template>

<script setup lang="ts">
Expand Down
29 changes: 28 additions & 1 deletion agate-ui/src/components/SchemaFormItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<div v-else-if="isString()">
<q-select
v-if="field.enum"
v-model="data"
v-model="dataString"
:label="field.title"
:hint="field.description"
dense
Expand Down Expand Up @@ -54,6 +54,19 @@
@update:model-value="onUpdate"
/>
</div>
<div v-else-if="isInteger()">
<q-input
v-model.number="dataInteger"
:label="field.title"
:hint="field.description"
type="number"
dense
:disable="disable"
class="q-mb-md"
:debounce="500"
@update:model-value="onUpdate"
/>
</div>
<div v-else-if="isBoolean()">
<q-toggle
v-model="dataBoolean"
Expand Down Expand Up @@ -132,6 +145,7 @@ const data = ref(props.modelValue);
const dataString = ref('');
const dataNumber = ref<number>();
const dataInteger = ref<number>();
const dataBoolean = ref<boolean>();
const dataArray = ref<Array<FormObject>>([]);
Expand All @@ -147,6 +161,8 @@ function init() {
dataString.value = data.value as string;
} else if (isNumber()) {
dataNumber.value = data.value as number;
} else if (isInteger()) {
dataInteger.value = data.value as number;
} else if (isBoolean()) {
dataBoolean.value = data.value as boolean;
}
Expand All @@ -165,6 +181,10 @@ function isArray() {
}
function isNumber() {
return props.field.type === 'number';
}
function isInteger() {
return props.field.type === 'integer';
}
Expand All @@ -179,6 +199,13 @@ function onUpdate() {
data.value = dataString.value;
} else if (isNumber()) {
data.value = dataNumber.value;
} else if (isInteger()) {
if (dataInteger.value) {
// Remove decimal part
data.value = dataInteger.value = Math.floor(dataInteger.value);
} else {
data.value = undefined;
}
} else if (isBoolean()) {
data.value = dataBoolean.value;
}
Expand Down
2 changes: 1 addition & 1 deletion agate-ui/src/components/SystemProperties.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
<q-item-label caption>{{ t('system.otp_strategy_hint') }}</q-item-label>
</q-item-section>
<q-item-section avatar>
{{ t(`system.otp_strategies.${config.enforced2FAStrategy}`) }}
{{ config.enforced2FAStrategy ? t(`system.otp_strategies.${config.enforced2FAStrategy}`) : '' }}
</q-item-section>
</q-item>
</q-list>
Expand Down
2 changes: 1 addition & 1 deletion agate-ui/src/components/SystemUserAttributesDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const systemStore = useSystemStore();
interface DialogProps {
modelValue: boolean;
attribute: AttributeConfigurationDto | null;
attribute?: AttributeConfigurationDto;
}
const props = defineProps<DialogProps>();
Expand Down
6 changes: 4 additions & 2 deletions agate-ui/src/components/UserAttributesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@
<script setup lang="ts">
import type { AttributeDto } from 'src/models/Agate';
interface Props {
modelValue: AttributeDto[] | undefined;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const systemStore = useSystemStore();
const attributes = ref<AttributeDto[]>(props.modelValue ? [...props.modelValue] : []);
Expand All @@ -64,14 +66,14 @@ function onUpdate() {
}
function getAttributes() {
const seen = new Set();
const seen = new Set(systemStore.userAttributes.map(config => config.name));
return attributes.value
.filter((attr) => attr.name && attr.value)
.filter((attr) => {
if (seen.has(attr.name)) {
return false; // Skip duplicates
}
seen.add(attr.name);
seen.add(attr.name);
return true; // Include unique
});
}
Expand Down
47 changes: 35 additions & 12 deletions agate-ui/src/components/UserDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@
</div>
</q-form>

<user-attributes-list class="q-mt-lg" v-model="selected!.attributes" />
<div class="text-bold q-mt-lg">
{{ t('system.attributes.title') }}
<schema-form ref="sfForm" v-model="sfModel" :schema="sfSchema" />
</div>

<user-attributes-list class="q-mt-lg" v-model="selectedAttributes" />
</q-card-section>

<q-separator />
Expand All @@ -183,25 +188,28 @@

<script setup lang="ts">
import { copyToClipboard } from 'quasar';
import type { UserDto } from 'src/models/Agate';
import type { AttributeDto, UserDto } from 'src/models/Agate';
import { notifyError, notifyInfo, notifySuccess } from 'src/utils/notify';
import UserAttributesList from 'src/components/UserAttributesList.vue';
import { attributesToSchema, splitAttributes } from 'src/utils/attributes';
import SchemaForm from 'src/components/SchemaForm.vue';
interface DialogProps {
modelValue: boolean;
user: UserDto | undefined;
}
const { t } = useI18n();
const userStore = useUserStore();
const groupStore = useGroupStore();
const applicationStore = useApplicationStore();
const realmStore = useRealmStore();
const systemStore = useSystemStore();
interface DialogProps {
modelValue: boolean;
user: UserDto | undefined;
}
const props = defineProps<DialogProps>();
const emit = defineEmits(['update:modelValue', 'saved', 'cancel']);
const sfForm = ref();
const sfSchema = ref();
const sfModel = ref();
const showDialog = ref(props.modelValue);
const selected = ref<UserDto>(
props.user ??
Expand All @@ -215,6 +223,7 @@ const selected = ref<UserDto>(
const editMode = ref(false);
const password = ref('');
const passwordVisible = ref(false);
const selectedAttributes = ref([] as AttributeDto[]);
const roleOptions = computed(() =>
['agate-user', 'agate-administrator'].map((role) => ({ label: t(`user.role.${role}`), value: role })),
Expand All @@ -240,13 +249,15 @@ const isValid = computed(
selected.value.name.trim().length >= 3 &&
selected.value.email &&
validateEmailFormat(selected.value.email) &&
(!showPassword.value || (password.value && password.value.trim().length >= 8)),
(!showPassword.value || (password.value && password.value.trim().length >= 8)) &&
sfForm.value?.validate(),
);
onMounted(() => {
groupStore.init();
applicationStore.init();
realmStore.init();
systemStore.init();
systemStore.initPub();
});
Expand All @@ -265,6 +276,13 @@ watch(
editMode.value = props.user !== undefined;
password.value = '';
passwordVisible.value = false;
if (value) {
sfSchema.value = attributesToSchema(systemStore.userAttributes, '', '');
const { custom, specific } = splitAttributes(selected.value.attributes || [], systemStore.userAttributes || []);
sfModel.value = custom.map((attr) => ({ [attr.name]: attr.value })).reduce((a, b) => ({ ...a, ...b }), {});
selectedAttributes.value = specific;
}
},
);
Expand All @@ -277,14 +295,19 @@ function onCancel() {
}
function onSave() {
selected.value.attributes = [
...Object.entries(sfModel.value).map(([name, value]) => ({ name, value: value as string })),
...selectedAttributes.value,
];
userStore
.save(selected.value, password.value)
.then(() => {
notifySuccess(t('user.saved'));
notifySuccess('user.saved');
emit('saved');
})
.catch(() => {
notifyError(t('user.save_failed'));
notifyError('user.save_failed');
})
.finally(() => {
emit('update:modelValue', false);
Expand Down
5 changes: 3 additions & 2 deletions agate-ui/src/i18n/en/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default {
},
update_password: 'Update password',
attributes: {
title: 'User Attributes',
title: 'Specific User Attributes',
hint: 'Additional user information.',
add: 'Add User Attribute',
update: 'Update User Attribute',
Expand Down Expand Up @@ -246,7 +246,7 @@ export default {
ANY: 'Email or mobile app',
},
attributes: {
title: 'User Attributes',
title: 'Custom User Attributes',
hint: 'Extend user profile with custom attributes.',
values_hint: 'Comma separated values',
add: 'Add Attribute',
Expand Down Expand Up @@ -292,6 +292,7 @@ export default {
groups: 'Groups',
help: 'Help',
inherited_from: 'Inherited from: {parent}',
missing_required_fields: 'Missing required fields',
more_actions: 'More actions',
my_profile: 'My Profile',
name_hint: 'Name must be unique',
Expand Down
5 changes: 3 additions & 2 deletions agate-ui/src/i18n/fr/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default {
},
update_password: 'Mettre à jour le mot de passe',
attributes: {
title: 'Attributs utilisateur',
title: 'Attributs utilisateur spécifiques',
hint: "Informations additionnelles sur l'usager.",
add: "Ajouter un attribut d'utilisateur",
update: "Mettre à Jour l'attribut d'utilisateur",
Expand Down Expand Up @@ -246,7 +246,7 @@ export default {
ANY: 'Courriel ou application mobile',
},
attributes: {
title: 'Attributs Utilisateur',
title: 'Attributs utilisateur personnalisés',
hint: 'Les attributs utilisateur sont des champs personnalisés qui peuvent être utilisés pour stocker des informations supplémentaires sur les utilisateurs.',
values_hint: 'Valeurs séparées par des virgules',
add: 'Ajouter un Attribut',
Expand Down Expand Up @@ -292,6 +292,7 @@ export default {
groups: 'Groupes',
help: 'Aide',
inherited_from: 'Hérité de: {parent}',
missing_required_fields: 'Champs obligatoires manquants',
more_actions: "Plus d'actions",
my_profile: 'Mon profil',
name_hint: 'Le nom doit être unique',
Expand Down
2 changes: 1 addition & 1 deletion agate-ui/src/stores/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const useSystemStore = defineStore('system', () => {
return api.get('/config').then((response) => {
if (response.status === 200) {
configuration.value = response.data;
userAttributes.value = configuration.value.userAttributes;
userAttributes.value = configuration.value.userAttributes || [];
}
return response;
});
Expand Down
61 changes: 61 additions & 0 deletions agate-ui/src/utils/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { AttributeConfigurationDto, AttributeDto } from 'src/models/Agate';
import type { SchemaFormField, SchemaFormObject } from 'src/components/models';

export function attributesToSchema(attributes: AttributeConfigurationDto[], title: string, description: string) {
const schema = {
$schema: 'http://json-schema.org/schema#',
title: title || '',
description: description || '',
type: 'array',
items: [] as SchemaFormField[],
required: [],
} as SchemaFormObject;

(attributes || []).forEach((attribute: AttributeConfigurationDto) => {
const type = attribute.type.toLowerCase();
const field = {
key: attribute.name,
type: type,
title: attribute.name,
description: attribute.description,
} as SchemaFormField;

switch (attribute.type.toLowerCase()) {
case 'number':
case 'integer':
case 'boolean':
case 'string':
if (type === 'string' && attribute.values) {
field.enum = attribute.values.map((value: string) => ({ key: value, title: value }));
}

schema.items.push(field);
if (attribute.required) {
schema.required.push(attribute.name);
}
break;
}
});

return schema;
}

export function splitAttributes(attributes: AttributeDto[], systemAttributes: AttributeConfigurationDto[]) {
const systemAttributesMap = new Map<string, AttributeConfigurationDto>();
systemAttributes.forEach((attribute: AttributeConfigurationDto) => {
systemAttributesMap.set(attribute.name, attribute);
});

const custom = [] as AttributeDto[];
const specific = [] as AttributeDto[];

(attributes || []).forEach((attribute: AttributeDto) => {
if (systemAttributesMap.has(attribute.name)) {
custom.push(attribute);
} else {
specific.push(attribute);
}
});

return { custom, specific };
}

0 comments on commit 0547372

Please sign in to comment.