Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/system attrs in user dlg #591

Merged
merged 3 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 };
}