@drax/identity-vue 3.20.0 → 3.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/components/IdentityChangeOwnPassword/IdentityChangeOwnPassword.vue +3 -6
- package/src/components/IdentityRecoveryPasswordComplete/IdentityRecoveryPasswordComplete.vue +4 -9
- package/src/components/PasswordPolicy/PasswordPolicyInput.vue +118 -0
- package/src/components/PasswordPolicy/PasswordPolicySummary.vue +244 -0
- package/src/cruds/user-crud/UserForm.vue +138 -97
- package/src/cruds/user-crud/UserPasswordDialog.vue +4 -1
- package/src/cruds/user-crud/UserPasswordForm.vue +14 -9
- package/src/pages/PasswordPolicyPage.vue +6 -149
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.22.1",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"module": "./src/index.ts",
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@drax/common-front": "^3.19.0",
|
|
28
28
|
"@drax/common-vue": "^3.19.0",
|
|
29
|
-
"@drax/crud-front": "^3.
|
|
30
|
-
"@drax/crud-share": "^3.
|
|
31
|
-
"@drax/crud-vue": "^3.
|
|
32
|
-
"@drax/identity-front": "^3.
|
|
33
|
-
"@drax/identity-share": "^3.
|
|
29
|
+
"@drax/crud-front": "^3.21.0",
|
|
30
|
+
"@drax/crud-share": "^3.21.0",
|
|
31
|
+
"@drax/crud-vue": "^3.21.0",
|
|
32
|
+
"@drax/identity-front": "^3.22.1",
|
|
33
|
+
"@drax/identity-share": "^3.21.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"pinia": "^3.0.4",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"vue-tsc": "^3.2.4",
|
|
56
56
|
"vuetify": "^3.11.8"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "9a2d949464ddf1a3b2da49be53d73f7d5b8955b4"
|
|
59
59
|
}
|
|
@@ -5,6 +5,7 @@ import {ClientError} from "@drax/common-front";
|
|
|
5
5
|
import type {IClientInputError} from "@drax/common-front";
|
|
6
6
|
import {useI18nValidation} from "@drax/common-vue";
|
|
7
7
|
import {useI18n} from "vue-i18n";
|
|
8
|
+
import PasswordPolicyInput from "../PasswordPolicy/PasswordPolicyInput.vue";
|
|
8
9
|
|
|
9
10
|
const {t,te} = useI18n()
|
|
10
11
|
const {$ta} = useI18nValidation()
|
|
@@ -86,18 +87,14 @@ async function submitChangePassword() {
|
|
|
86
87
|
:error-messages="$ta(inputErrors?.currentPassword)"
|
|
87
88
|
></v-text-field>
|
|
88
89
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.newPassword') }}</div>
|
|
89
|
-
<
|
|
90
|
+
<PasswordPolicyInput
|
|
90
91
|
variant="outlined"
|
|
91
92
|
id="new-password-input"
|
|
92
93
|
v-model="newPassword"
|
|
93
|
-
:type="newPasswordVisibility ? 'text': 'password'"
|
|
94
94
|
required
|
|
95
|
-
prepend-inner-icon="mdi-lock-outline"
|
|
96
|
-
:append-inner-icon="newPasswordVisibility ? 'mdi-eye-off': 'mdi-eye'"
|
|
97
|
-
@click:append-inner="newPasswordVisibility = !newPasswordVisibility"
|
|
98
95
|
autocomplete="new-password"
|
|
99
96
|
:error-messages="$ta(inputErrors?.newPassword)"
|
|
100
|
-
|
|
97
|
+
/>
|
|
101
98
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.confirmPassword') }}</div>
|
|
102
99
|
<v-text-field
|
|
103
100
|
variant="outlined"
|
package/src/components/IdentityRecoveryPasswordComplete/IdentityRecoveryPasswordComplete.vue
CHANGED
|
@@ -6,6 +6,7 @@ import type {IClientInputError} from "@drax/common-front";
|
|
|
6
6
|
import {useI18nValidation} from "@drax/common-vue";
|
|
7
7
|
import {useI18n} from "vue-i18n";
|
|
8
8
|
import {useRoute, useRouter} from "vue-router"
|
|
9
|
+
import PasswordPolicyInput from "../PasswordPolicy/PasswordPolicyInput.vue";
|
|
9
10
|
|
|
10
11
|
const {t,te} = useI18n()
|
|
11
12
|
const {$ta} = useI18nValidation()
|
|
@@ -22,9 +23,7 @@ const inputErrors = ref<IClientInputError|undefined>({currentPassword: [], newPa
|
|
|
22
23
|
const errorMsg = ref('')
|
|
23
24
|
const loading = ref(false)
|
|
24
25
|
const success = ref(false)
|
|
25
|
-
|
|
26
|
-
let newPasswordVisibility = ref(false)
|
|
27
|
-
|
|
26
|
+
const newPasswordVisibility = ref(false)
|
|
28
27
|
|
|
29
28
|
const isFormValid = computed(() =>
|
|
30
29
|
recoveryCode.value.trim() !== '' && newPassword.value.trim() !== ''
|
|
@@ -107,18 +106,14 @@ async function submitResetPassword() {
|
|
|
107
106
|
|
|
108
107
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.newPassword') }}</div>
|
|
109
108
|
<!-- NEW PASSWORD-->
|
|
110
|
-
<
|
|
109
|
+
<PasswordPolicyInput
|
|
111
110
|
variant="outlined"
|
|
112
111
|
id="new-password-input"
|
|
113
112
|
v-model="newPassword"
|
|
114
|
-
:type="newPasswordVisibility ? 'text': 'password'"
|
|
115
113
|
required
|
|
116
|
-
prepend-inner-icon="mdi-lock-outline"
|
|
117
|
-
:append-inner-icon="newPasswordVisibility ? 'mdi-eye-off': 'mdi-eye'"
|
|
118
|
-
@click:append-inner="newPasswordVisibility = !newPasswordVisibility"
|
|
119
114
|
autocomplete="new-password"
|
|
120
115
|
:error-messages="$ta(inputErrors?.newPassword)"
|
|
121
|
-
|
|
116
|
+
/>
|
|
122
117
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.confirmPassword') }}</div>
|
|
123
118
|
<!-- CONFIRM PASSWORD-->
|
|
124
119
|
<v-text-field
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {computed, ref, useAttrs} from "vue";
|
|
3
|
+
import {useI18n} from "vue-i18n";
|
|
4
|
+
import {useDisplay} from "vuetify";
|
|
5
|
+
import PasswordPolicySummary from "./PasswordPolicySummary.vue";
|
|
6
|
+
|
|
7
|
+
defineOptions({
|
|
8
|
+
inheritAttrs: false
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const modelValue = defineModel<string>({
|
|
12
|
+
default: ''
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const attrs = useAttrs()
|
|
16
|
+
const {t} = useI18n()
|
|
17
|
+
const {xs} = useDisplay()
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
id?: string
|
|
21
|
+
label?: string
|
|
22
|
+
variant?: 'outlined' | 'plain' | 'filled' | 'solo' | 'solo-filled' | 'solo-inverted' | 'underlined'
|
|
23
|
+
rules?: Array<(value: string) => boolean | string>
|
|
24
|
+
errorMessages?: string | string[]
|
|
25
|
+
readonly?: boolean
|
|
26
|
+
required?: boolean
|
|
27
|
+
autocomplete?: string
|
|
28
|
+
prependInnerIcon?: string
|
|
29
|
+
showPolicyLink?: boolean
|
|
30
|
+
}>(), {
|
|
31
|
+
id: undefined,
|
|
32
|
+
label: undefined,
|
|
33
|
+
variant: 'outlined',
|
|
34
|
+
rules: () => [],
|
|
35
|
+
errorMessages: () => [],
|
|
36
|
+
readonly: false,
|
|
37
|
+
required: false,
|
|
38
|
+
autocomplete: 'new-password',
|
|
39
|
+
prependInnerIcon: 'mdi-lock-outline',
|
|
40
|
+
showPolicyLink: true
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const isVisible = ref(false)
|
|
44
|
+
const inputType = computed(() => isVisible.value ? 'text' : 'password')
|
|
45
|
+
const visibilityIcon = computed(() => isVisible.value ? 'mdi-eye-off' : 'mdi-eye')
|
|
46
|
+
const policyLinkLabel = computed(() => t(xs.value ? 'user.passwordPolicy.linkShort' : 'user.passwordPolicy.link'))
|
|
47
|
+
|
|
48
|
+
function toggleVisibility() {
|
|
49
|
+
isVisible.value = !isVisible.value
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<v-text-field
|
|
55
|
+
v-bind="attrs"
|
|
56
|
+
:id="id"
|
|
57
|
+
v-model="modelValue"
|
|
58
|
+
:label="label"
|
|
59
|
+
:type="inputType"
|
|
60
|
+
:variant="variant"
|
|
61
|
+
:rules="rules"
|
|
62
|
+
:required="required"
|
|
63
|
+
:prepend-inner-icon="prependInnerIcon"
|
|
64
|
+
:autocomplete="autocomplete"
|
|
65
|
+
:error-messages="errorMessages"
|
|
66
|
+
:readonly="readonly"
|
|
67
|
+
>
|
|
68
|
+
<template #append-inner>
|
|
69
|
+
<div
|
|
70
|
+
v-if="showPolicyLink"
|
|
71
|
+
class="password-policy-link-wrapper mb-2"
|
|
72
|
+
>
|
|
73
|
+
<v-menu
|
|
74
|
+
open-on-hover
|
|
75
|
+
location="top end"
|
|
76
|
+
:close-delay="120"
|
|
77
|
+
:open-delay="120"
|
|
78
|
+
>
|
|
79
|
+
<template #activator="{ props: menuProps }">
|
|
80
|
+
<v-btn
|
|
81
|
+
v-bind="menuProps"
|
|
82
|
+
tabindex="-1"
|
|
83
|
+
variant="text"
|
|
84
|
+
color="grey"
|
|
85
|
+
size="small"
|
|
86
|
+
class="mt-2"
|
|
87
|
+
>
|
|
88
|
+
{{ policyLinkLabel }}
|
|
89
|
+
</v-btn>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<v-card class="password-policy-menu" rounded="lg">
|
|
93
|
+
<v-card-text class="pa-3">
|
|
94
|
+
<PasswordPolicySummary variant="compact" />
|
|
95
|
+
</v-card-text>
|
|
96
|
+
</v-card>
|
|
97
|
+
</v-menu>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<v-icon
|
|
101
|
+
:icon="visibilityIcon"
|
|
102
|
+
@click="toggleVisibility"
|
|
103
|
+
/>
|
|
104
|
+
</template>
|
|
105
|
+
</v-text-field>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.password-policy-link-wrapper {
|
|
110
|
+
display: flex;
|
|
111
|
+
justify-content: flex-start;
|
|
112
|
+
margin-bottom: 4px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.password-policy-menu {
|
|
116
|
+
max-width: 400px;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {computed, onMounted, ref} from "vue";
|
|
3
|
+
import {useI18n} from "vue-i18n";
|
|
4
|
+
import {useAuth} from "../../composables/useAuth";
|
|
5
|
+
import type {IPasswordPolicy} from "@drax/identity-front";
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<{
|
|
8
|
+
variant?: 'compact' | 'full'
|
|
9
|
+
}>(), {
|
|
10
|
+
variant: 'full'
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const {t} = useI18n()
|
|
14
|
+
const {passwordPolicy} = useAuth()
|
|
15
|
+
|
|
16
|
+
const loading = ref(true)
|
|
17
|
+
const errorMsg = ref('')
|
|
18
|
+
const policy = ref<IPasswordPolicy | null>(null)
|
|
19
|
+
|
|
20
|
+
const compact = computed(() => props.variant === 'compact')
|
|
21
|
+
|
|
22
|
+
const rules = computed(() => {
|
|
23
|
+
if (!policy.value) {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
label: t('user.passwordPolicy.minLength', {count: policy.value.minLength}),
|
|
30
|
+
enabled: true,
|
|
31
|
+
icon: 'mdi-format-letter-case'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: t('user.passwordPolicy.maxLength', {count: policy.value.maxLength}),
|
|
35
|
+
enabled: true,
|
|
36
|
+
icon: 'mdi-ruler'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: t('user.passwordPolicy.requireUppercase'),
|
|
40
|
+
enabled: policy.value.requireUppercase,
|
|
41
|
+
icon: 'mdi-format-letter-case-upper'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: t('user.passwordPolicy.requireLowercase'),
|
|
45
|
+
enabled: policy.value.requireLowercase,
|
|
46
|
+
icon: 'mdi-format-letter-case-lower'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: t('user.passwordPolicy.requireNumber'),
|
|
50
|
+
enabled: policy.value.requireNumber,
|
|
51
|
+
icon: 'mdi-numeric'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
label: t('user.passwordPolicy.requireSpecialChar'),
|
|
55
|
+
description: policy.value.allowedSpecialChars
|
|
56
|
+
? t('user.passwordPolicy.specialCharsAllowed', {chars: policy.value.allowedSpecialChars})
|
|
57
|
+
: undefined,
|
|
58
|
+
enabled: policy.value.requireSpecialChar,
|
|
59
|
+
icon: 'mdi-asterisk'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
label: t('user.passwordPolicy.disallowSpaces'),
|
|
63
|
+
enabled: policy.value.disallowSpaces,
|
|
64
|
+
icon: 'mdi-keyboard-space'
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const metadata = computed(() => {
|
|
70
|
+
if (!policy.value) {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const items = [
|
|
75
|
+
{
|
|
76
|
+
label: t('user.passwordPolicy.reuseLabel'),
|
|
77
|
+
value: policy.value.preventReuse > 0
|
|
78
|
+
? t('user.passwordPolicy.reuseValue', {count: policy.value.preventReuse})
|
|
79
|
+
: t('user.passwordPolicy.reuseNone')
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: t('user.passwordPolicy.expirationLabel'),
|
|
83
|
+
value: policy.value.expirationDays
|
|
84
|
+
? t('user.passwordPolicy.expirationValue', {count: policy.value.expirationDays})
|
|
85
|
+
: t('user.passwordPolicy.expirationNone')
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
if (policy.value.requireSpecialChar && policy.value.allowedSpecialChars) {
|
|
90
|
+
items.push({
|
|
91
|
+
label: t('user.passwordPolicy.specialCharsLabel'),
|
|
92
|
+
value: policy.value.allowedSpecialChars
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return items
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
async function loadPolicy() {
|
|
100
|
+
try {
|
|
101
|
+
loading.value = true
|
|
102
|
+
errorMsg.value = ''
|
|
103
|
+
policy.value = await passwordPolicy()
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const err = error as Error
|
|
106
|
+
errorMsg.value = err.message || t('user.passwordPolicy.loadError')
|
|
107
|
+
} finally {
|
|
108
|
+
loading.value = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onMounted(loadPolicy)
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<template>
|
|
116
|
+
<div>
|
|
117
|
+
<div
|
|
118
|
+
v-if="loading"
|
|
119
|
+
:class="compact ? 'text-caption text-medium-emphasis' : 'text-medium-emphasis'"
|
|
120
|
+
>
|
|
121
|
+
{{ t('user.passwordPolicy.loading') }}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<v-alert
|
|
125
|
+
v-else-if="errorMsg"
|
|
126
|
+
type="error"
|
|
127
|
+
variant="tonal"
|
|
128
|
+
:density="compact ? 'compact' : 'default'"
|
|
129
|
+
>
|
|
130
|
+
{{ errorMsg }}
|
|
131
|
+
</v-alert>
|
|
132
|
+
|
|
133
|
+
<template v-else-if="policy">
|
|
134
|
+
<template v-if="compact">
|
|
135
|
+
<div class="text-caption text-medium-emphasis mb-2">
|
|
136
|
+
{{ t('user.passwordPolicy.description') }}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<v-list class="py-0" density="compact" lines="two">
|
|
140
|
+
<v-list-item
|
|
141
|
+
v-for="rule in rules"
|
|
142
|
+
:key="rule.label"
|
|
143
|
+
class="px-0"
|
|
144
|
+
:prepend-icon="rule.icon"
|
|
145
|
+
>
|
|
146
|
+
<v-list-item-title class="text-body-2 policy-text-wrap">
|
|
147
|
+
{{ rule.label }}
|
|
148
|
+
</v-list-item-title>
|
|
149
|
+
<v-list-item-subtitle v-if="rule.enabled && rule.description" class="text-caption policy-text-wrap">
|
|
150
|
+
{{ rule.description }}
|
|
151
|
+
</v-list-item-subtitle>
|
|
152
|
+
<template #append>
|
|
153
|
+
<v-chip
|
|
154
|
+
:color="rule.enabled ? 'success' : 'default'"
|
|
155
|
+
size="x-small"
|
|
156
|
+
variant="tonal"
|
|
157
|
+
>
|
|
158
|
+
{{ rule.enabled ? t('user.passwordPolicy.required') : t('user.passwordPolicy.optional') }}
|
|
159
|
+
</v-chip>
|
|
160
|
+
</template>
|
|
161
|
+
</v-list-item>
|
|
162
|
+
</v-list>
|
|
163
|
+
|
|
164
|
+
<v-divider class="my-2" />
|
|
165
|
+
|
|
166
|
+
<div class="compact-meta">
|
|
167
|
+
<div v-for="item in metadata" :key="item.label" class="compact-meta-item">
|
|
168
|
+
<span class="text-caption text-medium-emphasis">{{ item.label }}:</span>
|
|
169
|
+
<span class="text-body-2">{{ item.value }}</span>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<template v-else>
|
|
175
|
+
<div class="text-body-1 mb-4">
|
|
176
|
+
{{ t('user.passwordPolicy.description') }}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<v-list lines="two">
|
|
180
|
+
<v-list-item
|
|
181
|
+
v-for="rule in rules"
|
|
182
|
+
:key="rule.label"
|
|
183
|
+
:prepend-icon="rule.icon"
|
|
184
|
+
>
|
|
185
|
+
<v-list-item-title>{{ rule.label }}</v-list-item-title>
|
|
186
|
+
<v-list-item-subtitle v-if="rule.enabled && rule.description">
|
|
187
|
+
{{ rule.description }}
|
|
188
|
+
</v-list-item-subtitle>
|
|
189
|
+
<template #append>
|
|
190
|
+
<v-chip
|
|
191
|
+
:color="rule.enabled ? 'success' : 'default'"
|
|
192
|
+
size="small"
|
|
193
|
+
variant="tonal"
|
|
194
|
+
>
|
|
195
|
+
{{ rule.enabled ? t('user.passwordPolicy.required') : t('user.passwordPolicy.optional') }}
|
|
196
|
+
</v-chip>
|
|
197
|
+
</template>
|
|
198
|
+
</v-list-item>
|
|
199
|
+
</v-list>
|
|
200
|
+
|
|
201
|
+
<v-divider />
|
|
202
|
+
|
|
203
|
+
<v-row class="mt-1">
|
|
204
|
+
<v-col
|
|
205
|
+
v-for="item in metadata"
|
|
206
|
+
:key="item.label"
|
|
207
|
+
cols="12"
|
|
208
|
+
sm="6"
|
|
209
|
+
>
|
|
210
|
+
<v-sheet class="info-box pa-4 fill-height" rounded>
|
|
211
|
+
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
|
|
212
|
+
<div class="text-body-1">{{ item.value }}</div>
|
|
213
|
+
</v-sheet>
|
|
214
|
+
</v-col>
|
|
215
|
+
</v-row>
|
|
216
|
+
</template>
|
|
217
|
+
</template>
|
|
218
|
+
</div>
|
|
219
|
+
</template>
|
|
220
|
+
|
|
221
|
+
<style scoped>
|
|
222
|
+
.compact-meta {
|
|
223
|
+
display: grid;
|
|
224
|
+
gap: 6px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.compact-meta-item {
|
|
228
|
+
display: flex;
|
|
229
|
+
gap: 6px;
|
|
230
|
+
align-items: baseline;
|
|
231
|
+
flex-wrap: wrap;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.policy-text-wrap {
|
|
235
|
+
white-space: normal;
|
|
236
|
+
overflow: visible;
|
|
237
|
+
text-overflow: unset;
|
|
238
|
+
word-break: break-word;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.info-box {
|
|
242
|
+
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
243
|
+
}
|
|
244
|
+
</style>
|
|
@@ -7,10 +7,10 @@ import TenantCombobox from "../../combobox/TenantCombobox.vue";
|
|
|
7
7
|
import {useAuth} from "../../composables/useAuth";
|
|
8
8
|
import {useI18n} from "vue-i18n";
|
|
9
9
|
import {useIdentityCrudStore} from "../../stores/IdentityCrudStore";
|
|
10
|
+
import PasswordPolicyInput from "../../components/PasswordPolicy/PasswordPolicyInput.vue";
|
|
10
11
|
|
|
11
12
|
const {$ta} = useI18nValidation()
|
|
12
13
|
const {t, te} = useI18n()
|
|
13
|
-
|
|
14
14
|
const {hasPermission} = useAuth()
|
|
15
15
|
|
|
16
16
|
|
|
@@ -25,7 +25,6 @@ const store = useCrudStore(entity.name)
|
|
|
25
25
|
const enablePassword = store.operation === 'create'
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
|
|
29
28
|
const valid = ref()
|
|
30
29
|
const formRef = ref()
|
|
31
30
|
|
|
@@ -33,12 +32,11 @@ const formRef = ref()
|
|
|
33
32
|
const isTenantEnabled = computed(() => import.meta.env.VITE_DRAX_TENANT === 'ENABLE')
|
|
34
33
|
|
|
35
34
|
|
|
36
|
-
|
|
37
35
|
async function submit() {
|
|
38
36
|
store.resetErrors()
|
|
39
37
|
|
|
40
|
-
if(store.operation === 'delete') {
|
|
41
|
-
emit('submit',valueModel.value)
|
|
38
|
+
if (store.operation === 'delete') {
|
|
39
|
+
emit('submit', valueModel.value)
|
|
42
40
|
return
|
|
43
41
|
}
|
|
44
42
|
|
|
@@ -55,7 +53,7 @@ function cancel() {
|
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
const {
|
|
58
|
-
|
|
56
|
+
submitColor, readonly
|
|
59
57
|
} = useFormUtils(store.operation)
|
|
60
58
|
|
|
61
59
|
|
|
@@ -74,6 +72,11 @@ const variant = computed(() => {
|
|
|
74
72
|
|
|
75
73
|
|
|
76
74
|
let passwordVisibility = ref(false)
|
|
75
|
+
const confirmPassword = ref('')
|
|
76
|
+
|
|
77
|
+
function confirmPasswordRule(value: string) {
|
|
78
|
+
return value === valueModel.value.password || t('validation.password.confirmed')
|
|
79
|
+
}
|
|
77
80
|
|
|
78
81
|
</script>
|
|
79
82
|
|
|
@@ -93,96 +96,135 @@ let passwordVisibility = ref(false)
|
|
|
93
96
|
|
|
94
97
|
<v-card-text>
|
|
95
98
|
|
|
96
|
-
<v-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
v-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
v-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
99
|
+
<v-row>
|
|
100
|
+
|
|
101
|
+
<v-col cols="12">
|
|
102
|
+
<v-text-field
|
|
103
|
+
id="name-input"
|
|
104
|
+
:label="t('user.field.name')"
|
|
105
|
+
v-model="valueModel.name"
|
|
106
|
+
prepend-inner-icon="mdi-card-account-details"
|
|
107
|
+
:variant="variant"
|
|
108
|
+
:rules="[v => !!v || t('validation.required')]"
|
|
109
|
+
:error-messages="$ta(store.inputErrors?.name)"
|
|
110
|
+
:readonly="readonly"
|
|
111
|
+
></v-text-field>
|
|
112
|
+
</v-col>
|
|
113
|
+
|
|
114
|
+
<v-col cols="12">
|
|
115
|
+
<v-text-field
|
|
116
|
+
id="username-input"
|
|
117
|
+
:label="t('user.field.username')"
|
|
118
|
+
v-model="valueModel.username"
|
|
119
|
+
prepend-inner-icon="mdi-account-question"
|
|
120
|
+
:variant="variant"
|
|
121
|
+
:rules="[v => !!v || t('validation.required')]"
|
|
122
|
+
autocomplete="new-username"
|
|
123
|
+
:error-messages="$ta(store.inputErrors?.username)"
|
|
124
|
+
:readonly="readonly"
|
|
125
|
+
></v-text-field>
|
|
126
|
+
</v-col>
|
|
127
|
+
|
|
128
|
+
<v-col cols="12">
|
|
129
|
+
<template v-if="enablePassword">
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
<PasswordPolicyInput
|
|
133
|
+
id="password-input"
|
|
134
|
+
:label="t('user.field.password')"
|
|
135
|
+
v-model="valueModel.password"
|
|
136
|
+
:variant="variant"
|
|
137
|
+
:rules="[v => !!v || t('validation.required')]"
|
|
138
|
+
autocomplete="new-password"
|
|
139
|
+
:error-messages="$ta(store.inputErrors?.password)"
|
|
140
|
+
:readonly="readonly"
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
</template>
|
|
145
|
+
</v-col>
|
|
146
|
+
|
|
147
|
+
<v-col cols="12">
|
|
148
|
+
<v-text-field
|
|
149
|
+
v-if="enablePassword"
|
|
150
|
+
id="confirm-password-input"
|
|
151
|
+
:label="t('user.field.confirmPassword')"
|
|
152
|
+
v-model="confirmPassword"
|
|
153
|
+
:type="passwordVisibility ? 'text': 'password'"
|
|
154
|
+
:variant="variant"
|
|
155
|
+
:rules="[
|
|
156
|
+
v => !!v || t('validation.required'),
|
|
157
|
+
confirmPasswordRule
|
|
158
|
+
]"
|
|
159
|
+
prepend-inner-icon="mdi-lock-outline"
|
|
160
|
+
:append-inner-icon="passwordVisibility ? 'mdi-eye-off': 'mdi-eye'"
|
|
161
|
+
@click:append-inner="passwordVisibility = !passwordVisibility"
|
|
162
|
+
autocomplete="new-password"
|
|
163
|
+
:readonly="readonly"
|
|
164
|
+
></v-text-field>
|
|
165
|
+
</v-col>
|
|
166
|
+
|
|
167
|
+
<v-col cols="12">
|
|
168
|
+
<RoleCombobox
|
|
169
|
+
v-model="valueModel.role"
|
|
170
|
+
:label="t('user.field.role')"
|
|
171
|
+
:variant="variant"
|
|
172
|
+
:rules="[(v:any) => !!v || t('validation.required')]"
|
|
173
|
+
:error-messages="$ta(store.inputErrors?.role)"
|
|
174
|
+
:readonly="readonly"
|
|
175
|
+
></RoleCombobox>
|
|
176
|
+
</v-col>
|
|
177
|
+
|
|
178
|
+
<v-col cols="12">
|
|
179
|
+
<TenantCombobox
|
|
180
|
+
v-if="isTenantEnabled && hasPermission('tenant:manage')"
|
|
181
|
+
v-model="valueModel.tenant"
|
|
182
|
+
:label="t('user.field.tenant')"
|
|
183
|
+
:variant="variant"
|
|
184
|
+
:error-messages="$ta(store.inputErrors?.tenant)"
|
|
185
|
+
clearable
|
|
186
|
+
:readonly="readonly"
|
|
187
|
+
></TenantCombobox>
|
|
188
|
+
</v-col>
|
|
189
|
+
|
|
190
|
+
<v-col cols="12">
|
|
191
|
+
<v-text-field
|
|
192
|
+
v-model="valueModel.email"
|
|
193
|
+
:variant="variant"
|
|
194
|
+
id="email-input"
|
|
195
|
+
:label="t('user.field.email')"
|
|
196
|
+
prepend-inner-icon="mdi-email"
|
|
197
|
+
:rules="[(v:any) => !!v || t('validation.required')]"
|
|
198
|
+
:error-messages="$ta(store.inputErrors?.email)"
|
|
199
|
+
:readonly="readonly"
|
|
200
|
+
></v-text-field>
|
|
201
|
+
</v-col>
|
|
202
|
+
|
|
203
|
+
<v-col cols="12">
|
|
204
|
+
<v-text-field
|
|
205
|
+
v-model="valueModel.phone"
|
|
206
|
+
:variant="variant"
|
|
207
|
+
id="phone-input"
|
|
208
|
+
:label="t('user.field.phone')"
|
|
209
|
+
prepend-inner-icon="mdi-phone"
|
|
210
|
+
:rules="[(v:any) => !!v || t('validation.required')]"
|
|
211
|
+
:error-messages="$ta(store.inputErrors?.phone)"
|
|
212
|
+
:readonly="readonly"
|
|
213
|
+
></v-text-field>
|
|
214
|
+
</v-col>
|
|
215
|
+
|
|
216
|
+
<v-col cols="12">
|
|
217
|
+
<v-switch
|
|
218
|
+
id="active-input"
|
|
219
|
+
v-model="valueModel.active"
|
|
220
|
+
color="primary"
|
|
221
|
+
label="Active"
|
|
222
|
+
:true-value="true"
|
|
223
|
+
:false-value="false"
|
|
224
|
+
:readonly="readonly"
|
|
225
|
+
></v-switch>
|
|
226
|
+
</v-col>
|
|
227
|
+
</v-row>
|
|
186
228
|
</v-card-text>
|
|
187
229
|
|
|
188
230
|
<v-card-actions>
|
|
@@ -207,5 +249,4 @@ let passwordVisibility = ref(false)
|
|
|
207
249
|
</template>
|
|
208
250
|
|
|
209
251
|
<style scoped>
|
|
210
|
-
|
|
211
252
|
</style>
|
|
@@ -22,9 +22,11 @@ let passwordChanged = ref(false);
|
|
|
22
22
|
let inputErrors = ref<IClientInputError>()
|
|
23
23
|
let loading = ref(false);
|
|
24
24
|
let userError = ref<string>('')
|
|
25
|
+
let form = ref()
|
|
25
26
|
|
|
26
27
|
async function savePassword() {
|
|
27
|
-
|
|
28
|
+
const result = await form.value?.validate()
|
|
29
|
+
if (result?.valid && passwordForm.value.newPassword === passwordForm.value.confirmPassword) {
|
|
28
30
|
await changeUserPassword(user._id, passwordForm.value.newPassword)
|
|
29
31
|
passwordChanged.value = true
|
|
30
32
|
}
|
|
@@ -56,6 +58,7 @@ async function changeUserPassword(id: string, newPassword: string) {
|
|
|
56
58
|
<v-card-subtitle>{{ t('user.field.username') }}: {{ user.username }}</v-card-subtitle>
|
|
57
59
|
<v-card-text>
|
|
58
60
|
<user-password-form
|
|
61
|
+
ref="form"
|
|
59
62
|
v-model="passwordForm"
|
|
60
63
|
:inputErrors="inputErrors"
|
|
61
64
|
:passwordChanged="passwordChanged"
|
|
@@ -4,12 +4,14 @@ import type {IClientInputError} from "@drax/common-front";
|
|
|
4
4
|
import type {IUserPassword} from "@drax/identity-front";
|
|
5
5
|
import {useI18nValidation} from "@drax/common-vue";
|
|
6
6
|
import {useI18n} from "vue-i18n";
|
|
7
|
+
import PasswordPolicyInput from "../../components/PasswordPolicy/PasswordPolicyInput.vue";
|
|
7
8
|
|
|
8
9
|
const {t} = useI18n()
|
|
9
10
|
const {$ta} = useI18nValidation()
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
const formRef = ref()
|
|
12
13
|
|
|
14
|
+
const newPasswordVisibility = ref(false)
|
|
13
15
|
|
|
14
16
|
defineProps({
|
|
15
17
|
inputErrors: {
|
|
@@ -34,10 +36,17 @@ function confirmPasswordRule(value: string) {
|
|
|
34
36
|
// Define emits
|
|
35
37
|
const emits = defineEmits(['formSubmit'])
|
|
36
38
|
|
|
39
|
+
async function validate() {
|
|
40
|
+
return await formRef.value?.validate()
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
// Function to call when form is attempted to be submitted
|
|
38
44
|
const onSubmit = () => {
|
|
39
45
|
emits('formSubmit', form); // Emitting an event with the form data
|
|
40
46
|
}
|
|
47
|
+
|
|
48
|
+
defineExpose({validate})
|
|
49
|
+
|
|
41
50
|
</script>
|
|
42
51
|
|
|
43
52
|
<template>
|
|
@@ -48,21 +57,17 @@ const onSubmit = () => {
|
|
|
48
57
|
</v-alert>
|
|
49
58
|
</v-sheet>
|
|
50
59
|
<v-sheet v-else>
|
|
51
|
-
<form ref="
|
|
60
|
+
<v-form ref="formRef" @submit.prevent="onSubmit">
|
|
52
61
|
|
|
53
62
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.newPassword') }}</div>
|
|
54
|
-
<
|
|
63
|
+
<PasswordPolicyInput
|
|
55
64
|
variant="outlined"
|
|
56
65
|
id="new-password-input"
|
|
57
66
|
v-model="form.newPassword"
|
|
58
|
-
:type="newPasswordVisibility ? 'text': 'password'"
|
|
59
67
|
required
|
|
60
|
-
prepend-inner-icon="mdi-lock-outline"
|
|
61
|
-
:append-inner-icon="newPasswordVisibility ? 'mdi-eye-off': 'mdi-eye'"
|
|
62
|
-
@click:append-inner="newPasswordVisibility = !newPasswordVisibility"
|
|
63
68
|
autocomplete="new-password"
|
|
64
69
|
:error-messages="$ta(inputErrors.newPassword)"
|
|
65
|
-
|
|
70
|
+
/>
|
|
66
71
|
|
|
67
72
|
<div class="text-subtitle-1 text-medium-emphasis">{{ t('user.field.confirmPassword') }}</div>
|
|
68
73
|
|
|
@@ -79,7 +84,7 @@ const onSubmit = () => {
|
|
|
79
84
|
:error-messages="$ta(inputErrors.confirmPassword)"
|
|
80
85
|
:rules="[confirmPasswordRule]"
|
|
81
86
|
></v-text-field>
|
|
82
|
-
</form>
|
|
87
|
+
</v-form>
|
|
83
88
|
</v-sheet>
|
|
84
89
|
|
|
85
90
|
</template>
|
|
@@ -1,93 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import type {IPasswordPolicy} from "@drax/identity-front";
|
|
2
|
+
import {useI18n} from "vue-i18n";
|
|
3
|
+
import PasswordPolicySummary from "../components/PasswordPolicy/PasswordPolicySummary.vue";
|
|
5
4
|
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
const loading = ref(true)
|
|
9
|
-
const errorMsg = ref('')
|
|
10
|
-
const policy = ref<IPasswordPolicy | null>(null)
|
|
11
|
-
|
|
12
|
-
const rules = computed(() => {
|
|
13
|
-
if (!policy.value) {
|
|
14
|
-
return []
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return [
|
|
18
|
-
{
|
|
19
|
-
label: `Longitud mínima de ${policy.value.minLength} caracteres`,
|
|
20
|
-
enabled: true,
|
|
21
|
-
icon: 'mdi-format-letter-case'
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
label: `Longitud máxima de ${policy.value.maxLength} caracteres`,
|
|
25
|
-
enabled: true,
|
|
26
|
-
icon: 'mdi-ruler'
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
label: 'Debe incluir al menos una mayúscula',
|
|
30
|
-
enabled: policy.value.requireUppercase,
|
|
31
|
-
icon: 'mdi-format-letter-case-upper'
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
label: 'Debe incluir al menos una minúscula',
|
|
35
|
-
enabled: policy.value.requireLowercase,
|
|
36
|
-
icon: 'mdi-format-letter-case-lower'
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
label: 'Debe incluir al menos un número',
|
|
40
|
-
enabled: policy.value.requireNumber,
|
|
41
|
-
icon: 'mdi-numeric'
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
label: 'Debe incluir al menos un carácter especial',
|
|
45
|
-
enabled: policy.value.requireSpecialChar,
|
|
46
|
-
icon: 'mdi-asterisk'
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
label: 'No se permiten espacios',
|
|
50
|
-
enabled: policy.value.disallowSpaces,
|
|
51
|
-
icon: 'mdi-keyboard-space'
|
|
52
|
-
}
|
|
53
|
-
]
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
const metadata = computed(() => {
|
|
57
|
-
if (!policy.value) {
|
|
58
|
-
return []
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return [
|
|
62
|
-
{
|
|
63
|
-
label: 'Reutilización',
|
|
64
|
-
value: policy.value.preventReuse > 0
|
|
65
|
-
? `No se pueden repetir las últimas ${policy.value.preventReuse} contraseñas`
|
|
66
|
-
: 'Sin restricción de reutilización'
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
label: 'Expiración',
|
|
70
|
-
value: policy.value.expirationDays
|
|
71
|
-
? `La contraseña expira cada ${policy.value.expirationDays} días`
|
|
72
|
-
: 'Sin expiración configurada'
|
|
73
|
-
}
|
|
74
|
-
]
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
async function loadPolicy() {
|
|
78
|
-
try {
|
|
79
|
-
loading.value = true
|
|
80
|
-
errorMsg.value = ''
|
|
81
|
-
policy.value = await passwordPolicy()
|
|
82
|
-
} catch (error) {
|
|
83
|
-
const err = error as Error
|
|
84
|
-
errorMsg.value = err.message || 'No se pudo obtener la política de contraseñas'
|
|
85
|
-
} finally {
|
|
86
|
-
loading.value = false
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
onMounted(loadPolicy)
|
|
5
|
+
const {t} = useI18n()
|
|
91
6
|
</script>
|
|
92
7
|
|
|
93
8
|
<template>
|
|
@@ -96,71 +11,13 @@ onMounted(loadPolicy)
|
|
|
96
11
|
<v-col cols="12" md="8" lg="6">
|
|
97
12
|
<v-card variant="elevated">
|
|
98
13
|
<v-card-title class="pa-4">
|
|
99
|
-
|
|
14
|
+
{{ t('user.passwordPolicy.title') }}
|
|
100
15
|
</v-card-title>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<div class="text-medium-emphasis">Cargando política...</div>
|
|
16
|
+
<v-card-text>
|
|
17
|
+
<PasswordPolicySummary variant="full" />
|
|
104
18
|
</v-card-text>
|
|
105
|
-
|
|
106
|
-
<v-card-text v-else-if="errorMsg">
|
|
107
|
-
<v-alert type="error" variant="tonal">
|
|
108
|
-
{{ errorMsg }}
|
|
109
|
-
</v-alert>
|
|
110
|
-
</v-card-text>
|
|
111
|
-
|
|
112
|
-
<template v-else-if="policy">
|
|
113
|
-
<v-card-text class="pb-2">
|
|
114
|
-
Requisitos que debe cumplir una contraseña válida.
|
|
115
|
-
</v-card-text>
|
|
116
|
-
|
|
117
|
-
<v-card-text class="pt-0">
|
|
118
|
-
<v-list lines="two">
|
|
119
|
-
<v-list-item
|
|
120
|
-
v-for="rule in rules"
|
|
121
|
-
:key="rule.label"
|
|
122
|
-
:prepend-icon="rule.icon"
|
|
123
|
-
>
|
|
124
|
-
<v-list-item-title>{{ rule.label }}</v-list-item-title>
|
|
125
|
-
<template #append>
|
|
126
|
-
<v-chip
|
|
127
|
-
:color="rule.enabled ? 'success' : 'default'"
|
|
128
|
-
size="small"
|
|
129
|
-
variant="tonal"
|
|
130
|
-
>
|
|
131
|
-
{{ rule.enabled ? 'Requerido' : 'Opcional' }}
|
|
132
|
-
</v-chip>
|
|
133
|
-
</template>
|
|
134
|
-
</v-list-item>
|
|
135
|
-
</v-list>
|
|
136
|
-
</v-card-text>
|
|
137
|
-
|
|
138
|
-
<v-divider />
|
|
139
|
-
|
|
140
|
-
<v-card-text>
|
|
141
|
-
<v-row>
|
|
142
|
-
<v-col
|
|
143
|
-
v-for="item in metadata"
|
|
144
|
-
:key="item.label"
|
|
145
|
-
cols="12"
|
|
146
|
-
sm="6"
|
|
147
|
-
>
|
|
148
|
-
<v-sheet class="info-box pa-4 fill-height" rounded>
|
|
149
|
-
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
|
|
150
|
-
<div class="text-body-1">{{ item.value }}</div>
|
|
151
|
-
</v-sheet>
|
|
152
|
-
</v-col>
|
|
153
|
-
</v-row>
|
|
154
|
-
</v-card-text>
|
|
155
|
-
</template>
|
|
156
19
|
</v-card>
|
|
157
20
|
</v-col>
|
|
158
21
|
</v-row>
|
|
159
22
|
</v-container>
|
|
160
23
|
</template>
|
|
161
|
-
|
|
162
|
-
<style scoped>
|
|
163
|
-
.info-box {
|
|
164
|
-
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
165
|
-
}
|
|
166
|
-
</style>
|