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:
@@ -243,6 +243,7 @@ type PersonalData struct {
|
|||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -253,6 +254,7 @@ type PersonalDataParams struct {
|
|||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
}
|
}
|
||||||
@@ -263,9 +265,9 @@ type PersonalDataParams struct {
|
|||||||
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
|
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
|
||||||
pd := PersonalData{UserID: userID}
|
pd := PersonalData{UserID: userID}
|
||||||
err := profilesDB.QueryRow(ctx, `
|
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
|
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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
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,
|
FirstName: p.FirstName,
|
||||||
LastName: p.LastName,
|
LastName: p.LastName,
|
||||||
Address: p.Address,
|
Address: p.Address,
|
||||||
|
Cap: p.Cap,
|
||||||
City: p.City,
|
City: p.City,
|
||||||
Country: p.Country,
|
Country: p.Country,
|
||||||
}
|
}
|
||||||
err := profilesDB.QueryRow(ctx, `
|
err := profilesDB.QueryRow(ctx, `
|
||||||
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (user_id) DO UPDATE
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
SET first_name = EXCLUDED.first_name,
|
SET first_name = EXCLUDED.first_name,
|
||||||
last_name = EXCLUDED.last_name,
|
last_name = EXCLUDED.last_name,
|
||||||
address = EXCLUDED.address,
|
address = EXCLUDED.address,
|
||||||
|
cap = EXCLUDED.cap,
|
||||||
city = EXCLUDED.city,
|
city = EXCLUDED.city,
|
||||||
country = EXCLUDED.country,
|
country = EXCLUDED.country,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING created_at, updated_at
|
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) {
|
if isForeignKeyViolation(err) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||||
}
|
}
|
||||||
|
|||||||
12
assicurazione.md
Normal file
12
assicurazione.md
Normal 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
|
||||||
@@ -36,6 +36,11 @@ type SetPasswordParams struct {
|
|||||||
Password string `json:"password" encore:"sensitive"`
|
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.
|
// SetPassword changes the password for the authenticated user.
|
||||||
//
|
//
|
||||||
//encore:api auth method=PUT path=/auth/password
|
//encore:api auth method=PUT path=/auth/password
|
||||||
@@ -60,3 +65,28 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,7 @@
|
|||||||
dense
|
dense
|
||||||
icon="edit"
|
icon="edit"
|
||||||
:aria-label="t('actions.editProfile')"
|
:aria-label="t('actions.editProfile')"
|
||||||
to="/profile"
|
@click.stop.prevent="editProfile"
|
||||||
@click.stop
|
|
||||||
>
|
>
|
||||||
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
|
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
@@ -67,6 +66,10 @@ async function logout() {
|
|||||||
profilesStore.logout();
|
profilesStore.logout();
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function editProfile() {
|
||||||
|
await router.push({ path: '/profile', query: { mode: 'edit' } });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
23
frontend/src/components/ProfileStatusBadge.vue
Normal file
23
frontend/src/components/ProfileStatusBadge.vue
Normal 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>
|
||||||
@@ -1 +1,19 @@
|
|||||||
/* app global css */
|
/* 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export namespace admin {
|
|||||||
"first_name": string
|
"first_name": string
|
||||||
"last_name": string
|
"last_name": string
|
||||||
address: string
|
address: string
|
||||||
|
cap: string
|
||||||
city: string
|
city: string
|
||||||
country: string
|
country: string
|
||||||
"created_at": string
|
"created_at": string
|
||||||
@@ -116,6 +117,7 @@ export namespace admin {
|
|||||||
"first_name": string
|
"first_name": string
|
||||||
"last_name": string
|
"last_name": string
|
||||||
address: string
|
address: string
|
||||||
|
cap: string
|
||||||
city: string
|
city: string
|
||||||
country: string
|
country: string
|
||||||
}
|
}
|
||||||
@@ -388,6 +390,7 @@ export namespace profiles {
|
|||||||
"first_name": string
|
"first_name": string
|
||||||
"last_name": string
|
"last_name": string
|
||||||
address: string
|
address: string
|
||||||
|
cap: string
|
||||||
city: string
|
city: string
|
||||||
country: string
|
country: string
|
||||||
"created_at": string
|
"created_at": string
|
||||||
@@ -401,6 +404,7 @@ export namespace profiles {
|
|||||||
"first_name": string
|
"first_name": string
|
||||||
"last_name": string
|
"last_name": string
|
||||||
address: string
|
address: string
|
||||||
|
cap: string
|
||||||
city: string
|
city: string
|
||||||
country: string
|
country: string
|
||||||
}
|
}
|
||||||
@@ -572,6 +576,23 @@ export namespace registration {
|
|||||||
profile: profiles.Profile
|
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 {
|
export interface WelcomeResponse {
|
||||||
email: string
|
email: string
|
||||||
confirmed: boolean
|
confirmed: boolean
|
||||||
@@ -584,8 +605,10 @@ export namespace registration {
|
|||||||
constructor(baseClient: BaseClient) {
|
constructor(baseClient: BaseClient) {
|
||||||
this.baseClient = baseClient
|
this.baseClient = baseClient
|
||||||
this.CheckEmail = this.CheckEmail.bind(this)
|
this.CheckEmail = this.CheckEmail.bind(this)
|
||||||
|
this.ConfirmPasswordReset = this.ConfirmPasswordReset.bind(this)
|
||||||
this.ConfirmWelcome = this.ConfirmWelcome.bind(this)
|
this.ConfirmWelcome = this.ConfirmWelcome.bind(this)
|
||||||
this.Register = this.Register.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
|
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.
|
* ConfirmWelcome validates the welcome email token.
|
||||||
*/
|
*/
|
||||||
@@ -614,6 +646,15 @@ export namespace registration {
|
|||||||
const resp = await this.baseClient.callTypedAPI("POST", `/registration`, JSON.stringify(params))
|
const resp = await this.baseClient.callTypedAPI("POST", `/registration`, JSON.stringify(params))
|
||||||
return await resp.json() as RegisterResponse
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const AdminPersonalDataParamsSchema = z.object({
|
|||||||
first_name: z.string().min(2).max(32),
|
first_name: z.string().min(2).max(32),
|
||||||
last_name: z.string().min(2).max(32),
|
last_name: z.string().min(2).max(32),
|
||||||
address: z.string().min(5).max(32),
|
address: z.string().min(5).max(32),
|
||||||
|
cap: z.string(),
|
||||||
city: z.string().min(2).max(32),
|
city: z.string().min(2).max(32),
|
||||||
country: z.string(),
|
country: z.string(),
|
||||||
});
|
});
|
||||||
@@ -47,6 +48,7 @@ export const ProfilesPersonalDataParamsSchema = z.object({
|
|||||||
first_name: z.string(),
|
first_name: z.string(),
|
||||||
last_name: z.string(),
|
last_name: z.string(),
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
|
cap: z.string(),
|
||||||
city: z.string(),
|
city: z.string(),
|
||||||
country: z.string(),
|
country: z.string(),
|
||||||
});
|
});
|
||||||
@@ -69,3 +71,11 @@ export const RegistrationRegisterParamsSchema = z.object({
|
|||||||
avatar_url: z.string(),
|
avatar_url: z.string(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const RegistrationPasswordResetConfirmParamsSchema = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RegistrationPasswordResetRequestParamsSchema = z.object({
|
||||||
|
email: z.string(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const messages = {
|
|||||||
firstName: 'First name',
|
firstName: 'First name',
|
||||||
lastName: 'Last name',
|
lastName: 'Last name',
|
||||||
address: 'Address',
|
address: 'Address',
|
||||||
|
cap: 'Postcode',
|
||||||
city: 'City',
|
city: 'City',
|
||||||
country: 'Country',
|
country: 'Country',
|
||||||
role: 'Role',
|
role: 'Role',
|
||||||
@@ -81,6 +82,16 @@ const messages = {
|
|||||||
login: {
|
login: {
|
||||||
title: 'Log in',
|
title: 'Log in',
|
||||||
needAccount: 'Create an account',
|
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: {
|
register: {
|
||||||
title: 'Create account',
|
title: 'Create account',
|
||||||
@@ -151,6 +162,7 @@ const messages = {
|
|||||||
firstName: 'Nome',
|
firstName: 'Nome',
|
||||||
lastName: 'Cognome',
|
lastName: 'Cognome',
|
||||||
address: 'Indirizzo',
|
address: 'Indirizzo',
|
||||||
|
cap: 'CAP',
|
||||||
city: 'Città',
|
city: 'Città',
|
||||||
country: 'Paese',
|
country: 'Paese',
|
||||||
role: 'Ruolo',
|
role: 'Ruolo',
|
||||||
@@ -184,6 +196,16 @@ const messages = {
|
|||||||
login: {
|
login: {
|
||||||
title: 'Accedi',
|
title: 'Accedi',
|
||||||
needAccount: 'Crea un account',
|
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 l’email esiste, abbiamo inviato un link per reimpostare la password.',
|
||||||
|
resetDone: 'Password aggiornata. Ora puoi accedere.',
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: 'Crea account',
|
title: 'Crea account',
|
||||||
@@ -254,6 +276,7 @@ const messages = {
|
|||||||
firstName: 'Prénom',
|
firstName: 'Prénom',
|
||||||
lastName: 'Nom',
|
lastName: 'Nom',
|
||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
|
cap: 'Code postal',
|
||||||
city: 'Ville',
|
city: 'Ville',
|
||||||
country: 'Pays',
|
country: 'Pays',
|
||||||
role: 'Rôle',
|
role: 'Rôle',
|
||||||
@@ -287,6 +310,16 @@ const messages = {
|
|||||||
login: {
|
login: {
|
||||||
title: 'Connexion',
|
title: 'Connexion',
|
||||||
needAccount: 'Créer un compte',
|
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 l’email existe, nous avons envoyé un lien de réinitialisation.',
|
||||||
|
resetDone: 'Mot de passe mis à jour. Vous pouvez maintenant vous connecter.',
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: 'Créer un compte',
|
title: 'Créer un compte',
|
||||||
@@ -357,6 +390,7 @@ const messages = {
|
|||||||
firstName: 'Vorname',
|
firstName: 'Vorname',
|
||||||
lastName: 'Nachname',
|
lastName: 'Nachname',
|
||||||
address: 'Adresse',
|
address: 'Adresse',
|
||||||
|
cap: 'Postleitzahl',
|
||||||
city: 'Stadt',
|
city: 'Stadt',
|
||||||
country: 'Land',
|
country: 'Land',
|
||||||
role: 'Rolle',
|
role: 'Rolle',
|
||||||
@@ -390,6 +424,16 @@ const messages = {
|
|||||||
login: {
|
login: {
|
||||||
title: 'Anmelden',
|
title: 'Anmelden',
|
||||||
needAccount: 'Konto erstellen',
|
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: {
|
register: {
|
||||||
title: 'Konto erstellen',
|
title: 'Konto erstellen',
|
||||||
@@ -460,6 +504,7 @@ const messages = {
|
|||||||
firstName: 'Nombre',
|
firstName: 'Nombre',
|
||||||
lastName: 'Apellido',
|
lastName: 'Apellido',
|
||||||
address: 'Dirección',
|
address: 'Dirección',
|
||||||
|
cap: 'Código postal',
|
||||||
city: 'Ciudad',
|
city: 'Ciudad',
|
||||||
country: 'País',
|
country: 'País',
|
||||||
role: 'Rol',
|
role: 'Rol',
|
||||||
@@ -493,6 +538,16 @@ const messages = {
|
|||||||
login: {
|
login: {
|
||||||
title: 'Iniciar sesión',
|
title: 'Iniciar sesión',
|
||||||
needAccount: 'Crear una cuenta',
|
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: {
|
register: {
|
||||||
title: 'Crear cuenta',
|
title: 'Crear cuenta',
|
||||||
|
|||||||
@@ -16,16 +16,34 @@
|
|||||||
:label="t('fields.email')"
|
:label="t('fields.email')"
|
||||||
type="email"
|
type="email"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
|
outlined
|
||||||
:rules="[(val) => !!val || t('fields.email')]"
|
: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
|
<q-input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:label="t('fields.password')"
|
:label="t('fields.password')"
|
||||||
type="password"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
outlined
|
||||||
:rules="[(val) => !!val || t('fields.password')]"
|
: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>
|
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
|
||||||
{{ store.error }}
|
{{ store.error }}
|
||||||
@@ -57,6 +75,7 @@ import { useProfilesStore } from '@/stores/profiles-store';
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const showPassword = ref(false);
|
||||||
|
|
||||||
const store = useProfilesStore();
|
const store = useProfilesStore();
|
||||||
const router = useRouter();
|
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 }}
|
{{ profilesStore.error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 480px">
|
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 560px">
|
||||||
<q-card-section class="q-gutter-md">
|
<q-card-section class="row items-center q-gutter-sm">
|
||||||
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
<q-avatar size="48px">
|
||||||
|
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" />
|
||||||
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
|
<q-icon v-else name="person" />
|
||||||
|
</q-avatar>
|
||||||
<div class="text-caption text-grey">
|
<div>
|
||||||
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
|
<div class="text-subtitle1">{{ profilesStore.profile.email }}</div>
|
||||||
|
<div class="text-subtitle1">{{ displayValue(profileForm.display_name) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</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-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -29,34 +167,185 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 AvatarUpload from '@/components/AvatarUpload.vue';
|
||||||
|
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const profilesStore = useProfilesStore();
|
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: '',
|
display_name: '',
|
||||||
avatar_url: '',
|
avatar_url: '',
|
||||||
});
|
});
|
||||||
|
const profileFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||||
|
|
||||||
function syncForm(profile: Profile | null) {
|
const personalForm = reactive<PersonalDataParams>({
|
||||||
form.display_name = profile?.display_name ?? '';
|
first_name: '',
|
||||||
form.avatar_url = profile?.avatar_url ?? '';
|
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() {
|
async function save() {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// profilesStore.error already holds the failure message
|
// 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 () => {
|
onMounted(async () => {
|
||||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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="row items-center q-mb-md">
|
||||||
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
@@ -11,16 +11,20 @@
|
|||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-table
|
<q-table
|
||||||
|
:style="{ height: `${profilesTableHeight}px` }"
|
||||||
|
class="sticky-header-table"
|
||||||
:rows="adminStore.profiles"
|
:rows="adminStore.profiles"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
v-model:pagination="pagination"
|
||||||
row-key="user_id"
|
row-key="user_id"
|
||||||
:loading="adminStore.loading"
|
:loading="adminStore.loading"
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
|
ref="profilesTable"
|
||||||
>
|
>
|
||||||
<template v-slot:body-cell-status="props">
|
<template v-slot:body-cell-status="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
|
<ProfileStatusBadge :status="props.value" />
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -69,20 +73,55 @@
|
|||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</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">
|
<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 { useI18n } from 'vue-i18n';
|
||||||
import type { QTableColumn } from 'quasar';
|
import type { QTableColumn } from 'quasar';
|
||||||
|
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
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 CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||||
|
|
||||||
const adminStore = useAdminStore();
|
const adminStore = useAdminStore();
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
const { t } = useI18n();
|
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 editDialogOpen = ref(false);
|
||||||
const editingProfile = ref<Profile | null>(null);
|
const editingProfile = ref<Profile | null>(null);
|
||||||
|
|
||||||
@@ -138,14 +177,49 @@ const columns = computed<QTableColumn[]>(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function statusLabel(status: number) {
|
function getElement(component: ComponentPublicInstance | null) {
|
||||||
const key = `status.${status}`;
|
return component?.$el instanceof HTMLElement ? component.$el : null;
|
||||||
const translated = t(key);
|
|
||||||
return translated === key ? t('status.unknown', { status }) : translated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.listProfiles();
|
||||||
void adminStore.fetchSystemOptions();
|
void adminStore.fetchSystemOptions();
|
||||||
|
layoutStore.startBodyResizeListener();
|
||||||
|
await updateProfilesTableHeightAfterRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [adminStore.error, layoutStore.bodyWidth, layoutStore.bodyHeight],
|
||||||
|
() => {
|
||||||
|
void updateProfilesTableHeightAfterRender();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
layoutStore.stopBodyResizeListener();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
:error="Boolean(personalFormErrors.address)"
|
:error="Boolean(personalFormErrors.address)"
|
||||||
:error-message="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
|
<q-input
|
||||||
v-model="personalForm.city"
|
v-model="personalForm.city"
|
||||||
:label="t('fields.city')"
|
:label="t('fields.city')"
|
||||||
@@ -127,6 +133,7 @@ const personalForm = reactive<PersonalDataParams>({
|
|||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
cap: '',
|
||||||
city: '',
|
city: '',
|
||||||
country: '',
|
country: '',
|
||||||
});
|
});
|
||||||
@@ -196,6 +203,7 @@ function resetPersonalForm(data?: PersonalDataParams) {
|
|||||||
personalForm.first_name = data?.first_name ?? '';
|
personalForm.first_name = data?.first_name ?? '';
|
||||||
personalForm.last_name = data?.last_name ?? '';
|
personalForm.last_name = data?.last_name ?? '';
|
||||||
personalForm.address = data?.address ?? '';
|
personalForm.address = data?.address ?? '';
|
||||||
|
personalForm.cap = data?.cap ?? '';
|
||||||
personalForm.city = data?.city ?? '';
|
personalForm.city = data?.city ?? '';
|
||||||
personalForm.country = data?.country ?? '';
|
personalForm.country = data?.country ?? '';
|
||||||
clearPersonalFormErrors();
|
clearPersonalFormErrors();
|
||||||
@@ -234,6 +242,7 @@ function clearPersonalFormErrors() {
|
|||||||
personalFormErrors.first_name = '';
|
personalFormErrors.first_name = '';
|
||||||
personalFormErrors.last_name = '';
|
personalFormErrors.last_name = '';
|
||||||
personalFormErrors.address = '';
|
personalFormErrors.address = '';
|
||||||
|
personalFormErrors.cap = '';
|
||||||
personalFormErrors.city = '';
|
personalFormErrors.city = '';
|
||||||
personalFormErrors.country = '';
|
personalFormErrors.country = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
||||||
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
|
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
|
||||||
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
|
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
|
||||||
|
{ path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') },
|
||||||
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
|
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
|
||||||
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
|
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
|
||||||
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||||
|
|||||||
62
frontend/src/stores/layout-store.ts
Normal file
62
frontend/src/stores/layout-store.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
1
profiles/migrations/10_add_personal_data_cap.up.sql
Normal file
1
profiles/migrations/10_add_personal_data_cap.up.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE personal_data ADD COLUMN cap TEXT NOT NULL DEFAULT '';
|
||||||
@@ -18,6 +18,7 @@ type PersonalData struct {
|
|||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -29,6 +30,7 @@ type PersonalDataParams struct {
|
|||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LastName string `json:"last_name"`
|
LastName string `json:"last_name"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
|
Cap string `json:"cap"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
}
|
}
|
||||||
@@ -43,9 +45,9 @@ func GetPersonalData(ctx context.Context) (*PersonalData, error) {
|
|||||||
}
|
}
|
||||||
pd := PersonalData{UserID: userID}
|
pd := PersonalData{UserID: userID}
|
||||||
err = db.QueryRow(ctx, `
|
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
|
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) {
|
if errors.Is(err, sqldb.ErrNoRows) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
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,
|
FirstName: p.FirstName,
|
||||||
LastName: p.LastName,
|
LastName: p.LastName,
|
||||||
Address: p.Address,
|
Address: p.Address,
|
||||||
|
Cap: p.Cap,
|
||||||
City: p.City,
|
City: p.City,
|
||||||
Country: p.Country,
|
Country: p.Country,
|
||||||
}
|
}
|
||||||
err = db.QueryRow(ctx, `
|
err = db.QueryRow(ctx, `
|
||||||
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (user_id) DO UPDATE
|
ON CONFLICT (user_id) DO UPDATE
|
||||||
SET first_name = EXCLUDED.first_name,
|
SET first_name = EXCLUDED.first_name,
|
||||||
last_name = EXCLUDED.last_name,
|
last_name = EXCLUDED.last_name,
|
||||||
address = EXCLUDED.address,
|
address = EXCLUDED.address,
|
||||||
|
cap = EXCLUDED.cap,
|
||||||
city = EXCLUDED.city,
|
city = EXCLUDED.city,
|
||||||
country = EXCLUDED.country,
|
country = EXCLUDED.country,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING created_at, updated_at
|
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) {
|
if isForeignKeyViolation(err) {
|
||||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||||
}
|
}
|
||||||
|
|||||||
12
registration/mails/password_reset_html.tmpl
Normal file
12
registration/mails/password_reset_html.tmpl
Normal 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>
|
||||||
1
registration/mails/password_reset_subject.tmpl
Normal file
1
registration/mails/password_reset_subject.tmpl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Reset your password
|
||||||
8
registration/mails/password_reset_text.tmpl
Normal file
8
registration/mails/password_reset_text.tmpl
Normal 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.
|
||||||
@@ -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);
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
texttemplate "text/template"
|
texttemplate "text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
authsvc "encore.app/auth"
|
||||||
mailssvc "encore.app/mails"
|
mailssvc "encore.app/mails"
|
||||||
profilessvc "encore.app/profiles"
|
profilessvc "encore.app/profiles"
|
||||||
"encore.dev/beta/errs"
|
"encore.dev/beta/errs"
|
||||||
@@ -31,6 +32,7 @@ var profilesDB = sqldb.Named("profiles")
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
welcomeTokenTTL = 24 * time.Hour
|
welcomeTokenTTL = 24 * time.Hour
|
||||||
|
passwordResetTokenTTL = time.Hour
|
||||||
frontendBaseURL = "http://localhost:9000/#"
|
frontendBaseURL = "http://localhost:9000/#"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +53,23 @@ type WelcomeResponse struct {
|
|||||||
UsedAt time.Time `json:"used_at"`
|
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 {
|
type EmailAvailabilityResponse struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Available bool `json:"available"`
|
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
|
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) {
|
func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
|
||||||
token, err := uuid.NewV4()
|
token, err := uuid.NewV4()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,6 +272,30 @@ func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid
|
|||||||
return token, err
|
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) {
|
func emailDomain(email string) (string, bool) {
|
||||||
_, domain, ok := strings.Cut(email, "@")
|
_, domain, ok := strings.Cut(email, "@")
|
||||||
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
|
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
|
||||||
@@ -234,7 +363,52 @@ type welcomeMailData struct {
|
|||||||
WelcomeURL string
|
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)
|
tmpl, err := texttemplate.ParseFS(mailTemplates, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -246,7 +420,7 @@ func renderTextTemplate(name string, data welcomeMailData) (string, error) {
|
|||||||
return strings.TrimSpace(out.String()), nil
|
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)
|
tmpl, err := htmltemplate.ParseFS(mailTemplates, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
Reference in New Issue
Block a user