feat: add password update functionality for user profiles

This commit is contained in:
fabio
2026-07-26 17:48:17 +02:00
parent 72e7626122
commit 7a2b80381d
10 changed files with 349 additions and 15 deletions

View File

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

View File

@@ -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,6 +36,7 @@ func Register(ctx context.Context, p *RegisterParams) error {
}
type SetPasswordParams struct {
CurrentPassword string `json:"current_password" encore:"sensitive"`
Password string `json:"password" encore:"sensitive"`
}
@@ -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(&currentHash)
if errors.Is(err, sqldb.ErrNoRows) {
return &errs.Error{Code: errs.NotFound, Message: "credentials not found"}
}
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to fetch credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), pepperedPassword(p.CurrentPassword)); err != nil {
return &errs.Error{Code: errs.Unauthenticated, Message: "invalid current password"}
}
hash, err := hashPassword(p.Password)
if err != nil {
return errs.WrapCode(err, errs.Internal, "failed to hash password")

View File

@@ -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<void> {
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
}

View File

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

View File

@@ -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 laccesso 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: {

View File

@@ -19,21 +19,23 @@
</q-card-section>
<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 name="profile" class="q-gutter-md">
<template v-if="isEditMode">
<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>
</template>
</q-tab-panel>
<q-list v-else separator>
<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>
@@ -152,6 +154,63 @@
</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">
@@ -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<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: '',
@@ -212,6 +278,17 @@ const personalForm = reactive<PersonalDataParams>({
});
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) => {
@@ -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();
}
}

View File

@@ -58,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>
@@ -68,6 +71,7 @@
<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>
@@ -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<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 },

View File

@@ -0,0 +1,95 @@
<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 { 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 });
open.value = false;
} catch {
// adminStore.error already holds the failure message
}
}
async function focusFirstField() {
await nextTick();
passwordInputRef.value?.focus();
}
</script>

View File

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

View File

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