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:
fabio
2026-07-26 17:28:51 +02:00
parent 167be9b0b3
commit 1367829f80
23 changed files with 1042 additions and 49 deletions

View File

@@ -28,8 +28,7 @@
dense
icon="edit"
:aria-label="t('actions.editProfile')"
to="/profile"
@click.stop
@click.stop.prevent="editProfile"
>
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
</q-btn>
@@ -67,6 +66,10 @@ async function logout() {
profilesStore.logout();
await router.push('/');
}
async function editProfile() {
await router.push({ path: '/profile', query: { mode: 'edit' } });
}
</script>
<style scoped>

View File

@@ -0,0 +1,23 @@
<template>
<q-badge :color="statusInfo(status).color">
{{ statusLabel }}
</q-badge>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { statusInfo, type Status } from '@/stores/profiles-store';
const props = defineProps<{
status: Status;
}>();
const { t } = useI18n();
const statusLabel = computed(() => {
const key = `status.${props.status}`;
const translated = t(key);
return translated === key ? t('status.unknown', { status: props.status }) : translated;
});
</script>

View File

@@ -1 +1,19 @@
/* app global css */
.q-field input:-webkit-autofill,
.q-field input:-webkit-autofill:hover,
.q-field input:-webkit-autofill:focus,
.q-field textarea:-webkit-autofill,
.q-field textarea:-webkit-autofill:hover,
.q-field textarea:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #ffffff inset !important;
box-shadow: 0 0 0 1000px #ffffff inset !important;
-webkit-text-fill-color: currentColor !important;
caret-color: currentColor !important;
transition: background-color 999999s ease-out;
}
.q-field:has(input:-webkit-autofill) .q-field__control,
.q-field:has(textarea:-webkit-autofill) .q-field__control {
background-color: #ffffff !important;
}

View File

@@ -106,6 +106,7 @@ export namespace admin {
"first_name": string
"last_name": string
address: string
cap: string
city: string
country: string
"created_at": string
@@ -116,6 +117,7 @@ export namespace admin {
"first_name": string
"last_name": string
address: string
cap: string
city: string
country: string
}
@@ -388,6 +390,7 @@ export namespace profiles {
"first_name": string
"last_name": string
address: string
cap: string
city: string
country: string
"created_at": string
@@ -401,6 +404,7 @@ export namespace profiles {
"first_name": string
"last_name": string
address: string
cap: string
city: string
country: string
}
@@ -572,6 +576,23 @@ export namespace registration {
profile: profiles.Profile
}
export interface PasswordResetConfirmParams {
password: string
}
export interface PasswordResetConfirmResponse {
email: string
reset: boolean
}
export interface PasswordResetRequestParams {
email: string
}
export interface PasswordResetRequestResponse {
"email_sent": boolean
}
export interface WelcomeResponse {
email: string
confirmed: boolean
@@ -584,8 +605,10 @@ export namespace registration {
constructor(baseClient: BaseClient) {
this.baseClient = baseClient
this.CheckEmail = this.CheckEmail.bind(this)
this.ConfirmPasswordReset = this.ConfirmPasswordReset.bind(this)
this.ConfirmWelcome = this.ConfirmWelcome.bind(this)
this.Register = this.Register.bind(this)
this.RequestPasswordReset = this.RequestPasswordReset.bind(this)
}
/**
@@ -597,6 +620,15 @@ export namespace registration {
return await resp.json() as EmailAvailabilityResponse
}
/**
* ConfirmPasswordReset validates a reset token and changes the account password.
*/
public async ConfirmPasswordReset(token: string, params: PasswordResetConfirmParams): Promise<PasswordResetConfirmResponse> {
// Now make the actual call to the API
const resp = await this.baseClient.callTypedAPI("POST", `/registration/password-reset/${encodeURIComponent(token)}`, JSON.stringify(params))
return await resp.json() as PasswordResetConfirmResponse
}
/**
* ConfirmWelcome validates the welcome email token.
*/
@@ -614,6 +646,15 @@ export namespace registration {
const resp = await this.baseClient.callTypedAPI("POST", `/registration`, JSON.stringify(params))
return await resp.json() as RegisterResponse
}
/**
* RequestPasswordReset creates a reset token and queues an email when the account exists.
*/
public async RequestPasswordReset(params: PasswordResetRequestParams): Promise<PasswordResetRequestResponse> {
// Now make the actual call to the API
const resp = await this.baseClient.callTypedAPI("POST", `/registration/password-reset`, JSON.stringify(params))
return await resp.json() as PasswordResetRequestResponse
}
}
}

View File

@@ -6,6 +6,7 @@ export const AdminPersonalDataParamsSchema = z.object({
first_name: z.string().min(2).max(32),
last_name: z.string().min(2).max(32),
address: z.string().min(5).max(32),
cap: z.string(),
city: z.string().min(2).max(32),
country: z.string(),
});
@@ -47,6 +48,7 @@ export const ProfilesPersonalDataParamsSchema = z.object({
first_name: z.string(),
last_name: z.string(),
address: z.string(),
cap: z.string(),
city: z.string(),
country: z.string(),
});
@@ -69,3 +71,11 @@ export const RegistrationRegisterParamsSchema = z.object({
avatar_url: z.string(),
password: z.string().min(8),
});
export const RegistrationPasswordResetConfirmParamsSchema = z.object({
password: z.string(),
});
export const RegistrationPasswordResetRequestParamsSchema = z.object({
email: z.string(),
});

View File

@@ -48,6 +48,7 @@ const messages = {
firstName: 'First name',
lastName: 'Last name',
address: 'Address',
cap: 'Postcode',
city: 'City',
country: 'Country',
role: 'Role',
@@ -81,6 +82,16 @@ const messages = {
login: {
title: 'Log in',
needAccount: 'Create an account',
forgotPassword: 'Forgot password',
},
passwordReset: {
title: 'Password reset',
newPasswordTitle: 'Choose a new password',
sendLink: 'Send reset link',
updatePassword: 'Update password',
backToLogin: 'Back to log in',
requestSent: 'If the email exists, we sent a password reset link.',
resetDone: 'Password updated. You can now log in.',
},
register: {
title: 'Create account',
@@ -151,6 +162,7 @@ const messages = {
firstName: 'Nome',
lastName: 'Cognome',
address: 'Indirizzo',
cap: 'CAP',
city: 'Città',
country: 'Paese',
role: 'Ruolo',
@@ -184,6 +196,16 @@ const messages = {
login: {
title: 'Accedi',
needAccount: 'Crea un account',
forgotPassword: 'Password dimenticata',
},
passwordReset: {
title: 'Recupero password',
newPasswordTitle: 'Scegli una nuova password',
sendLink: 'Invia link di recupero',
updatePassword: 'Aggiorna password',
backToLogin: 'Torna al login',
requestSent: 'Se lemail esiste, abbiamo inviato un link per reimpostare la password.',
resetDone: 'Password aggiornata. Ora puoi accedere.',
},
register: {
title: 'Crea account',
@@ -254,6 +276,7 @@ const messages = {
firstName: 'Prénom',
lastName: 'Nom',
address: 'Adresse',
cap: 'Code postal',
city: 'Ville',
country: 'Pays',
role: 'Rôle',
@@ -287,6 +310,16 @@ const messages = {
login: {
title: 'Connexion',
needAccount: 'Créer un compte',
forgotPassword: 'Mot de passe oublié',
},
passwordReset: {
title: 'Réinitialisation du mot de passe',
newPasswordTitle: 'Choisir un nouveau mot de passe',
sendLink: 'Envoyer le lien',
updatePassword: 'Mettre à jour le mot de passe',
backToLogin: 'Retour à la connexion',
requestSent: 'Si lemail existe, nous avons envoyé un lien de réinitialisation.',
resetDone: 'Mot de passe mis à jour. Vous pouvez maintenant vous connecter.',
},
register: {
title: 'Créer un compte',
@@ -357,6 +390,7 @@ const messages = {
firstName: 'Vorname',
lastName: 'Nachname',
address: 'Adresse',
cap: 'Postleitzahl',
city: 'Stadt',
country: 'Land',
role: 'Rolle',
@@ -390,6 +424,16 @@ const messages = {
login: {
title: 'Anmelden',
needAccount: 'Konto erstellen',
forgotPassword: 'Passwort vergessen',
},
passwordReset: {
title: 'Passwort zurücksetzen',
newPasswordTitle: 'Neues Passwort wählen',
sendLink: 'Link senden',
updatePassword: 'Passwort aktualisieren',
backToLogin: 'Zur Anmeldung',
requestSent: 'Wenn die E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.',
resetDone: 'Passwort aktualisiert. Sie können sich jetzt anmelden.',
},
register: {
title: 'Konto erstellen',
@@ -460,6 +504,7 @@ const messages = {
firstName: 'Nombre',
lastName: 'Apellido',
address: 'Dirección',
cap: 'Código postal',
city: 'Ciudad',
country: 'País',
role: 'Rol',
@@ -493,6 +538,16 @@ const messages = {
login: {
title: 'Iniciar sesión',
needAccount: 'Crear una cuenta',
forgotPassword: 'Contraseña olvidada',
},
passwordReset: {
title: 'Recuperar contraseña',
newPasswordTitle: 'Elige una nueva contraseña',
sendLink: 'Enviar enlace',
updatePassword: 'Actualizar contraseña',
backToLogin: 'Volver al inicio de sesión',
requestSent: 'Si el email existe, enviamos un enlace para restablecer la contraseña.',
resetDone: 'Contraseña actualizada. Ahora puedes iniciar sesión.',
},
register: {
title: 'Crear cuenta',

View File

@@ -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();

View 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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 = '';
}

View File

@@ -8,6 +8,7 @@ const routes: RouteRecordRaw[] = [
{ path: '', component: () => import('@/pages/IndexPage.vue') },
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
{ path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') },
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },

View File

@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useLayoutStore = defineStore('layout', () => {
const bodyWidth = ref(0);
const bodyHeight = ref(0);
let bodyResizeObserver: ResizeObserver | null = null;
let listeners = 0;
function updateBodySize() {
if (typeof document === 'undefined') {
return;
}
const rect = document.body.getBoundingClientRect();
bodyWidth.value = Math.floor(rect.width);
bodyHeight.value = Math.floor(rect.height);
}
function startBodyResizeListener() {
listeners += 1;
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
updateBodySize();
if (bodyResizeObserver) {
return;
}
bodyResizeObserver = new ResizeObserver(() => {
updateBodySize();
});
bodyResizeObserver.observe(document.body);
window.addEventListener('resize', updateBodySize);
}
function stopBodyResizeListener() {
listeners = Math.max(0, listeners - 1);
if (listeners > 0) {
return;
}
bodyResizeObserver?.disconnect();
bodyResizeObserver = null;
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateBodySize);
}
}
return {
bodyWidth,
bodyHeight,
startBodyResizeListener,
stopBodyResizeListener,
};
});