From 7a2b80381d8b44f96d3192fc6caeab0dc881a801 Mon Sep 17 00:00:00 2001 From: fabio Date: Sun, 26 Jul 2026 17:48:17 +0200 Subject: [PATCH] feat: add password update functionality for user profiles --- admin/admin.go | 14 ++ auth/credentials.go | 21 ++- frontend/src/encore/client.ts | 13 ++ frontend/src/encore/zod.ts | 5 + frontend/src/i18n/index.ts | 35 +++++ frontend/src/pages/ProfilePage.vue | 148 ++++++++++++++++-- frontend/src/pages/admin/ProfilesPage.vue | 13 ++ .../admin/dialogs/UpdatePasswordDialog.vue | 95 +++++++++++ frontend/src/stores/admin-store.ts | 9 ++ frontend/src/stores/profiles-store.ts | 11 +- 10 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue diff --git a/admin/admin.go b/admin/admin.go index c0cad42..2eccf2c 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -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"` } diff --git a/auth/credentials.go b/auth/credentials.go index b895685..1aa1432 100644 --- a/auth/credentials.go +++ b/auth/credentials.go @@ -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,8 @@ 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 { @@ -53,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") diff --git a/frontend/src/encore/client.ts b/frontend/src/encore/client.ts index a571bc3..1182e1d 100755 --- a/frontend/src/encore/client.ts +++ b/frontend/src/encore/client.ts @@ -155,6 +155,10 @@ export namespace admin { "is_artist": boolean } + export interface UpdateProfilePasswordParams { + password: string + } + export interface UpdateProfileRoleParams { role: string } @@ -176,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) @@ -253,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 { + await this.baseClient.callTypedAPI("PUT", `/admin/profiles/${encodeURIComponent(userID)}/password`, JSON.stringify(params)) + } + /** * UpdateProfileRole sets the role of any user's profile. */ @@ -299,6 +311,7 @@ export namespace auth { } export interface SetPasswordParams { + "current_password": string password: string } diff --git a/frontend/src/encore/zod.ts b/frontend/src/encore/zod.ts index fc8fcc9..06c0c58 100644 --- a/frontend/src/encore/zod.ts +++ b/frontend/src/encore/zod.ts @@ -27,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(), }); @@ -36,6 +40,7 @@ export const AdminUpdateProfileStatusParamsSchema = z.object({ }); export const AuthSetPasswordParamsSchema = z.object({ + current_password: z.string(), password: z.string(), }); diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 04310a6..6be34cf 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -40,6 +40,7 @@ const messages = { editProfile: 'Edit profile', updateRole: 'Update role', updateStatus: 'Update status', + updatePassword: 'Update password', }, fields: { email: 'Email', @@ -77,6 +78,12 @@ const messages = { }, 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: { @@ -154,6 +161,7 @@ const messages = { editProfile: 'Modifica profilo', updateRole: 'Aggiorna ruolo', updateStatus: 'Aggiorna stato', + updatePassword: 'Aggiorna password', }, fields: { email: 'Email', @@ -191,6 +199,12 @@ const messages = { }, 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: { @@ -268,6 +282,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', @@ -305,6 +320,12 @@ const messages = { }, 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: { @@ -382,6 +403,7 @@ const messages = { editProfile: 'Profil bearbeiten', updateRole: 'Rolle ändern', updateStatus: 'Status ändern', + updatePassword: 'Passwort ändern', }, fields: { email: 'E-Mail', @@ -419,6 +441,12 @@ const messages = { }, 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: { @@ -496,6 +524,7 @@ const messages = { editProfile: 'Editar perfil', updateRole: 'Actualizar rol', updateStatus: 'Actualizar estado', + updatePassword: 'Actualizar contraseña', }, fields: { email: 'Email', @@ -533,6 +562,12 @@ const messages = { }, 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: { diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue index bb31d95..fd65db0 100644 --- a/frontend/src/pages/ProfilePage.vue +++ b/frontend/src/pages/ProfilePage.vue @@ -19,21 +19,23 @@ + + - - + + +
+ {{ profileFormErrors.avatar_url }} +
+
- + + {{ t('fields.displayName') }} @@ -152,6 +154,63 @@ + + + + + + + + + + + + + +
@@ -171,8 +230,14 @@ import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { countries } from '@/data/countries'; -import { ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod'; -import { useProfilesStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/profiles-store'; +import { 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'; @@ -182,7 +247,7 @@ const route = useRoute(); const router = useRouter(); type CountryOption = { label: string; value: string }; -type ProfileTab = 'profile' | 'personal'; +type ProfileTab = 'avatar' | 'profile' | 'personal' | 'password'; type Focusable = { focus: () => void }; const countryOptions: CountryOption[] = countries.map((c) => { @@ -195,6 +260,7 @@ const profileTab = ref('profile'); const isEditMode = computed(() => route.query.mode === 'edit'); const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country); const firstNameInputRef = ref(null); +const currentPasswordInputRef = ref(null); const profileForm = reactive({ display_name: '', @@ -212,6 +278,17 @@ const personalForm = reactive({ }); const personalFormErrors = reactive>>({}); +const passwordForm = reactive({ + current_password: '', + password: '', +}); +const passwordFormErrors = reactive>>({}); +const passwordConfirmation = ref(''); +const passwordConfirmationError = ref(''); +const showCurrentPassword = ref(false); +const showNewPassword = ref(false); +const showPasswordConfirmation = ref(false); + watch( () => profilesStore.profile, (profile) => { @@ -230,8 +307,11 @@ watch(profileTab, () => { 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); } }); @@ -264,14 +344,19 @@ async function loadProfile(profile: Profile) { async function save() { try { - if (profileTab.value === 'profile') { + if (profileTab.value === 'avatar') { const parsed = validateProfileForm(); if (!parsed) return; await profilesStore.update(parsed); - } else { + } else if (profileTab.value === 'personal') { const parsed = validatePersonalForm(); if (!parsed) return; await profilesStore.savePersonalData(parsed); + } else if (profileTab.value === 'password') { + const parsed = validatePasswordForm(); + if (!parsed) return; + await profilesStore.setPassword(parsed); + resetPasswordForm(); } await router.replace('/profile'); } catch { @@ -334,6 +419,41 @@ function clearPersonalFormErrors() { 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 || '-'; @@ -343,6 +463,8 @@ async function focusFirstField() { await nextTick(); if (profileTab.value === 'personal') { firstNameInputRef.value?.focus(); + } else if (profileTab.value === 'password') { + currentPasswordInputRef.value?.focus(); } } diff --git a/frontend/src/pages/admin/ProfilesPage.vue b/frontend/src/pages/admin/ProfilesPage.vue index 719e013..a2298e9 100644 --- a/frontend/src/pages/admin/ProfilesPage.vue +++ b/frontend/src/pages/admin/ProfilesPage.vue @@ -58,6 +58,9 @@ {{ t('actions.updateStatus') }} + + {{ t('actions.updatePassword') }} + @@ -68,6 +71,7 @@ + @@ -107,6 +111,7 @@ 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'; @@ -148,6 +153,14 @@ function openStatusEdit(profile: Profile) { statusDialogOpen.value = true; } +const passwordDialogOpen = ref(false); +const passwordEditingProfile = ref(null); + +function openPasswordEdit(profile: Profile) { + passwordEditingProfile.value = profile; + passwordDialogOpen.value = true; +} + const columns = computed(() => [ { name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' }, { name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true }, diff --git a/frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue b/frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue new file mode 100644 index 0000000..9f7d7c6 --- /dev/null +++ b/frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/stores/admin-store.ts b/frontend/src/stores/admin-store.ts index 751a122..54b614a 100644 --- a/frontend/src/stores/admin-store.ts +++ b/frontend/src/stores/admin-store.ts @@ -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 { + 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 { return withLoading(async () => { @@ -143,6 +151,7 @@ export const useAdminStore = defineStore('admin', () => { updateProfile, updateProfileRole, updateProfileStatus, + updateProfilePassword, getPersonalData, upsertPersonalData, uploadAvatar, diff --git a/frontend/src/stores/profiles-store.ts b/frontend/src/stores/profiles-store.ts index 5a37040..b4f9d68 100644 --- a/frontend/src/stores/profiles-store.ts +++ b/frontend/src/stores/profiles-store.ts @@ -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 { + return withLoading(async () => { + await client.auth.SetPassword(params); + }); + } + /** Uploads an avatar image and returns its public URL. */ async function uploadAvatar(image: Blob): Promise { return withLoading(async () => { @@ -162,6 +170,7 @@ export const useProfilesStore = defineStore('profiles', () => { remove, fetchPersonalData, savePersonalData, + setPassword, uploadAvatar, }; });