Compare commits

...

4 Commits

Author SHA1 Message Date
fabio
cd858b0d8c feat: add notifications for profile updates, personal data changes, and password updates 2026-07-26 17:56:33 +02:00
fabio
7a2b80381d feat: add password update functionality for user profiles 2026-07-26 17:48:17 +02:00
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
30 changed files with 1425 additions and 46 deletions

View File

@@ -198,6 +198,20 @@ func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRo
return &profile, nil return &profile, nil
} }
type UpdateProfilePasswordParams struct {
Password string `json:"password" encore:"sensitive"`
}
// UpdateProfilePassword sets the password of any user's profile.
//
//encore:api auth method=PUT path=/admin/profiles/:userID/password
func UpdateProfilePassword(ctx context.Context, userID uuid.UUID, p *UpdateProfilePasswordParams) error {
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
return err
}
return nil
}
type UpdateProfileArtistParams struct { type UpdateProfileArtistParams struct {
IsArtist bool `json:"is_artist"` IsArtist bool `json:"is_artist"`
} }
@@ -243,6 +257,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 +268,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 +279,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 +300,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
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

@@ -2,10 +2,13 @@ package auth
import ( import (
"context" "context"
"errors"
"encore.dev/beta/auth" "encore.dev/beta/auth"
"encore.dev/beta/errs" "encore.dev/beta/errs"
"encore.dev/storage/sqldb"
"encore.dev/types/uuid" "encore.dev/types/uuid"
"golang.org/x/crypto/bcrypt"
) )
type RegisterParams struct { type RegisterParams struct {
@@ -33,6 +36,12 @@ func Register(ctx context.Context, p *RegisterParams) error {
} }
type SetPasswordParams struct { type SetPasswordParams struct {
CurrentPassword string `json:"current_password" encore:"sensitive"`
Password string `json:"password" encore:"sensitive"`
}
type SetUserPasswordParams struct {
UserID uuid.UUID `json:"user_id"`
Password string `json:"password" encore:"sensitive"` Password string `json:"password" encore:"sensitive"`
} }
@@ -48,6 +57,21 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
if err != nil { if err != nil {
return errs.WrapCode(err, errs.Internal, "invalid user id") return errs.WrapCode(err, errs.Internal, "invalid user id")
} }
var currentHash string
err = db.QueryRow(ctx, `
SELECT password_hash FROM credentials WHERE user_id = $1
`, userID).Scan(&currentHash)
if errors.Is(err, sqldb.ErrNoRows) {
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
}
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), pepperedPassword(p.CurrentPassword)); err != nil {
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
}
hash, err := hashPassword(p.Password) hash, err := hashPassword(p.Password)
if err != nil { if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password") return errs.WrapCode(err, errs.Internal, "failed to hash password")
@@ -60,3 +84,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
}

View File

@@ -99,7 +99,7 @@ export default defineConfig((/* ctx */) => {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [] plugins: ['Notify']
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations

View File

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

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 */ /* 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 "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
} }
@@ -153,6 +155,10 @@ export namespace admin {
"is_artist": boolean "is_artist": boolean
} }
export interface UpdateProfilePasswordParams {
password: string
}
export interface UpdateProfileRoleParams { export interface UpdateProfileRoleParams {
role: string role: string
} }
@@ -174,6 +180,7 @@ export namespace admin {
this.ListProfiles = this.ListProfiles.bind(this) this.ListProfiles = this.ListProfiles.bind(this)
this.UpdateProfile = this.UpdateProfile.bind(this) this.UpdateProfile = this.UpdateProfile.bind(this)
this.UpdateProfileArtist = this.UpdateProfileArtist.bind(this) this.UpdateProfileArtist = this.UpdateProfileArtist.bind(this)
this.UpdateProfilePassword = this.UpdateProfilePassword.bind(this)
this.UpdateProfileRole = this.UpdateProfileRole.bind(this) this.UpdateProfileRole = this.UpdateProfileRole.bind(this)
this.UpdateProfileStatus = this.UpdateProfileStatus.bind(this) this.UpdateProfileStatus = this.UpdateProfileStatus.bind(this)
this.UpsertPersonalData = this.UpsertPersonalData.bind(this) this.UpsertPersonalData = this.UpsertPersonalData.bind(this)
@@ -251,6 +258,13 @@ export namespace admin {
return await resp.json() as Profile return await resp.json() as Profile
} }
/**
* UpdateProfilePassword sets the password of any user's profile.
*/
public async UpdateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
await this.baseClient.callTypedAPI("PUT", `/admin/profiles/${encodeURIComponent(userID)}/password`, JSON.stringify(params))
}
/** /**
* UpdateProfileRole sets the role of any user's profile. * UpdateProfileRole sets the role of any user's profile.
*/ */
@@ -297,6 +311,7 @@ export namespace auth {
} }
export interface SetPasswordParams { export interface SetPasswordParams {
"current_password": string
password: string password: string
} }
@@ -388,6 +403,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 +417,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 +589,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 +618,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 +633,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 +659,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
}
} }
} }

View File

@@ -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(),
}); });
@@ -26,6 +27,10 @@ export const AdminUpdateProfileArtistParamsSchema = z.object({
is_artist: z.boolean(), is_artist: z.boolean(),
}); });
export const AdminUpdateProfilePasswordParamsSchema = z.object({
password: z.string(),
});
export const AdminUpdateProfileRoleParamsSchema = z.object({ export const AdminUpdateProfileRoleParamsSchema = z.object({
role: z.string(), role: z.string(),
}); });
@@ -35,6 +40,7 @@ export const AdminUpdateProfileStatusParamsSchema = z.object({
}); });
export const AuthSetPasswordParamsSchema = z.object({ export const AuthSetPasswordParamsSchema = z.object({
current_password: z.string(),
password: z.string(), password: z.string(),
}); });
@@ -47,6 +53,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 +76,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(),
});

View File

@@ -40,6 +40,7 @@ const messages = {
editProfile: 'Edit profile', editProfile: 'Edit profile',
updateRole: 'Update role', updateRole: 'Update role',
updateStatus: 'Update status', updateStatus: 'Update status',
updatePassword: 'Update password',
}, },
fields: { fields: {
email: 'Email', email: 'Email',
@@ -48,6 +49,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',
@@ -73,14 +75,38 @@ const messages = {
created: 'Created', created: 'Created',
updated: 'Updated', updated: 'Updated',
}, },
notifications: {
profileCreated: 'Profile created.',
profileUpdated: 'Profile updated.',
personalDataUpdated: 'Personal data updated.',
roleUpdated: 'Role updated.',
statusUpdated: 'Status updated.',
passwordUpdated: 'Password updated.',
},
}, },
profile: { profile: {
title: 'My profile', title: 'My profile',
avatar: 'Avatar',
password: 'Password',
currentPassword: 'Current password',
newPassword: 'New password',
confirmNewPassword: 'Confirm new password',
passwordMismatch: 'Passwords do not match.',
loginRequired: 'You must be logged in to view your profile.', loginRequired: 'You must be logged in to view your profile.',
}, },
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',
@@ -143,6 +169,7 @@ const messages = {
editProfile: 'Modifica profilo', editProfile: 'Modifica profilo',
updateRole: 'Aggiorna ruolo', updateRole: 'Aggiorna ruolo',
updateStatus: 'Aggiorna stato', updateStatus: 'Aggiorna stato',
updatePassword: 'Aggiorna password',
}, },
fields: { fields: {
email: 'Email', email: 'Email',
@@ -151,6 +178,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',
@@ -176,14 +204,38 @@ const messages = {
created: 'Creato', created: 'Creato',
updated: 'Aggiornato', updated: 'Aggiornato',
}, },
notifications: {
profileCreated: 'Profilo creato.',
profileUpdated: 'Profilo aggiornato.',
personalDataUpdated: 'Dati personali aggiornati.',
roleUpdated: 'Ruolo aggiornato.',
statusUpdated: 'Stato aggiornato.',
passwordUpdated: 'Password aggiornata.',
},
}, },
profile: { profile: {
title: 'Il mio profilo', title: 'Il mio profilo',
avatar: 'Avatar',
password: 'Password',
currentPassword: 'Password corrente',
newPassword: 'Nuova password',
confirmNewPassword: 'Conferma nuova password',
passwordMismatch: 'Le password non coincidono.',
loginRequired: 'Devi effettuare laccesso per visualizzare il profilo.', loginRequired: 'Devi effettuare laccesso per visualizzare il profilo.',
}, },
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 lemail esiste, abbiamo inviato un link per reimpostare la password.',
resetDone: 'Password aggiornata. Ora puoi accedere.',
}, },
register: { register: {
title: 'Crea account', title: 'Crea account',
@@ -246,6 +298,7 @@ const messages = {
editProfile: 'Modifier le profil', editProfile: 'Modifier le profil',
updateRole: 'Modifier le rôle', updateRole: 'Modifier le rôle',
updateStatus: 'Modifier le statut', updateStatus: 'Modifier le statut',
updatePassword: 'Modifier le mot de passe',
}, },
fields: { fields: {
email: 'Email', email: 'Email',
@@ -254,6 +307,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',
@@ -279,14 +333,38 @@ const messages = {
created: 'Créé', created: 'Créé',
updated: 'Mis à jour', updated: 'Mis à jour',
}, },
notifications: {
profileCreated: 'Profil créé.',
profileUpdated: 'Profil mis à jour.',
personalDataUpdated: 'Données personnelles mises à jour.',
roleUpdated: 'Rôle mis à jour.',
statusUpdated: 'Statut mis à jour.',
passwordUpdated: 'Mot de passe mis à jour.',
},
}, },
profile: { profile: {
title: 'Mon profil', title: 'Mon profil',
avatar: 'Avatar',
password: 'Mot de passe',
currentPassword: 'Mot de passe actuel',
newPassword: 'Nouveau mot de passe',
confirmNewPassword: 'Confirmer le nouveau mot de passe',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
loginRequired: 'Vous devez être connecté pour voir votre profil.', loginRequired: 'Vous devez être connecté pour voir votre profil.',
}, },
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 lemail 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',
@@ -349,6 +427,7 @@ const messages = {
editProfile: 'Profil bearbeiten', editProfile: 'Profil bearbeiten',
updateRole: 'Rolle ändern', updateRole: 'Rolle ändern',
updateStatus: 'Status ändern', updateStatus: 'Status ändern',
updatePassword: 'Passwort ändern',
}, },
fields: { fields: {
email: 'E-Mail', email: 'E-Mail',
@@ -357,6 +436,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',
@@ -382,14 +462,38 @@ const messages = {
created: 'Erstellt', created: 'Erstellt',
updated: 'Aktualisiert', updated: 'Aktualisiert',
}, },
notifications: {
profileCreated: 'Profil erstellt.',
profileUpdated: 'Profil aktualisiert.',
personalDataUpdated: 'Persönliche Daten aktualisiert.',
roleUpdated: 'Rolle aktualisiert.',
statusUpdated: 'Status aktualisiert.',
passwordUpdated: 'Passwort aktualisiert.',
},
}, },
profile: { profile: {
title: 'Mein Profil', title: 'Mein Profil',
avatar: 'Avatar',
password: 'Passwort',
currentPassword: 'Aktuelles Passwort',
newPassword: 'Neues Passwort',
confirmNewPassword: 'Neues Passwort bestätigen',
passwordMismatch: 'Die Passwörter stimmen nicht überein.',
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.', loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
}, },
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',
@@ -452,6 +556,7 @@ const messages = {
editProfile: 'Editar perfil', editProfile: 'Editar perfil',
updateRole: 'Actualizar rol', updateRole: 'Actualizar rol',
updateStatus: 'Actualizar estado', updateStatus: 'Actualizar estado',
updatePassword: 'Actualizar contraseña',
}, },
fields: { fields: {
email: 'Email', email: 'Email',
@@ -460,6 +565,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',
@@ -485,14 +591,38 @@ const messages = {
created: 'Creado', created: 'Creado',
updated: 'Actualizado', updated: 'Actualizado',
}, },
notifications: {
profileCreated: 'Perfil creado.',
profileUpdated: 'Perfil actualizado.',
personalDataUpdated: 'Datos personales actualizados.',
roleUpdated: 'Rol actualizado.',
statusUpdated: 'Estado actualizado.',
passwordUpdated: 'Contraseña actualizada.',
},
}, },
profile: { profile: {
title: 'Mi perfil', title: 'Mi perfil',
avatar: 'Avatar',
password: 'Contraseña',
currentPassword: 'Contraseña actual',
newPassword: 'Nueva contraseña',
confirmNewPassword: 'Confirmar nueva contraseña',
passwordMismatch: 'Las contraseñas no coinciden.',
loginRequired: 'Debes iniciar sesión para ver tu perfil.', loginRequired: 'Debes iniciar sesión para ver tu perfil.',
}, },
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',

View File

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

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,215 @@
{{ 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 v-if="isEditMode" name="avatar" :label="t('profile.avatar')" />
<q-tab name="profile" :label="t('admin.profile')" />
<q-tab name="personal" :label="t('admin.personalData')" />
<q-tab v-if="isEditMode" name="password" :label="t('profile.password')" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="profileTab" animated>
<q-tab-panel v-if="isEditMode" name="avatar" class="q-gutter-md">
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
{{ profileFormErrors.avatar_url }}
</div>
</q-tab-panel>
<q-tab-panel name="profile" class="q-gutter-md">
<q-list 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-panel v-if="isEditMode" name="password" class="q-gutter-md">
<q-input
ref="currentPasswordInputRef"
v-model="passwordForm.current_password"
:label="t('profile.currentPassword')"
:type="showCurrentPassword ? 'text' : 'password'"
autocomplete="current-password"
:error="Boolean(passwordFormErrors.current_password)"
:error-message="passwordFormErrors.current_password"
outlined
>
<template v-slot:append>
<q-icon
:name="showCurrentPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showCurrentPassword = !showCurrentPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordForm.password"
:label="t('profile.newPassword')"
:type="showNewPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordFormErrors.password)"
:error-message="passwordFormErrors.password"
outlined
>
<template v-slot:append>
<q-icon
:name="showNewPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showNewPassword = !showNewPassword"
/>
</template>
</q-input>
<q-input
v-model="passwordConfirmation"
:label="t('profile.confirmNewPassword')"
:type="showPasswordConfirmation ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordConfirmationError)"
:error-message="passwordConfirmationError"
outlined
>
<template v-slot:append>
<q-icon
:name="showPasswordConfirmation ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPasswordConfirmation = !showPasswordConfirmation"
/>
</template>
</q-input>
</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 +226,250 @@
</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 { Notify } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { countries } from '@/data/countries';
import { AuthSetPasswordParamsSchema, ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
import {
useProfilesStore,
type PersonalDataParams,
type Profile,
type ProfileParams,
type SetPasswordParams,
} 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 = 'avatar' | 'profile' | 'personal' | 'password';
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 currentPasswordInputRef = 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>>>({});
const passwordForm = reactive<SetPasswordParams>({
current_password: '',
password: '',
});
const passwordFormErrors = reactive<Partial<Record<keyof SetPasswordParams, string>>>({});
const passwordConfirmation = ref('');
const passwordConfirmationError = ref('');
const showCurrentPassword = ref(false);
const showNewPassword = ref(false);
const showPasswordConfirmation = ref(false);
watch(
() => profilesStore.profile,
(profile) => {
if (profile) {
void loadProfile(profile);
}
},
{ immediate: true },
);
watch(profileTab, () => {
if (isEditMode.value) {
void focusFirstField();
}
});
watch(isEditMode, (editing) => {
if (editing) {
profileTab.value = 'avatar';
resetPasswordForm();
void focusFirstField();
} else if (profilesStore.profile) {
profileTab.value = profileTab.value === 'avatar' || profileTab.value === 'password' ? 'profile' : profileTab.value;
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 === 'avatar') {
const parsed = validateProfileForm();
if (!parsed) return;
await profilesStore.update(parsed);
} else if (profileTab.value === 'personal') {
const parsed = validatePersonalForm();
if (!parsed) return;
await profilesStore.savePersonalData(parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
} else if (profileTab.value === 'password') {
const parsed = validatePasswordForm();
if (!parsed) return;
await profilesStore.setPassword(parsed);
resetPasswordForm();
}
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 validatePasswordForm(): SetPasswordParams | null {
clearPasswordFormErrors();
const result = AuthSetPasswordParamsSchema.safeParse({ ...passwordForm });
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors;
for (const key of Object.keys(fieldErrors) as (keyof SetPasswordParams)[]) {
passwordFormErrors[key] = fieldErrors[key]?.[0] ?? '';
}
return null;
}
if (passwordForm.password !== passwordConfirmation.value) {
passwordConfirmationError.value = t('profile.passwordMismatch');
return null;
}
return result.data;
}
function clearPasswordFormErrors() {
passwordFormErrors.current_password = '';
passwordFormErrors.password = '';
passwordConfirmationError.value = '';
}
function resetPasswordForm() {
passwordForm.current_password = '';
passwordForm.password = '';
passwordConfirmation.value = '';
showCurrentPassword.value = false;
showNewPassword.value = false;
showPasswordConfirmation.value = false;
clearPasswordFormErrors();
}
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();
} else if (profileTab.value === 'password') {
currentPasswordInputRef.value?.focus();
}
}
onMounted(async () => { onMounted(async () => {
if (profilesStore.isAuthenticated && !profilesStore.profile) { if (profilesStore.isAuthenticated && !profilesStore.profile) {
try { try {

View File

@@ -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,12 +11,15 @@
</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"
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">
@@ -55,6 +58,9 @@
<q-item v-close-popup clickable @click="openStatusEdit(props.row)"> <q-item v-close-popup clickable @click="openStatusEdit(props.row)">
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section> <q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
</q-item> </q-item>
<q-item v-close-popup clickable @click="openPasswordEdit(props.row)">
<q-item-section>{{ t('actions.updatePassword') }}</q-item-section>
</q-item>
</q-list> </q-list>
</q-menu> </q-menu>
</q-btn> </q-btn>
@@ -65,24 +71,58 @@
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" /> <EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" /> <UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" /> <UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
<UpdatePasswordDialog v-model="passwordDialogOpen" :profile="passwordEditingProfile" />
<CreateProfileDialog v-model="createDialogOpen" /> <CreateProfileDialog v-model="createDialogOpen" />
</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 { useAdminStore, type Profile } from '@/stores/admin-store'; import { useAdminStore, type Profile } from '@/stores/admin-store';
import { useLayoutStore } from '@/stores/layout-store';
import { statusInfo } from '@/stores/profiles-store'; import { statusInfo } from '@/stores/profiles-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 UpdatePasswordDialog from './dialogs/UpdatePasswordDialog.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 editDialogOpen = ref(false); const editDialogOpen = ref(false);
const editingProfile = ref<Profile | null>(null); const editingProfile = ref<Profile | null>(null);
@@ -113,6 +153,14 @@ function openStatusEdit(profile: Profile) {
statusDialogOpen.value = true; statusDialogOpen.value = true;
} }
const passwordDialogOpen = ref(false);
const passwordEditingProfile = ref<Profile | null>(null);
function openPasswordEdit(profile: Profile) {
passwordEditingProfile.value = profile;
passwordDialogOpen.value = true;
}
const columns = computed<QTableColumn[]>(() => [ const columns = computed<QTableColumn[]>(() => [
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' }, { name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true }, { name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
@@ -144,8 +192,46 @@ function statusLabel(status: number) {
return translated === key ? t('status.unknown', { status }) : translated; 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.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>

View File

@@ -40,6 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue'; import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type RegisterParams } from '@/stores/admin-store'; import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
import AvatarUpload from '@/components/AvatarUpload.vue'; import AvatarUpload from '@/components/AvatarUpload.vue';
@@ -66,6 +67,7 @@ watch(open, (isOpen) => {
async function save() { async function save() {
try { try {
await adminStore.insertProfile({ ...createForm }); await adminStore.insertProfile({ ...createForm });
Notify.create({ type: 'positive', message: t('admin.notifications.profileCreated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

@@ -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')"
@@ -92,6 +98,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue'; import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store'; import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
import { countries } from '@/data/countries'; import { countries } from '@/data/countries';
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod'; import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
@@ -127,6 +134,7 @@ const personalForm = reactive<PersonalDataParams>({
first_name: '', first_name: '',
last_name: '', last_name: '',
address: '', address: '',
cap: '',
city: '', city: '',
country: '', country: '',
}); });
@@ -181,10 +189,12 @@ async function save() {
const parsed = validateEditForm(); const parsed = validateEditForm();
if (!parsed) return; if (!parsed) return;
await adminStore.updateProfile(props.profile.user_id, parsed); await adminStore.updateProfile(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.profileUpdated') });
} else { } else {
const parsed = validatePersonalForm(); const parsed = validatePersonalForm();
if (!parsed) return; if (!parsed) return;
await adminStore.upsertPersonalData(props.profile.user_id, parsed); await adminStore.upsertPersonalData(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
} }
open.value = false; open.value = false;
} catch { } catch {
@@ -196,6 +206,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 +245,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 = '';
} }

View File

@@ -0,0 +1,97 @@
<template>
<q-dialog v-model="open" @show="focusFirstField">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">{{ t('actions.updatePassword') }}</div>
</q-card-section>
<q-card-section v-if="profile" class="q-gutter-md">
<div class="text-subtitle1">{{ profile.email }}</div>
<q-input
ref="passwordInputRef"
v-model="password"
:label="t('fields.password')"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="Boolean(passwordError)"
:error-message="passwordError"
outlined
>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<q-banner v-if="adminStore.error" class="bg-negative text-white" rounded>
{{ adminStore.error }}
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-close-popup flat :label="t('actions.cancel')" />
<q-btn color="primary" :label="t('actions.save')" :loading="adminStore.loading" @click="save" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void };
const props = defineProps<{
profile: Profile | null;
}>();
const open = defineModel<boolean>({ required: true });
const adminStore = useAdminStore();
const { t } = useI18n();
const password = ref('');
const passwordError = ref('');
const showPassword = ref(false);
const passwordInputRef = ref<Focusable | null>(null);
watch(
() => [open.value, props.profile] as const,
([isOpen]) => {
if (isOpen) {
password.value = '';
passwordError.value = '';
showPassword.value = false;
}
},
{ immediate: true },
);
async function save() {
if (!props.profile) return;
passwordError.value = '';
if (!password.value) {
passwordError.value = t('fields.password');
return;
}
try {
await adminStore.updateProfilePassword(props.profile.user_id, { password: password.value });
Notify.create({ type: 'positive', message: t('admin.notifications.passwordUpdated') });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
passwordInputRef.value?.focus();
}
</script>

View File

@@ -35,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store'; import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void }; type Focusable = { focus: () => void };
@@ -63,6 +64,7 @@ async function save() {
if (!props.profile) return; if (!props.profile) return;
try { try {
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value); await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
Notify.create({ type: 'positive', message: t('admin.notifications.roleUpdated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

@@ -35,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store'; import { useAdminStore, type Profile } from '@/stores/admin-store';
import type { Status } from '@/stores/profiles-store'; import type { Status } from '@/stores/profiles-store';
@@ -64,6 +65,7 @@ async function save() {
if (!props.profile) return; if (!props.profile) return;
try { try {
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value); await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
Notify.create({ type: 'positive', message: t('admin.notifications.statusUpdated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

@@ -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') },

View File

@@ -8,6 +8,7 @@ export type ProfileParams = AdminNS.ProfileParams;
export type RegisterParams = AdminNS.RegisterParams; export type RegisterParams = AdminNS.RegisterParams;
export type PersonalData = AdminNS.PersonalData; export type PersonalData = AdminNS.PersonalData;
export type PersonalDataParams = AdminNS.PersonalDataParams; export type PersonalDataParams = AdminNS.PersonalDataParams;
export type UpdateProfilePasswordParams = AdminNS.UpdateProfilePasswordParams;
export type RoleOption = AuthNS.RoleOption; export type RoleOption = AuthNS.RoleOption;
export type StatusOption = ProfilesNS.StatusOption; export type StatusOption = ProfilesNS.StatusOption;
@@ -101,6 +102,13 @@ export const useAdminStore = defineStore('admin', () => {
}); });
} }
/** Updates the password of any user's profile. */
async function updateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
return withLoading(async () => {
await client.admin.UpdateProfilePassword(userID, params);
});
}
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */ /** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
async function getPersonalData(userID: string): Promise<PersonalData> { async function getPersonalData(userID: string): Promise<PersonalData> {
return withLoading(async () => { return withLoading(async () => {
@@ -143,6 +151,7 @@ export const useAdminStore = defineStore('admin', () => {
updateProfile, updateProfile,
updateProfileRole, updateProfileRole,
updateProfileStatus, updateProfileStatus,
updateProfilePassword,
getPersonalData, getPersonalData,
upsertPersonalData, upsertPersonalData,
uploadAvatar, uploadAvatar,

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

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { LocalStorage } from 'quasar'; import { LocalStorage } from 'quasar';
import Client, { Local, profiles as ProfilesNS } from '@/encore/client'; import Client, { Local, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
export type Profile = ProfilesNS.Profile; export type Profile = ProfilesNS.Profile;
export type ProfileParams = ProfilesNS.ProfileParams; export type ProfileParams = ProfilesNS.ProfileParams;
@@ -11,6 +11,7 @@ export type LoginResponse = ProfilesNS.LoginResponse;
export type Status = ProfilesNS.Status; export type Status = ProfilesNS.Status;
export type PersonalData = ProfilesNS.PersonalData; export type PersonalData = ProfilesNS.PersonalData;
export type PersonalDataParams = ProfilesNS.PersonalDataParams; export type PersonalDataParams = ProfilesNS.PersonalDataParams;
export type SetPasswordParams = AuthNS.SetPasswordParams;
/** Display info for each Status value, mirroring profiles/status.go. */ /** Display info for each Status value, mirroring profiles/status.go. */
export const STATUS_INFO: { label: string; color: string }[] = [ export const STATUS_INFO: { label: string; color: string }[] = [
@@ -132,6 +133,13 @@ export const useProfilesStore = defineStore('profiles', () => {
}); });
} }
/** Changes the authenticated caller's password after verifying the current one. */
async function setPassword(params: SetPasswordParams): Promise<void> {
return withLoading(async () => {
await client.auth.SetPassword(params);
});
}
/** Uploads an avatar image and returns its public URL. */ /** Uploads an avatar image and returns its public URL. */
async function uploadAvatar(image: Blob): Promise<string> { async function uploadAvatar(image: Blob): Promise<string> {
return withLoading(async () => { return withLoading(async () => {
@@ -162,6 +170,7 @@ export const useProfilesStore = defineStore('profiles', () => {
remove, remove,
fetchPersonalData, fetchPersonalData,
savePersonalData, savePersonalData,
setPassword,
uploadAvatar, uploadAvatar,
}; };
}); });

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"` 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"}
} }

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