Compare commits
4 Commits
167be9b0b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd858b0d8c | ||
|
|
7a2b80381d | ||
|
|
72e7626122 | ||
|
|
1367829f80 |
@@ -198,6 +198,20 @@ func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRo
|
||||
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 {
|
||||
IsArtist bool `json:"is_artist"`
|
||||
}
|
||||
@@ -243,6 +257,7 @@ type PersonalData struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -253,6 +268,7 @@ type PersonalDataParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
@@ -263,9 +279,9 @@ type PersonalDataParams struct {
|
||||
func GetPersonalData(ctx context.Context, userID uuid.UUID) (*PersonalData, error) {
|
||||
pd := PersonalData{UserID: userID}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, address, city, country, created_at, updated_at
|
||||
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
|
||||
FROM personal_data WHERE user_id = $1
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
||||
}
|
||||
@@ -284,21 +300,23 @@ func UpsertPersonalData(ctx context.Context, userID uuid.UUID, p *PersonalDataPa
|
||||
FirstName: p.FirstName,
|
||||
LastName: p.LastName,
|
||||
Address: p.Address,
|
||||
Cap: p.Cap,
|
||||
City: p.City,
|
||||
Country: p.Country,
|
||||
}
|
||||
err := profilesDB.QueryRow(ctx, `
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
address = EXCLUDED.address,
|
||||
cap = EXCLUDED.cap,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
updated_at = NOW()
|
||||
RETURNING created_at, updated_at
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
if isForeignKeyViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
|
||||
12
assicurazione.md
Normal file
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
|
||||
@@ -2,10 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"encore.dev/beta/auth"
|
||||
"encore.dev/beta/errs"
|
||||
"encore.dev/storage/sqldb"
|
||||
"encore.dev/types/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type RegisterParams struct {
|
||||
@@ -33,7 +36,13 @@ func Register(ctx context.Context, p *RegisterParams) error {
|
||||
}
|
||||
|
||||
type SetPasswordParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// SetPassword changes the password for the authenticated user.
|
||||
@@ -48,6 +57,21 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
||||
if err != nil {
|
||||
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(¤tHash)
|
||||
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)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
@@ -60,3 +84,28 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserPassword changes a user's password from trusted internal flows.
|
||||
//
|
||||
//encore:api private method=PUT path=/auth/internal/users/:userID/password
|
||||
func SetUserPassword(ctx context.Context, userID uuid.UUID, p *SetUserPasswordParams) error {
|
||||
if p.UserID != uuid.Nil && p.UserID != userID {
|
||||
return &errs.Error{Code: errs.InvalidArgument, Message: "user id mismatch"}
|
||||
}
|
||||
|
||||
hash, err := hashPassword(p.Password)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||
}
|
||||
|
||||
res, err := db.Exec(ctx, `
|
||||
UPDATE credentials SET password_hash = $2, updated_at = NOW() WHERE user_id = $1
|
||||
`, userID, hash)
|
||||
if err != nil {
|
||||
return errs.WrapCode(err, errs.Internal, "failed to update password")
|
||||
}
|
||||
if res.RowsAffected() == 0 {
|
||||
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default defineConfig((/* ctx */) => {
|
||||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: []
|
||||
plugins: ['Notify']
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
dense
|
||||
icon="edit"
|
||||
:aria-label="t('actions.editProfile')"
|
||||
to="/profile"
|
||||
@click.stop
|
||||
@click.stop.prevent="editProfile"
|
||||
>
|
||||
<q-tooltip>{{ t('actions.editProfile') }}</q-tooltip>
|
||||
</q-btn>
|
||||
@@ -67,6 +66,10 @@ async function logout() {
|
||||
profilesStore.logout();
|
||||
await router.push('/');
|
||||
}
|
||||
|
||||
async function editProfile() {
|
||||
await router.push({ path: '/profile', query: { mode: 'edit' } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
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 */
|
||||
|
||||
.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
|
||||
"last_name": string
|
||||
address: string
|
||||
cap: string
|
||||
city: string
|
||||
country: string
|
||||
"created_at": string
|
||||
@@ -116,6 +117,7 @@ export namespace admin {
|
||||
"first_name": string
|
||||
"last_name": string
|
||||
address: string
|
||||
cap: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
@@ -153,6 +155,10 @@ export namespace admin {
|
||||
"is_artist": boolean
|
||||
}
|
||||
|
||||
export interface UpdateProfilePasswordParams {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UpdateProfileRoleParams {
|
||||
role: string
|
||||
}
|
||||
@@ -174,6 +180,7 @@ export namespace admin {
|
||||
this.ListProfiles = this.ListProfiles.bind(this)
|
||||
this.UpdateProfile = this.UpdateProfile.bind(this)
|
||||
this.UpdateProfileArtist = this.UpdateProfileArtist.bind(this)
|
||||
this.UpdateProfilePassword = this.UpdateProfilePassword.bind(this)
|
||||
this.UpdateProfileRole = this.UpdateProfileRole.bind(this)
|
||||
this.UpdateProfileStatus = this.UpdateProfileStatus.bind(this)
|
||||
this.UpsertPersonalData = this.UpsertPersonalData.bind(this)
|
||||
@@ -251,6 +258,13 @@ export namespace admin {
|
||||
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.
|
||||
*/
|
||||
@@ -297,6 +311,7 @@ export namespace auth {
|
||||
}
|
||||
|
||||
export interface SetPasswordParams {
|
||||
"current_password": string
|
||||
password: string
|
||||
}
|
||||
|
||||
@@ -388,6 +403,7 @@ export namespace profiles {
|
||||
"first_name": string
|
||||
"last_name": string
|
||||
address: string
|
||||
cap: string
|
||||
city: string
|
||||
country: string
|
||||
"created_at": string
|
||||
@@ -401,6 +417,7 @@ export namespace profiles {
|
||||
"first_name": string
|
||||
"last_name": string
|
||||
address: string
|
||||
cap: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
@@ -572,6 +589,23 @@ export namespace registration {
|
||||
profile: profiles.Profile
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmParams {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmResponse {
|
||||
email: string
|
||||
reset: boolean
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestParams {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestResponse {
|
||||
"email_sent": boolean
|
||||
}
|
||||
|
||||
export interface WelcomeResponse {
|
||||
email: string
|
||||
confirmed: boolean
|
||||
@@ -584,8 +618,10 @@ export namespace registration {
|
||||
constructor(baseClient: BaseClient) {
|
||||
this.baseClient = baseClient
|
||||
this.CheckEmail = this.CheckEmail.bind(this)
|
||||
this.ConfirmPasswordReset = this.ConfirmPasswordReset.bind(this)
|
||||
this.ConfirmWelcome = this.ConfirmWelcome.bind(this)
|
||||
this.Register = this.Register.bind(this)
|
||||
this.RequestPasswordReset = this.RequestPasswordReset.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -597,6 +633,15 @@ export namespace registration {
|
||||
return await resp.json() as EmailAvailabilityResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmPasswordReset validates a reset token and changes the account password.
|
||||
*/
|
||||
public async ConfirmPasswordReset(token: string, params: PasswordResetConfirmParams): Promise<PasswordResetConfirmResponse> {
|
||||
// Now make the actual call to the API
|
||||
const resp = await this.baseClient.callTypedAPI("POST", `/registration/password-reset/${encodeURIComponent(token)}`, JSON.stringify(params))
|
||||
return await resp.json() as PasswordResetConfirmResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfirmWelcome validates the welcome email token.
|
||||
*/
|
||||
@@ -614,6 +659,15 @@ export namespace registration {
|
||||
const resp = await this.baseClient.callTypedAPI("POST", `/registration`, JSON.stringify(params))
|
||||
return await resp.json() as RegisterResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* RequestPasswordReset creates a reset token and queues an email when the account exists.
|
||||
*/
|
||||
public async RequestPasswordReset(params: PasswordResetRequestParams): Promise<PasswordResetRequestResponse> {
|
||||
// Now make the actual call to the API
|
||||
const resp = await this.baseClient.callTypedAPI("POST", `/registration/password-reset`, JSON.stringify(params))
|
||||
return await resp.json() as PasswordResetRequestResponse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const AdminPersonalDataParamsSchema = z.object({
|
||||
first_name: z.string().min(2).max(32),
|
||||
last_name: z.string().min(2).max(32),
|
||||
address: z.string().min(5).max(32),
|
||||
cap: z.string(),
|
||||
city: z.string().min(2).max(32),
|
||||
country: z.string(),
|
||||
});
|
||||
@@ -26,6 +27,10 @@ export const AdminUpdateProfileArtistParamsSchema = z.object({
|
||||
is_artist: z.boolean(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfilePasswordParamsSchema = z.object({
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const AdminUpdateProfileRoleParamsSchema = z.object({
|
||||
role: z.string(),
|
||||
});
|
||||
@@ -35,6 +40,7 @@ export const AdminUpdateProfileStatusParamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const AuthSetPasswordParamsSchema = z.object({
|
||||
current_password: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
@@ -47,6 +53,7 @@ export const ProfilesPersonalDataParamsSchema = z.object({
|
||||
first_name: z.string(),
|
||||
last_name: z.string(),
|
||||
address: z.string(),
|
||||
cap: z.string(),
|
||||
city: z.string(),
|
||||
country: z.string(),
|
||||
});
|
||||
@@ -69,3 +76,11 @@ export const RegistrationRegisterParamsSchema = z.object({
|
||||
avatar_url: z.string(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const RegistrationPasswordResetConfirmParamsSchema = z.object({
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const RegistrationPasswordResetRequestParamsSchema = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages = {
|
||||
editProfile: 'Edit profile',
|
||||
updateRole: 'Update role',
|
||||
updateStatus: 'Update status',
|
||||
updatePassword: 'Update password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -48,6 +49,7 @@ const messages = {
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
address: 'Address',
|
||||
cap: 'Postcode',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
role: 'Role',
|
||||
@@ -73,14 +75,38 @@ const messages = {
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profile created.',
|
||||
profileUpdated: 'Profile updated.',
|
||||
personalDataUpdated: 'Personal data updated.',
|
||||
roleUpdated: 'Role updated.',
|
||||
statusUpdated: 'Status updated.',
|
||||
passwordUpdated: 'Password updated.',
|
||||
},
|
||||
},
|
||||
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.',
|
||||
},
|
||||
login: {
|
||||
title: 'Log in',
|
||||
needAccount: 'Create an account',
|
||||
forgotPassword: 'Forgot password',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Password reset',
|
||||
newPasswordTitle: 'Choose a new password',
|
||||
sendLink: 'Send reset link',
|
||||
updatePassword: 'Update password',
|
||||
backToLogin: 'Back to log in',
|
||||
requestSent: 'If the email exists, we sent a password reset link.',
|
||||
resetDone: 'Password updated. You can now log in.',
|
||||
},
|
||||
register: {
|
||||
title: 'Create account',
|
||||
@@ -143,6 +169,7 @@ const messages = {
|
||||
editProfile: 'Modifica profilo',
|
||||
updateRole: 'Aggiorna ruolo',
|
||||
updateStatus: 'Aggiorna stato',
|
||||
updatePassword: 'Aggiorna password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -151,6 +178,7 @@ const messages = {
|
||||
firstName: 'Nome',
|
||||
lastName: 'Cognome',
|
||||
address: 'Indirizzo',
|
||||
cap: 'CAP',
|
||||
city: 'Città',
|
||||
country: 'Paese',
|
||||
role: 'Ruolo',
|
||||
@@ -176,14 +204,38 @@ const messages = {
|
||||
created: 'Creato',
|
||||
updated: 'Aggiornato',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profilo creato.',
|
||||
profileUpdated: 'Profilo aggiornato.',
|
||||
personalDataUpdated: 'Dati personali aggiornati.',
|
||||
roleUpdated: 'Ruolo aggiornato.',
|
||||
statusUpdated: 'Stato aggiornato.',
|
||||
passwordUpdated: 'Password aggiornata.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
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 l’accesso per visualizzare il profilo.',
|
||||
},
|
||||
login: {
|
||||
title: 'Accedi',
|
||||
needAccount: 'Crea un account',
|
||||
forgotPassword: 'Password dimenticata',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Recupero password',
|
||||
newPasswordTitle: 'Scegli una nuova password',
|
||||
sendLink: 'Invia link di recupero',
|
||||
updatePassword: 'Aggiorna password',
|
||||
backToLogin: 'Torna al login',
|
||||
requestSent: 'Se l’email esiste, abbiamo inviato un link per reimpostare la password.',
|
||||
resetDone: 'Password aggiornata. Ora puoi accedere.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crea account',
|
||||
@@ -246,6 +298,7 @@ const messages = {
|
||||
editProfile: 'Modifier le profil',
|
||||
updateRole: 'Modifier le rôle',
|
||||
updateStatus: 'Modifier le statut',
|
||||
updatePassword: 'Modifier le mot de passe',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -254,6 +307,7 @@ const messages = {
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
address: 'Adresse',
|
||||
cap: 'Code postal',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
role: 'Rôle',
|
||||
@@ -279,14 +333,38 @@ const messages = {
|
||||
created: 'Créé',
|
||||
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: {
|
||||
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.',
|
||||
},
|
||||
login: {
|
||||
title: 'Connexion',
|
||||
needAccount: 'Créer un compte',
|
||||
forgotPassword: 'Mot de passe oublié',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Réinitialisation du mot de passe',
|
||||
newPasswordTitle: 'Choisir un nouveau mot de passe',
|
||||
sendLink: 'Envoyer le lien',
|
||||
updatePassword: 'Mettre à jour le mot de passe',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
requestSent: 'Si l’email existe, nous avons envoyé un lien de réinitialisation.',
|
||||
resetDone: 'Mot de passe mis à jour. Vous pouvez maintenant vous connecter.',
|
||||
},
|
||||
register: {
|
||||
title: 'Créer un compte',
|
||||
@@ -349,6 +427,7 @@ const messages = {
|
||||
editProfile: 'Profil bearbeiten',
|
||||
updateRole: 'Rolle ändern',
|
||||
updateStatus: 'Status ändern',
|
||||
updatePassword: 'Passwort ändern',
|
||||
},
|
||||
fields: {
|
||||
email: 'E-Mail',
|
||||
@@ -357,6 +436,7 @@ const messages = {
|
||||
firstName: 'Vorname',
|
||||
lastName: 'Nachname',
|
||||
address: 'Adresse',
|
||||
cap: 'Postleitzahl',
|
||||
city: 'Stadt',
|
||||
country: 'Land',
|
||||
role: 'Rolle',
|
||||
@@ -382,14 +462,38 @@ const messages = {
|
||||
created: 'Erstellt',
|
||||
updated: 'Aktualisiert',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Profil erstellt.',
|
||||
profileUpdated: 'Profil aktualisiert.',
|
||||
personalDataUpdated: 'Persönliche Daten aktualisiert.',
|
||||
roleUpdated: 'Rolle aktualisiert.',
|
||||
statusUpdated: 'Status aktualisiert.',
|
||||
passwordUpdated: 'Passwort aktualisiert.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
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.',
|
||||
},
|
||||
login: {
|
||||
title: 'Anmelden',
|
||||
needAccount: 'Konto erstellen',
|
||||
forgotPassword: 'Passwort vergessen',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Passwort zurücksetzen',
|
||||
newPasswordTitle: 'Neues Passwort wählen',
|
||||
sendLink: 'Link senden',
|
||||
updatePassword: 'Passwort aktualisieren',
|
||||
backToLogin: 'Zur Anmeldung',
|
||||
requestSent: 'Wenn die E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.',
|
||||
resetDone: 'Passwort aktualisiert. Sie können sich jetzt anmelden.',
|
||||
},
|
||||
register: {
|
||||
title: 'Konto erstellen',
|
||||
@@ -452,6 +556,7 @@ const messages = {
|
||||
editProfile: 'Editar perfil',
|
||||
updateRole: 'Actualizar rol',
|
||||
updateStatus: 'Actualizar estado',
|
||||
updatePassword: 'Actualizar contraseña',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -460,6 +565,7 @@ const messages = {
|
||||
firstName: 'Nombre',
|
||||
lastName: 'Apellido',
|
||||
address: 'Dirección',
|
||||
cap: 'Código postal',
|
||||
city: 'Ciudad',
|
||||
country: 'País',
|
||||
role: 'Rol',
|
||||
@@ -485,14 +591,38 @@ const messages = {
|
||||
created: 'Creado',
|
||||
updated: 'Actualizado',
|
||||
},
|
||||
notifications: {
|
||||
profileCreated: 'Perfil creado.',
|
||||
profileUpdated: 'Perfil actualizado.',
|
||||
personalDataUpdated: 'Datos personales actualizados.',
|
||||
roleUpdated: 'Rol actualizado.',
|
||||
statusUpdated: 'Estado actualizado.',
|
||||
passwordUpdated: 'Contraseña actualizada.',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
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.',
|
||||
},
|
||||
login: {
|
||||
title: 'Iniciar sesión',
|
||||
needAccount: 'Crear una cuenta',
|
||||
forgotPassword: 'Contraseña olvidada',
|
||||
},
|
||||
passwordReset: {
|
||||
title: 'Recuperar contraseña',
|
||||
newPasswordTitle: 'Elige una nueva contraseña',
|
||||
sendLink: 'Enviar enlace',
|
||||
updatePassword: 'Actualizar contraseña',
|
||||
backToLogin: 'Volver al inicio de sesión',
|
||||
requestSent: 'Si el email existe, enviamos un enlace para restablecer la contraseña.',
|
||||
resetDone: 'Contraseña actualizada. Ahora puedes iniciar sesión.',
|
||||
},
|
||||
register: {
|
||||
title: 'Crear cuenta',
|
||||
|
||||
@@ -16,16 +16,34 @@
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
/>
|
||||
<div class="column q-gutter-xs">
|
||||
<div class="text-right">
|
||||
<router-link to="/password-reset" class="text-primary text-caption text-no-decoration">
|
||||
{{ t('login.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
|
||||
{{ store.error }}
|
||||
@@ -57,6 +75,7 @@ import { useProfilesStore } from '@/stores/profiles-store';
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
const store = useProfilesStore();
|
||||
const router = useRouter();
|
||||
|
||||
132
frontend/src/pages/PasswordResetPage.vue
Normal file
132
frontend/src/pages/PasswordResetPage.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ pageTitle }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form v-if="!token" class="column q-gutter-md" @submit.prevent="requestReset">
|
||||
<q-banner v-if="requestSent" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.requestSent') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!requestSent"
|
||||
v-model="email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!requestSent"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.sendLink')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
|
||||
<q-form v-else class="column q-gutter-md" @submit.prevent="confirmReset">
|
||||
<q-banner v-if="resetDone" class="bg-positive text-white" rounded>
|
||||
{{ t('passwordReset.resetDone') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-if="!resetDone"
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
outlined
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon
|
||||
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="showPassword = !showPassword"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
v-if="!resetDone"
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('passwordReset.updatePassword')"
|
||||
:loading="loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('passwordReset.backToLogin')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Client, { Local } from '@/encore/client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const client = new Client(Local);
|
||||
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
|
||||
const pageTitle = computed(() => (token.value ? t('passwordReset.newPasswordTitle') : t('passwordReset.title')));
|
||||
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const requestSent = ref(false);
|
||||
const resetDone = ref(false);
|
||||
|
||||
async function withLoading(fn: () => Promise<void>) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.RequestPasswordReset({ email: email.value });
|
||||
requestSent.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
await withLoading(async () => {
|
||||
await client.registration.ConfirmPasswordReset(token.value, { password: password.value });
|
||||
resetDone.value = true;
|
||||
password.value = '';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -6,18 +6,215 @@
|
||||
{{ profilesStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 480px">
|
||||
<q-card-section class="q-gutter-md">
|
||||
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||
|
||||
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
|
||||
|
||||
<div class="text-caption text-grey">
|
||||
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 560px">
|
||||
<q-card-section class="row items-center q-gutter-sm">
|
||||
<q-avatar size="48px">
|
||||
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle1">{{ profilesStore.profile.email }}</div>
|
||||
<div class="text-subtitle1">{{ displayValue(profileForm.display_name) }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-tabs v-model="profileTab" align="left" class="text-primary" dense>
|
||||
<q-tab 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-card-actions>
|
||||
</q-card>
|
||||
@@ -29,34 +226,250 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useProfilesStore, type Profile } from '@/stores/profiles-store';
|
||||
import { 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 ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const profilesStore = useProfilesStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const form = reactive({
|
||||
type CountryOption = { label: string; value: string };
|
||||
type ProfileTab = '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: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const profileFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||
|
||||
function syncForm(profile: Profile | null) {
|
||||
form.display_name = profile?.display_name ?? '';
|
||||
form.avatar_url = profile?.avatar_url ?? '';
|
||||
const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
||||
|
||||
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() {
|
||||
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 {
|
||||
// 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 () => {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<q-page class="q-pa-md" ref="profilesPage">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
||||
<q-space />
|
||||
@@ -11,12 +11,15 @@
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:style="{ height: `${profilesTableHeight}px` }"
|
||||
class="sticky-header-table"
|
||||
:rows="adminStore.profiles"
|
||||
:columns="columns"
|
||||
row-key="user_id"
|
||||
:loading="adminStore.loading"
|
||||
flat
|
||||
bordered
|
||||
ref="profilesTable"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
@@ -55,6 +58,9 @@
|
||||
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
|
||||
</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-menu>
|
||||
</q-btn>
|
||||
@@ -65,24 +71,58 @@
|
||||
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
|
||||
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
|
||||
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
|
||||
<UpdatePasswordDialog v-model="passwordDialogOpen" :profile="passwordEditingProfile" />
|
||||
<CreateProfileDialog v-model="createDialogOpen" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.sticky-header-table { /* height or max-height is important */
|
||||
|
||||
.q-table__top,
|
||||
.q-table__bottom,
|
||||
thead tr:first-child th { /* bg color is important for th; just specify one */
|
||||
background-color: #ffffff;
|
||||
}
|
||||
thead tr th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
}
|
||||
thead tr:first-child th {
|
||||
top: 0;
|
||||
}
|
||||
/* this is when the loading indicator appears */
|
||||
&.q-table--loading thead tr:last-child th { /* height of all previous header rows */
|
||||
top: 48px;
|
||||
}
|
||||
/* prevent scrolling behind sticky top row on focus */
|
||||
tbody { /* height of all previous header rows */
|
||||
scroll-margin-top: 48px;
|
||||
}
|
||||
}</style>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
import { statusInfo } from '@/stores/profiles-store';
|
||||
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||
import UpdatePasswordDialog from './dialogs/UpdatePasswordDialog.vue';
|
||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const profilesPage = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTable = ref<ComponentPublicInstance | null>(null);
|
||||
const profilesTableHeight = ref(610);
|
||||
|
||||
const editDialogOpen = ref(false);
|
||||
const editingProfile = ref<Profile | null>(null);
|
||||
|
||||
@@ -113,6 +153,14 @@ function openStatusEdit(profile: Profile) {
|
||||
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[]>(() => [
|
||||
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
|
||||
{ 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;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function getElement(component: ComponentPublicInstance | null) {
|
||||
return component?.$el instanceof HTMLElement ? component.$el : null;
|
||||
}
|
||||
|
||||
function updateProfilesTableHeight() {
|
||||
const page = getElement(profilesPage.value);
|
||||
const table = getElement(profilesTable.value);
|
||||
|
||||
if (!page || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const pageStyle = window.getComputedStyle(page);
|
||||
const pagePaddingBottom = parseFloat(pageStyle.paddingBottom) || 0;
|
||||
const availableHeight = window.innerHeight - tableRect.top - pagePaddingBottom;
|
||||
|
||||
profilesTableHeight.value = Math.max(0, Math.floor(availableHeight));
|
||||
}
|
||||
|
||||
async function updateProfilesTableHeightAfterRender() {
|
||||
await nextTick();
|
||||
updateProfilesTableHeight();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void adminStore.listProfiles();
|
||||
void adminStore.fetchSystemOptions();
|
||||
layoutStore.startBodyResizeListener();
|
||||
await updateProfilesTableHeightAfterRender();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [adminStore.error, layoutStore.bodyWidth, layoutStore.bodyHeight],
|
||||
() => {
|
||||
void updateProfilesTableHeightAfterRender();
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
layoutStore.stopBodyResizeListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
@@ -66,6 +67,7 @@ watch(open, (isOpen) => {
|
||||
async function save() {
|
||||
try {
|
||||
await adminStore.insertProfile({ ...createForm });
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.profileCreated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.cap"
|
||||
:label="t('fields.cap')"
|
||||
:error="Boolean(personalFormErrors.cap)"
|
||||
:error-message="personalFormErrors.cap"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
@@ -92,6 +98,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
|
||||
import { countries } from '@/data/countries';
|
||||
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
|
||||
@@ -127,6 +134,7 @@ const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
cap: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
@@ -181,10 +189,12 @@ async function save() {
|
||||
const parsed = validateEditForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.updateProfile(props.profile.user_id, parsed);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.profileUpdated') });
|
||||
} else {
|
||||
const parsed = validatePersonalForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
|
||||
}
|
||||
open.value = false;
|
||||
} catch {
|
||||
@@ -196,6 +206,7 @@ function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.cap = data?.cap ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
@@ -234,6 +245,7 @@ function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.cap = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
97
frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue
Normal file
97
frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue
Normal 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>
|
||||
@@ -35,6 +35,7 @@
|
||||
<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 };
|
||||
@@ -63,6 +64,7 @@ async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.roleUpdated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<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';
|
||||
import type { Status } from '@/stores/profiles-store';
|
||||
|
||||
@@ -64,6 +65,7 @@ async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
|
||||
Notify.create({ type: 'positive', message: t('admin.notifications.statusUpdated') });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
|
||||
@@ -8,6 +8,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
||||
{ path: 'second', component: () => import('@/pages/SecondPage.vue') },
|
||||
{ path: 'login', component: () => import('@/pages/LoginPage.vue') },
|
||||
{ path: 'password-reset', component: () => import('@/pages/PasswordResetPage.vue') },
|
||||
{ path: 'register', component: () => import('@/pages/RegisterPage.vue') },
|
||||
{ path: 'welcome', component: () => import('@/pages/WelcomePage.vue') },
|
||||
{ path: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ProfileParams = AdminNS.ProfileParams;
|
||||
export type RegisterParams = AdminNS.RegisterParams;
|
||||
export type PersonalData = AdminNS.PersonalData;
|
||||
export type PersonalDataParams = AdminNS.PersonalDataParams;
|
||||
export type UpdateProfilePasswordParams = AdminNS.UpdateProfilePasswordParams;
|
||||
export type RoleOption = AuthNS.RoleOption;
|
||||
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. */
|
||||
async function getPersonalData(userID: string): Promise<PersonalData> {
|
||||
return withLoading(async () => {
|
||||
@@ -143,6 +151,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
updateProfile,
|
||||
updateProfileRole,
|
||||
updateProfileStatus,
|
||||
updateProfilePassword,
|
||||
getPersonalData,
|
||||
upsertPersonalData,
|
||||
uploadAvatar,
|
||||
|
||||
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,7 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
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 ProfileParams = ProfilesNS.ProfileParams;
|
||||
@@ -11,6 +11,7 @@ export type LoginResponse = ProfilesNS.LoginResponse;
|
||||
export type Status = ProfilesNS.Status;
|
||||
export type PersonalData = ProfilesNS.PersonalData;
|
||||
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
|
||||
export type SetPasswordParams = AuthNS.SetPasswordParams;
|
||||
|
||||
/** Display info for each Status value, mirroring profiles/status.go. */
|
||||
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. */
|
||||
async function uploadAvatar(image: Blob): Promise<string> {
|
||||
return withLoading(async () => {
|
||||
@@ -162,6 +170,7 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
remove,
|
||||
fetchPersonalData,
|
||||
savePersonalData,
|
||||
setPassword,
|
||||
uploadAvatar,
|
||||
};
|
||||
});
|
||||
|
||||
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"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -29,6 +30,7 @@ type PersonalDataParams struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Address string `json:"address"`
|
||||
Cap string `json:"cap"`
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
@@ -43,9 +45,9 @@ func GetPersonalData(ctx context.Context) (*PersonalData, error) {
|
||||
}
|
||||
pd := PersonalData{UserID: userID}
|
||||
err = db.QueryRow(ctx, `
|
||||
SELECT first_name, last_name, address, city, country, created_at, updated_at
|
||||
SELECT first_name, last_name, address, cap, city, country, created_at, updated_at
|
||||
FROM personal_data WHERE user_id = $1
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
`, userID).Scan(&pd.FirstName, &pd.LastName, &pd.Address, &pd.Cap, &pd.City, &pd.Country, &pd.CreatedAt, &pd.UpdatedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "personal data not found"}
|
||||
}
|
||||
@@ -68,21 +70,23 @@ func UpsertPersonalData(ctx context.Context, p *PersonalDataParams) (*PersonalDa
|
||||
FirstName: p.FirstName,
|
||||
LastName: p.LastName,
|
||||
Address: p.Address,
|
||||
Cap: p.Cap,
|
||||
City: p.City,
|
||||
Country: p.Country,
|
||||
}
|
||||
err = db.QueryRow(ctx, `
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO personal_data (user_id, first_name, last_name, address, cap, city, country)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
address = EXCLUDED.address,
|
||||
cap = EXCLUDED.cap,
|
||||
city = EXCLUDED.city,
|
||||
country = EXCLUDED.country,
|
||||
updated_at = NOW()
|
||||
RETURNING created_at, updated_at
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
`, userID, p.FirstName, p.LastName, p.Address, p.Cap, p.City, p.Country).Scan(&pd.CreatedAt, &pd.UpdatedAt)
|
||||
if isForeignKeyViolation(err) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "profile not found"}
|
||||
}
|
||||
|
||||
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"
|
||||
"time"
|
||||
|
||||
authsvc "encore.app/auth"
|
||||
mailssvc "encore.app/mails"
|
||||
profilessvc "encore.app/profiles"
|
||||
"encore.dev/beta/errs"
|
||||
@@ -30,8 +31,9 @@ var db = sqldb.NewDatabase("registration", sqldb.DatabaseConfig{
|
||||
var profilesDB = sqldb.Named("profiles")
|
||||
|
||||
const (
|
||||
welcomeTokenTTL = 24 * time.Hour
|
||||
frontendBaseURL = "http://localhost:9000/#"
|
||||
welcomeTokenTTL = 24 * time.Hour
|
||||
passwordResetTokenTTL = time.Hour
|
||||
frontendBaseURL = "http://localhost:9000/#"
|
||||
)
|
||||
|
||||
type RegisterParams struct {
|
||||
@@ -51,6 +53,23 @@ type WelcomeResponse struct {
|
||||
UsedAt time.Time `json:"used_at"`
|
||||
}
|
||||
|
||||
type PasswordResetRequestParams struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type PasswordResetRequestResponse struct {
|
||||
EmailSent bool `json:"email_sent"`
|
||||
}
|
||||
|
||||
type PasswordResetConfirmParams struct {
|
||||
Password string `json:"password" encore:"sensitive"`
|
||||
}
|
||||
|
||||
type PasswordResetConfirmResponse struct {
|
||||
Email string `json:"email"`
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
type EmailAvailabilityResponse struct {
|
||||
Email string `json:"email"`
|
||||
Available bool `json:"available"`
|
||||
@@ -155,6 +174,92 @@ func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, err
|
||||
return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil
|
||||
}
|
||||
|
||||
// RequestPasswordReset creates a reset token and queues an email when the account exists.
|
||||
//
|
||||
//encore:api public method=POST path=/registration/password-reset
|
||||
func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*PasswordResetRequestResponse, error) {
|
||||
email, err := normalizeEmail(p.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var profile profilessvc.Profile
|
||||
err = profilesDB.QueryRow(ctx, `
|
||||
SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at
|
||||
FROM user_profiles
|
||||
WHERE email = $1
|
||||
`, email).Scan(
|
||||
&profile.UserID,
|
||||
&profile.Email,
|
||||
&profile.DisplayName,
|
||||
&profile.AvatarURL,
|
||||
&profile.Role,
|
||||
&profile.Status,
|
||||
&profile.IsArtist,
|
||||
&profile.CreatedAt,
|
||||
&profile.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return &PasswordResetRequestResponse{EmailSent: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to load profile")
|
||||
}
|
||||
|
||||
token, err := createPasswordResetToken(ctx, &profile)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to create password reset token")
|
||||
}
|
||||
|
||||
mail, err := passwordResetMail(&profile, token)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to render password reset email")
|
||||
}
|
||||
if _, err := mailssvc.SendTransactional(ctx, mail); err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to queue password reset email")
|
||||
}
|
||||
|
||||
return &PasswordResetRequestResponse{EmailSent: true}, nil
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset validates a reset token and changes the account password.
|
||||
//
|
||||
//encore:api public method=POST path=/registration/password-reset/:token
|
||||
func ConfirmPasswordReset(ctx context.Context, token uuid.UUID, p *PasswordResetConfirmParams) (*PasswordResetConfirmResponse, error) {
|
||||
var userID uuid.UUID
|
||||
var email string
|
||||
var expiresAt time.Time
|
||||
var usedAt *time.Time
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT user_id, email, expires_at, used_at FROM password_reset_tokens WHERE token = $1
|
||||
`, token).Scan(&userID, &email, &expiresAt, &usedAt)
|
||||
if errors.Is(err, sqldb.ErrNoRows) {
|
||||
return nil, &errs.Error{Code: errs.NotFound, Message: "password reset token not found"}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to load password reset token")
|
||||
}
|
||||
if usedAt != nil {
|
||||
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token already used"}
|
||||
}
|
||||
if time.Now().After(expiresAt) {
|
||||
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token has expired"}
|
||||
}
|
||||
|
||||
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.Exec(ctx, `
|
||||
UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1
|
||||
`, token)
|
||||
if err != nil {
|
||||
return nil, errs.WrapCode(err, errs.Internal, "failed to consume password reset token")
|
||||
}
|
||||
|
||||
return &PasswordResetConfirmResponse{Email: email, Reset: true}, nil
|
||||
}
|
||||
|
||||
func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
@@ -167,6 +272,30 @@ func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid
|
||||
return token, err
|
||||
}
|
||||
|
||||
func createPasswordResetToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) {
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
_, err = db.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (token, user_id, email, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, token, profile.UserID, profile.Email, time.Now().Add(passwordResetTokenTTL))
|
||||
return token, err
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) (string, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"}
|
||||
}
|
||||
parsed, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"}
|
||||
}
|
||||
return parsed.Address, nil
|
||||
}
|
||||
|
||||
func emailDomain(email string) (string, bool) {
|
||||
_, domain, ok := strings.Cut(email, "@")
|
||||
domain = strings.TrimSpace(strings.TrimSuffix(domain, "."))
|
||||
@@ -234,7 +363,52 @@ type welcomeMailData struct {
|
||||
WelcomeURL string
|
||||
}
|
||||
|
||||
func renderTextTemplate(name string, data welcomeMailData) (string, error) {
|
||||
func passwordResetMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) {
|
||||
name := profile.DisplayName
|
||||
if name == "" {
|
||||
name = profile.Email
|
||||
}
|
||||
|
||||
data := passwordResetMailData{
|
||||
Name: name,
|
||||
Email: profile.Email,
|
||||
UserID: profile.UserID.String(),
|
||||
PasswordResetURL: frontendBaseURL + "/password-reset?token=" + token.String(),
|
||||
}
|
||||
|
||||
subject, err := renderTextTemplate("mails/password_reset_subject.tmpl", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
textBody, err := renderTextTemplate("mails/password_reset_text.tmpl", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
htmlBody, err := renderHTMLTemplate("mails/password_reset_html.tmpl", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &mailssvc.TransactionalMailParams{
|
||||
To: profile.Email,
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HTMLBody: htmlBody,
|
||||
Metadata: map[string]string{
|
||||
"kind": "password_reset",
|
||||
"user_id": profile.UserID.String(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type passwordResetMailData struct {
|
||||
Name string
|
||||
Email string
|
||||
UserID string
|
||||
PasswordResetURL string
|
||||
}
|
||||
|
||||
func renderTextTemplate(name string, data any) (string, error) {
|
||||
tmpl, err := texttemplate.ParseFS(mailTemplates, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -246,7 +420,7 @@ func renderTextTemplate(name string, data welcomeMailData) (string, error) {
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
}
|
||||
|
||||
func renderHTMLTemplate(name string, data welcomeMailData) (string, error) {
|
||||
func renderHTMLTemplate(name string, data any) (string, error) {
|
||||
tmpl, err := htmltemplate.ParseFS(mailTemplates, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
Reference in New Issue
Block a user