Compare commits

..

2 Commits

Author SHA1 Message Date
fabio
72e7626122 feat: enhance profile status display and remove pagination 2026-07-26 17:29:11 +02:00
fabio
1367829f80 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.
2026-07-26 17:28:51 +02:00
23 changed files with 1035 additions and 43 deletions

View File

@@ -243,6 +243,7 @@ type PersonalData struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
CreatedAt time.Time `json:"created_at"`
@@ -253,6 +254,7 @@ type PersonalDataParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
}
@@ -263,9 +265,9 @@ type PersonalDataParams struct {
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
pd := PersonalData{UserID: userID}
err := profilesDB.QueryRow(ctx, `
SELECT first_name, last_name, address, city, country, created_at, updated_at
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
FROM personal_data WHERE user_id = $1
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
}
@@ -284,21 +286,23 @@ func UpsertPersonalData(ctx context.Context, userID uuid.UUID, p *PersonalDataPa
FirstName: p.FirstName,
LastName: p.LastName,
Address: p.Address,
Cap: p.Cap,
City: p.City,
Country: p.Country,
}
err := profilesDB.QueryRow(ctx, `
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE
SET first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
address = EXCLUDED.address,
cap = EXCLUDED.cap,
city = EXCLUDED.city,
country = EXCLUDED.country,
updated_at = NOW()
RETURNING created_at, updated_at
`, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
if isForeignKeyViolation(err) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}

12
assicurazione.md Normal file
View File

@@ -0,0 +1,12 @@
N. sinistro 2026 7196966
1. descrizione dell'incidente
procedevo salendo verso Breno sulla strada cantonale (classificata strada di montagna). Affrontando la curva al tornante che prima di arrivare al negozio "Bottega Breno". La careggiata in questo tornante non ofre visibilità è necessario osservare la massima prudenza, cosa che percorrendo regolarmente questa strada applico regolarmente. Ho affrontato la curva come sempre con prudenza e tenendo la destra in modo adeguato. Essendo la careggiata non larga a sufficienza per incrociare due veicoli senza che uno si debba fermare. Inoltre visto la dimensione ridotta non c'è la segalazione di careggiata. (linea bianca al centro). Il veicolo che procedeva in discesa non mi ha concesso la precedenza, non si è fermato e mi ha urtato la parte frontale sinistra.
allego alla risposta alcune fotografie indicative e lo schizzo.
2. secondo lei, a chi è attribuibile la colpa e perche?
a mio avviso la colpa è attribuibile al vostro assicurato. Avrebbe dovuto prestare prudenza e rispettare la precedenza.
Manno 3.07.2026

View File

@@ -36,6 +36,11 @@ type SetPasswordParams struct {
Password string `json:"password" encore:"sensitive"`
}
type SetUserPasswordParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"`
}
// SetPassword changes the password for the authenticated user.
//
//encore:api auth method=PUT path=/auth/password
@@ -60,3 +65,28 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
}
return nil
}
// SetUserPassword changes a user's password from trusted internal flows.
//
//encore:api private method=PUT path=/auth/internal/users/:userID/password
func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordParams) error {
if p.UserID != uuid.Nil && p.UserID != userID {
return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"}
}
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")
}
res, err := db.Exec(ctx, `
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
`, userID, hash)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to update password")
}
if res.RowsAffected() == 0 {
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
}
return nil
}

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')]"
/>
<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="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,12 +11,15 @@
</q-banner>
<q-table
:style="{ height: `${profilesTableHeight}px` }"
class="sticky-header-table"
:rows="adminStore.profiles"
:columns="columns"
row-key="user_id"
:loading="adminStore.loading"
flat
bordered
ref="profilesTable"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
@@ -69,11 +72,38 @@
</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 { useAdminStore, type Profile } from '@/stores/admin-store';
import { useLayoutStore } from '@/stores/layout-store';
import { statusInfo } from '@/stores/profiles-store';
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
@@ -81,8 +111,13 @@ 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 editDialogOpen = ref(false);
const editingProfile = ref<Profile | null>(null);
@@ -144,8 +179,46 @@ function statusLabel(status: number) {
return translated === key ? t('status.unknown', { status }) : translated;
}
onMounted(() => {
function getElement(component: ComponentPublicInstance | null) {
return component?.$el instanceof HTMLElement ? component.$el : null;
}
function updateProfilesTableHeight() {
const page = getElement(profilesPage.value);
const table = getElement(profilesTable.value);
if (!page || !table) {
return;
}
const tableRect = table.getBoundingClientRect();
const pageStyle = window.getComputedStyle(page);
const pagePaddingBottom = parseFloat(pageStyle.paddingBottom) || 0;
const availableHeight = window.innerHeight - tableRect.top - pagePaddingBottom;
profilesTableHeight.value = Math.max(0, Math.floor(availableHeight));
}
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,
};
});

View File

@@ -0,0 +1 @@
ALTER TABLE personal_data ADD COLUMN cap TEXT NOT NULL DEFAULT '';

View File

@@ -18,6 +18,7 @@ type PersonalData struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
CreatedAt time.Time `json:"created_at"`
@@ -29,6 +30,7 @@ type PersonalDataParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Address string `json:"address"`
Cap string `json:"cap"`
City string `json:"city"`
Country string `json:"country"`
}
@@ -43,9 +45,9 @@ func GetPersonalData(ctx context.Context) (*PersonalData, error) {
}
pd := PersonalData{UserID: userID}
err = db.QueryRow(ctx, `
SELECT first_name, last_name, address, city, country, created_at, updated_at
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
FROM personal_data WHERE user_id = $1
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
}
@@ -68,21 +70,23 @@ func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalDa
FirstName: p.FirstName,
LastName: p.LastName,
Address: p.Address,
Cap: p.Cap,
City: p.City,
Country: p.Country,
}
err = db.QueryRow(ctx, `
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE
SET first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
address = EXCLUDED.address,
cap = EXCLUDED.cap,
city = EXCLUDED.city,
country = EXCLUDED.country,
updated_at = NOW()
RETURNING created_at, updated_at
`, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
if isForeignKeyViolation(err) {
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html>
<body>
<p>Hi {{ .Name }},</p>
<p>We received a request to reset the password for {{ .Email }}.</p>
<p>
Set a new password by opening this link:<br />
<a href="{{ .PasswordResetURL }}">{{ .PasswordResetURL }}</a>
</p>
<p>If you did not request this, you can ignore this email.</p>
</body>
</html>

View File

@@ -0,0 +1 @@
Reset your password

View File

@@ -0,0 +1,8 @@
Hi {{ .Name }},
We received a request to reset the password for {{ .Email }}.
Set a new password by opening this link:
{{ .PasswordResetURL }}
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,11 @@
CREATE TABLE password_reset_tokens (
token UUID PRIMARY KEY,
user_id UUID NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE INDEX password_reset_tokens_user_id_idx ON password_reset_tokens (user_id);
CREATE INDEX password_reset_tokens_email_idx ON password_reset_tokens (email);

View File

@@ -13,6 +13,7 @@ import (
texttemplate "text/template"
"time"
authsvc "encore.app/auth"
mailssvc "encore.app/mails"
profilessvc "encore.app/profiles"
"encore.dev/beta/errs"
@@ -31,6 +32,7 @@ var profilesDB = sqldb.Named("profiles")
const (
welcomeTokenTTL = 24 * time.Hour
passwordResetTokenTTL = time.Hour
frontendBaseURL = "http://localhost:9000/#"
)
@@ -51,6 +53,23 @@ type WelcomeResponse struct {
UsedAt time.Time `json:"used_at"`
}
type PasswordResetRequestParams struct {
Email string `json:"email"`
}
type PasswordResetRequestResponse struct {
EmailSent bool `json:"email_sent"`
}
type PasswordResetConfirmParams struct {
Password string `json:"password" encore:"sensitive"`
}
type PasswordResetConfirmResponse struct {
Email string `json:"email"`
Reset bool `json:"reset"`
}
type EmailAvailabilityResponse struct {
Email string `json:"email"`
Available bool `json:"available"`
@@ -155,6 +174,92 @@ func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, err
return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil
}
// RequestPasswordReset creates a reset token and queues an email when the account exists.
//
//encore:api public method=POST path=/registration/password-reset
func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*PasswordResetRequestResponse, error) {
email, err := normalizeEmail(p.Email)
if err != nil {
return nil, err
}
var profile profilessvc.Profile
err = profilesDB.QueryRow(ctx, `
SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at
FROM user_profiles
WHERE email = $1
`, email).Scan(
&profile.UserID,
&profile.Email,
&profile.DisplayName,
&profile.AvatarURL,
&profile.Role,
&profile.Status,
&profile.IsArtist,
&profile.CreatedAt,
&profile.UpdatedAt,
)
if errors.Is(err, sqldb.ErrNoRows) {
return &PasswordResetRequestResponse{EmailSent: true}, nil
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to load profile")
}
token, err := createPasswordResetToken(ctx, &profile)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to create password reset token")
}
mail, err := passwordResetMail(&profile, token)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to render password reset email")
}
if _, err := mailssvc.SendTransactional(ctx, mail); err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to queue password reset email")
}
return &PasswordResetRequestResponse{EmailSent: true}, nil
}
// ConfirmPasswordReset validates a reset token and changes the account password.
//
//encore:api public method=POST path=/registration/password-reset/:token
func ConfirmPasswordReset(ctx context.Context, token uuid.UUID, p *PasswordResetConfirmParams) (*PasswordResetConfirmResponse, error) {
var userID uuid.UUID
var email string
var expiresAt time.Time
var usedAt *time.Time
err := db.QueryRow(ctx, `
SELECT user_id, email, expires_at, used_at FROM password_reset_tokens WHERE token = $1
`, token).Scan(&userID, &email, &expiresAt, &usedAt)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "password reset token not found"}
}
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to load password reset token")
}
if usedAt != nil {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token already used"}
}
if time.Now().After(expiresAt) {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token has expired"}
}
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
return nil, err
}
_, err = db.Exec(ctx, `
UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1
`, token)
if err != nil {
return nil, errs.WrapCode(err, errs.Internal, "failed to consume password reset token")
}
return &PasswordResetConfirmResponse{Email: email, Reset: true}, nil
}
func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
token, err := uuid.NewV4()
if err != nil {
@@ -167,6 +272,30 @@ func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid
return token, err
}
func createPasswordResetToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
token, err := uuid.NewV4()
if err != nil {
return uuid.Nil, err
}
_, err = db.Exec(ctx, `
INSERT INTO password_reset_tokens (token, user_id, email, expires_at)
VALUES ($1, $2, $3, $4)
`, token, profile.UserID, profile.Email, time.Now().Add(passwordResetTokenTTL))
return token, err
}
func normalizeEmail(email string) (string, error) {
email = strings.TrimSpace(email)
if email == "" {
return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"}
}
parsed, err := mail.ParseAddress(email)
if err != nil {
return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
}
return parsed.Address, nil
}
func emailDomain(email string) (string, bool) {
_, domain, ok := strings.Cut(email, "@")
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
@@ -234,7 +363,52 @@ type welcomeMailData struct {
WelcomeURL string
}
func renderTextTemplate(name string, data welcomeMailData) (string, error) {
func passwordResetMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) {
name := profile.DisplayName
if name == "" {
name = profile.Email
}
data := passwordResetMailData{
Name: name,
Email: profile.Email,
UserID: profile.UserID.String(),
PasswordResetURL: frontendBaseURL + "/password-reset?token=" + token.String(),
}
subject, err := renderTextTemplate("mails/password_reset_subject.tmpl", data)
if err != nil {
return nil, err
}
textBody, err := renderTextTemplate("mails/password_reset_text.tmpl", data)
if err != nil {
return nil, err
}
htmlBody, err := renderHTMLTemplate("mails/password_reset_html.tmpl", data)
if err != nil {
return nil, err
}
return &mailssvc.TransactionalMailParams{
To: profile.Email,
Subject: subject,
TextBody: textBody,
HTMLBody: htmlBody,
Metadata: map[string]string{
"kind": "password_reset",
"user_id": profile.UserID.String(),
},
}, nil
}
type passwordResetMailData struct {
Name string
Email string
UserID string
PasswordResetURL string
}
func renderTextTemplate(name string, data any) (string, error) {
tmpl, err := texttemplate.ParseFS(mailTemplates, name)
if err != nil {
return "", err
@@ -246,7 +420,7 @@ func renderTextTemplate(name string, data welcomeMailData) (string, error) {
return strings.TrimSpace(out.String()), nil
}
func renderHTMLTemplate(name string, data welcomeMailData) (string, error) {
func renderHTMLTemplate(name string, data any) (string, error) {
tmpl, err := htmltemplate.ParseFS(mailTemplates, name)
if err != nil {
return "", err