feat: implement password reset functionality
- Added PasswordResetPage component for user password reset. - Integrated password reset link in LoginPage with a router link. - Created API endpoints for requesting and confirming password resets. - Added email templates for password reset notifications. - Updated database schema to support password reset tokens. - Enhanced ProfilePage and EditProfileDialog to include personal data fields. - Introduced layout store to manage body dimensions for responsive design. - Added validation for personal data fields in profile management.
This commit is contained in:
@@ -16,16 +16,34 @@
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
/>
|
||||
<div class="column q-gutter-xs">
|
||||
<div class="text-right">
|
||||
<router-link to="/password-reset" class="text-primary text-caption text-no-decoration">
|
||||
{{ t('login.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
|
||||
{{ store.error }}
|
||||
@@ -57,6 +75,7 @@ import { useProfilesStore } from '@/stores/profiles-store';
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
const store = useProfilesStore();
|
||||
const router = useRouter();
|
||||
|
||||
132
frontend/src/pages/PasswordResetPage.vue
Normal file
132
frontend/src/pages/PasswordResetPage.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ pageTitle }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form v-if="!token" class="column q-gutter-md" @submit.prevent="requestReset">
|
||||
<q-banner v-if="requestSent" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.requestSent') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!requestSent"
|
||||
v-model="email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!requestSent"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.sendLink')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
|
||||
<q-form v-else class="column q-gutter-md" @submit.prevent="confirmReset">
|
||||
<q-banner v-if="resetDone" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.resetDone') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!resetDone"
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!resetDone"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.updatePassword')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Client, { Local } from '@/encore/client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const client = new Client(Local);
|
||||
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
|
||||
const pageTitle = computed(() => (token.value ? t('passwordReset.newPasswordTitle') : t('passwordReset.title')));
|
||||
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const requestSent = ref(false);
|
||||
const resetDone = ref(false);
|
||||
|
||||
async function withLoading(fn: () => Promise<void>) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.RequestPasswordReset({ email: email.value });
|
||||
requestSent.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.ConfirmPasswordReset(token.value, { password: password.value });
|
||||
resetDone.value = true;
|
||||
password.value = '';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -6,18 +6,156 @@
|
||||
{{ profilesStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 480px">
|
||||
<q-card-section class="q-gutter-md">
|
||||
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||
|
||||
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
|
||||
|
||||
<div class="text-caption text-grey">
|
||||
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 560px">
|
||||
<q-card-section class="row items-center q-gutter-sm">
|
||||
<q-avatar size="48px">
|
||||
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle1">{{ profilesStore.profile.email }}</div>
|
||||
<div class="text-subtitle1">{{ displayValue(profileForm.display_name) }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-tabs v-model="profileTab" align="left" class="text-primary" dense>
|
||||
<q-tab name="profile" :label="t('admin.profile')" />
|
||||
<q-tab name="personal" :label="t('admin.personalData')" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="profileTab" animated>
|
||||
<q-tab-panel name="profile" class="q-gutter-md">
|
||||
<template v-if="isEditMode">
|
||||
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
|
||||
{{ profileFormErrors.avatar_url }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<q-list v-else separator>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.displayName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(profileForm.display_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.email') }}</q-item-label>
|
||||
<q-item-label>{{ profilesStore.profile.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.role') }}</q-item-label>
|
||||
<q-item-label>{{ profilesStore.profile.role }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.status') }}</q-item-label>
|
||||
<q-item-label>
|
||||
<ProfileStatusBadge :status="profilesStore.profile.status" />
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="personal" class="q-gutter-md">
|
||||
<template v-if="isEditMode">
|
||||
<q-input
|
||||
ref="firstNameInputRef"
|
||||
v-model="personalForm.first_name"
|
||||
:label="t('fields.firstName')"
|
||||
:error="Boolean(personalFormErrors.first_name)"
|
||||
:error-message="personalFormErrors.first_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.last_name"
|
||||
:label="t('fields.lastName')"
|
||||
:error="Boolean(personalFormErrors.last_name)"
|
||||
:error-message="personalFormErrors.last_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.address"
|
||||
:label="t('fields.address')"
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.cap"
|
||||
:label="t('fields.cap')"
|
||||
:error="Boolean(personalFormErrors.cap)"
|
||||
:error-message="personalFormErrors.cap"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
:error="Boolean(personalFormErrors.city)"
|
||||
:error-message="personalFormErrors.city"
|
||||
/>
|
||||
<q-select
|
||||
v-model="personalForm.country"
|
||||
:options="filteredCountries"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
use-input
|
||||
clearable
|
||||
input-debounce="200"
|
||||
:label="t('fields.country')"
|
||||
:error="Boolean(personalFormErrors.country)"
|
||||
:error-message="personalFormErrors.country"
|
||||
@filter="filterCountries"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<q-list v-else separator>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.firstName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.first_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.lastName') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.last_name) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.address') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.address) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.cap') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.cap) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.city') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(personalForm.city) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ t('fields.country') }}</q-item-label>
|
||||
<q-item-label>{{ displayValue(countryLabel) }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<q-card-actions v-if="isEditMode" align="right">
|
||||
<q-btn flat :label="t('actions.cancel')" @click="cancelEdit" />
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
@@ -29,34 +167,185 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useProfilesStore, type Profile } from '@/stores/profiles-store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { countries } from '@/data/countries';
|
||||
import { ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
|
||||
import { useProfilesStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/profiles-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const profilesStore = useProfilesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const form = reactive({
|
||||
type CountryOption = { label: string; value: string };
|
||||
type ProfileTab = 'profile' | 'personal';
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const countryOptions: CountryOption[] = countries.map((c) => {
|
||||
const [value, label] = Object.entries(c)[0] as [string, string];
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const filteredCountries = ref<CountryOption[]>(countryOptions);
|
||||
const profileTab = ref<ProfileTab>('profile');
|
||||
const isEditMode = computed(() => route.query.mode === 'edit');
|
||||
const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country);
|
||||
const firstNameInputRef = ref<Focusable | null>(null);
|
||||
|
||||
const profileForm = reactive<ProfileParams>({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const profileFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||
|
||||
function syncForm(profile: Profile | null) {
|
||||
form.display_name = profile?.display_name ?? '';
|
||||
form.avatar_url = profile?.avatar_url ?? '';
|
||||
const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
||||
|
||||
watch(
|
||||
() => profilesStore.profile,
|
||||
(profile) => {
|
||||
if (profile) {
|
||||
void loadProfile(profile);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(profileTab, () => {
|
||||
if (isEditMode.value) {
|
||||
void focusFirstField();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isEditMode, (editing) => {
|
||||
if (editing) {
|
||||
void focusFirstField();
|
||||
} else if (profilesStore.profile) {
|
||||
void loadProfile(profilesStore.profile);
|
||||
}
|
||||
});
|
||||
|
||||
function filterCountries(val: string, update: (cb: () => void) => void) {
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
filteredCountries.value = needle
|
||||
? countryOptions.filter(
|
||||
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
|
||||
)
|
||||
: countryOptions;
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => profilesStore.profile, syncForm, { immediate: true });
|
||||
async function loadProfile(profile: Profile) {
|
||||
profileForm.display_name = profile.display_name;
|
||||
profileForm.avatar_url = profile.avatar_url;
|
||||
clearProfileFormErrors();
|
||||
resetPersonalForm();
|
||||
|
||||
try {
|
||||
const data = profilesStore.personalData ?? (await profilesStore.fetchPersonalData());
|
||||
resetPersonalForm(data);
|
||||
} catch {
|
||||
// No personal data yet (404) - leave the form empty for creation.
|
||||
profilesStore.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await profilesStore.update({ ...form });
|
||||
if (profileTab.value === 'profile') {
|
||||
const parsed = validateProfileForm();
|
||||
if (!parsed) return;
|
||||
await profilesStore.update(parsed);
|
||||
} else {
|
||||
const parsed = validatePersonalForm();
|
||||
if (!parsed) return;
|
||||
await profilesStore.savePersonalData(parsed);
|
||||
}
|
||||
await router.replace('/profile');
|
||||
} catch {
|
||||
// profilesStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelEdit() {
|
||||
if (profilesStore.profile) {
|
||||
await loadProfile(profilesStore.profile);
|
||||
}
|
||||
await router.replace('/profile');
|
||||
}
|
||||
|
||||
function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.cap = data?.cap ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
}
|
||||
|
||||
function validateProfileForm(): ProfileParams | null {
|
||||
clearProfileFormErrors();
|
||||
const result = ProfilesProfileParamsSchema.safeParse({ ...profileForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
|
||||
profileFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearProfileFormErrors() {
|
||||
profileFormErrors.display_name = '';
|
||||
profileFormErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function validatePersonalForm(): PersonalDataParams | null {
|
||||
clearPersonalFormErrors();
|
||||
const result = ProfilesPersonalDataParamsSchema.safeParse({ ...personalForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
|
||||
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.cap = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
function displayValue(value: string | number | null | undefined) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || '-';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
if (profileTab.value === 'personal') {
|
||||
firstNameInputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<q-page class="q-pa-md" ref="profilesPage">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
||||
<q-space />
|
||||
@@ -11,16 +11,20 @@
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:style="{ height: `${profilesTableHeight}px` }"
|
||||
class="sticky-header-table"
|
||||
:rows="adminStore.profiles"
|
||||
:columns="columns"
|
||||
v-model:pagination="pagination"
|
||||
row-key="user_id"
|
||||
:loading="adminStore.loading"
|
||||
flat
|
||||
bordered
|
||||
ref="profilesTable"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
|
||||
<ProfileStatusBadge :status="props.value" />
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
@@ -69,20 +73,55 @@
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.sticky-header-table { /* height or max-height is important */
|
||||
|
||||
.q-table__top,
|
||||
.q-table__bottom,
|
||||
thead tr:first-child th { /* bg color is important for th; just specify one */
|
||||
background-color: #ffffff;
|
||||
}
|
||||
thead tr th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
thead tr:first-child th {
|
||||
top: 0;
|
||||
}
|
||||
/* this is when the loading indicator appears */
|
||||
&.q-table--loading thead tr:last-child th { /* height of all previous header rows */
|
||||
top: 48px;
|
||||
}
|
||||
/* prevent scrolling behind sticky top row on focus */
|
||||
tbody { /* height of all previous header rows */
|
||||
scroll-margin-top: 48px;
|
||||
}
|
||||
}</style>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
import { statusInfo } from '@/stores/profiles-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const profilesPage = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTable = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTableHeight = ref(610);
|
||||
const pagination = ref({
|
||||
rowsPerPage: 0,
|
||||
});
|
||||
|
||||
const editDialogOpen = ref(false);
|
||||
const editingProfile = ref<Profile | null>(null);
|
||||
|
||||
@@ -138,14 +177,49 @@ const columns = computed<QTableColumn[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
function statusLabel(status: number) {
|
||||
const key = `status.${status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('status.unknown', { status }) : translated;
|
||||
function getElement(component: ComponentPublicInstance | null) {
|
||||
return component?.$el instanceof HTMLElement ? component.$el : null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function updateProfilesTableHeight() {
|
||||
const page = getElement(profilesPage.value);
|
||||
const table = getElement(profilesTable.value);
|
||||
|
||||
if (!page || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageRect = page.getBoundingClientRect();
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const pageStyle = window.getComputedStyle(page);
|
||||
const pageMinHeight = parseFloat(pageStyle.minHeight);
|
||||
const pageHeight = Number.isFinite(pageMinHeight) && pageMinHeight > 0 ? pageMinHeight : pageRect.height;
|
||||
const pagePaddingBottom = parseFloat(pageStyle.paddingBottom) || 0;
|
||||
const tableTopInPage = tableRect.top - pageRect.top;
|
||||
|
||||
profilesTableHeight.value = Math.max(0, Math.floor(pageHeight - tableTopInPage - pagePaddingBottom));
|
||||
}
|
||||
|
||||
async function updateProfilesTableHeightAfterRender() {
|
||||
await nextTick();
|
||||
updateProfilesTableHeight();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void adminStore.listProfiles();
|
||||
void adminStore.fetchSystemOptions();
|
||||
layoutStore.startBodyResizeListener();
|
||||
await updateProfilesTableHeightAfterRender();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [adminStore.error, layoutStore.bodyWidth, layoutStore.bodyHeight],
|
||||
() => {
|
||||
void updateProfilesTableHeightAfterRender();
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
layoutStore.stopBodyResizeListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.cap"
|
||||
:label="t('fields.cap')"
|
||||
:error="Boolean(personalFormErrors.cap)"
|
||||
:error-message="personalFormErrors.cap"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
@@ -127,6 +133,7 @@ const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
@@ -196,6 +203,7 @@ function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.cap = data?.cap ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
@@ -234,6 +242,7 @@ function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.cap = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user