feat: add password update functionality for user profiles
This commit is contained in:
@@ -198,6 +198,20 @@ func UpdateProfileRole(ctx context.Context, userID uuid.UUID, p *UpdateProfileRo
|
|||||||
return &profile, nil
|
return &profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateProfilePasswordParams struct {
|
||||||
|
Password string `json:"password" encore:"sensitive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfilePassword sets the password of any user's profile.
|
||||||
|
//
|
||||||
|
//encore:api auth method=PUT path=/admin/profiles/:userID/password
|
||||||
|
func UpdateProfilePassword(ctx context.Context, userID uuid.UUID, p *UpdateProfilePasswordParams) error {
|
||||||
|
if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateProfileArtistParams struct {
|
type UpdateProfileArtistParams struct {
|
||||||
IsArtist bool `json:"is_artist"`
|
IsArtist bool `json:"is_artist"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"encore.dev/beta/auth"
|
"encore.dev/beta/auth"
|
||||||
"encore.dev/beta/errs"
|
"encore.dev/beta/errs"
|
||||||
|
"encore.dev/storage/sqldb"
|
||||||
"encore.dev/types/uuid"
|
"encore.dev/types/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RegisterParams struct {
|
type RegisterParams struct {
|
||||||
@@ -33,7 +36,8 @@ func Register(ctx context.Context, p *RegisterParams) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordParams struct {
|
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 {
|
type SetUserPasswordParams struct {
|
||||||
@@ -53,6 +57,21 @@ func SetPassword(ctx context.Context, p *SetPasswordParams) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
return errs.WrapCode(err, errs.Internal, "invalid user id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var currentHash string
|
||||||
|
err = db.QueryRow(ctx, `
|
||||||
|
SELECT password_hash FROM credentials WHERE user_id = $1
|
||||||
|
`, userID).Scan(¤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)
|
hash, err := hashPassword(p.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
return errs.WrapCode(err, errs.Internal, "failed to hash password")
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ export namespace admin {
|
|||||||
"is_artist": boolean
|
"is_artist": boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfilePasswordParams {
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateProfileRoleParams {
|
export interface UpdateProfileRoleParams {
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
@@ -176,6 +180,7 @@ export namespace admin {
|
|||||||
this.ListProfiles = this.ListProfiles.bind(this)
|
this.ListProfiles = this.ListProfiles.bind(this)
|
||||||
this.UpdateProfile = this.UpdateProfile.bind(this)
|
this.UpdateProfile = this.UpdateProfile.bind(this)
|
||||||
this.UpdateProfileArtist = this.UpdateProfileArtist.bind(this)
|
this.UpdateProfileArtist = this.UpdateProfileArtist.bind(this)
|
||||||
|
this.UpdateProfilePassword = this.UpdateProfilePassword.bind(this)
|
||||||
this.UpdateProfileRole = this.UpdateProfileRole.bind(this)
|
this.UpdateProfileRole = this.UpdateProfileRole.bind(this)
|
||||||
this.UpdateProfileStatus = this.UpdateProfileStatus.bind(this)
|
this.UpdateProfileStatus = this.UpdateProfileStatus.bind(this)
|
||||||
this.UpsertPersonalData = this.UpsertPersonalData.bind(this)
|
this.UpsertPersonalData = this.UpsertPersonalData.bind(this)
|
||||||
@@ -253,6 +258,13 @@ export namespace admin {
|
|||||||
return await resp.json() as Profile
|
return await resp.json() as Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UpdateProfilePassword sets the password of any user's profile.
|
||||||
|
*/
|
||||||
|
public async UpdateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
|
||||||
|
await this.baseClient.callTypedAPI("PUT", `/admin/profiles/${encodeURIComponent(userID)}/password`, JSON.stringify(params))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UpdateProfileRole sets the role of any user's profile.
|
* UpdateProfileRole sets the role of any user's profile.
|
||||||
*/
|
*/
|
||||||
@@ -299,6 +311,7 @@ export namespace auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SetPasswordParams {
|
export interface SetPasswordParams {
|
||||||
|
"current_password": string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const AdminUpdateProfileArtistParamsSchema = z.object({
|
|||||||
is_artist: z.boolean(),
|
is_artist: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AdminUpdateProfilePasswordParamsSchema = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const AdminUpdateProfileRoleParamsSchema = z.object({
|
export const AdminUpdateProfileRoleParamsSchema = z.object({
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
});
|
});
|
||||||
@@ -36,6 +40,7 @@ export const AdminUpdateProfileStatusParamsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const AuthSetPasswordParamsSchema = z.object({
|
export const AuthSetPasswordParamsSchema = z.object({
|
||||||
|
current_password: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const messages = {
|
|||||||
editProfile: 'Edit profile',
|
editProfile: 'Edit profile',
|
||||||
updateRole: 'Update role',
|
updateRole: 'Update role',
|
||||||
updateStatus: 'Update status',
|
updateStatus: 'Update status',
|
||||||
|
updatePassword: 'Update password',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
@@ -77,6 +78,12 @@ const messages = {
|
|||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'My profile',
|
title: 'My profile',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
password: 'Password',
|
||||||
|
currentPassword: 'Current password',
|
||||||
|
newPassword: 'New password',
|
||||||
|
confirmNewPassword: 'Confirm new password',
|
||||||
|
passwordMismatch: 'Passwords do not match.',
|
||||||
loginRequired: 'You must be logged in to view your profile.',
|
loginRequired: 'You must be logged in to view your profile.',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
@@ -154,6 +161,7 @@ const messages = {
|
|||||||
editProfile: 'Modifica profilo',
|
editProfile: 'Modifica profilo',
|
||||||
updateRole: 'Aggiorna ruolo',
|
updateRole: 'Aggiorna ruolo',
|
||||||
updateStatus: 'Aggiorna stato',
|
updateStatus: 'Aggiorna stato',
|
||||||
|
updatePassword: 'Aggiorna password',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
@@ -191,6 +199,12 @@ const messages = {
|
|||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'Il mio profilo',
|
title: 'Il mio profilo',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
password: 'Password',
|
||||||
|
currentPassword: 'Password corrente',
|
||||||
|
newPassword: 'Nuova password',
|
||||||
|
confirmNewPassword: 'Conferma nuova password',
|
||||||
|
passwordMismatch: 'Le password non coincidono.',
|
||||||
loginRequired: 'Devi effettuare l’accesso per visualizzare il profilo.',
|
loginRequired: 'Devi effettuare l’accesso per visualizzare il profilo.',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
@@ -268,6 +282,7 @@ const messages = {
|
|||||||
editProfile: 'Modifier le profil',
|
editProfile: 'Modifier le profil',
|
||||||
updateRole: 'Modifier le rôle',
|
updateRole: 'Modifier le rôle',
|
||||||
updateStatus: 'Modifier le statut',
|
updateStatus: 'Modifier le statut',
|
||||||
|
updatePassword: 'Modifier le mot de passe',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
@@ -305,6 +320,12 @@ const messages = {
|
|||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'Mon profil',
|
title: 'Mon profil',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
password: 'Mot de passe',
|
||||||
|
currentPassword: 'Mot de passe actuel',
|
||||||
|
newPassword: 'Nouveau mot de passe',
|
||||||
|
confirmNewPassword: 'Confirmer le nouveau mot de passe',
|
||||||
|
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||||
loginRequired: 'Vous devez être connecté pour voir votre profil.',
|
loginRequired: 'Vous devez être connecté pour voir votre profil.',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
@@ -382,6 +403,7 @@ const messages = {
|
|||||||
editProfile: 'Profil bearbeiten',
|
editProfile: 'Profil bearbeiten',
|
||||||
updateRole: 'Rolle ändern',
|
updateRole: 'Rolle ändern',
|
||||||
updateStatus: 'Status ändern',
|
updateStatus: 'Status ändern',
|
||||||
|
updatePassword: 'Passwort ändern',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
email: 'E-Mail',
|
email: 'E-Mail',
|
||||||
@@ -419,6 +441,12 @@ const messages = {
|
|||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'Mein Profil',
|
title: 'Mein Profil',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
password: 'Passwort',
|
||||||
|
currentPassword: 'Aktuelles Passwort',
|
||||||
|
newPassword: 'Neues Passwort',
|
||||||
|
confirmNewPassword: 'Neues Passwort bestätigen',
|
||||||
|
passwordMismatch: 'Die Passwörter stimmen nicht überein.',
|
||||||
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
|
loginRequired: 'Sie müssen angemeldet sein, um Ihr Profil zu sehen.',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
@@ -496,6 +524,7 @@ const messages = {
|
|||||||
editProfile: 'Editar perfil',
|
editProfile: 'Editar perfil',
|
||||||
updateRole: 'Actualizar rol',
|
updateRole: 'Actualizar rol',
|
||||||
updateStatus: 'Actualizar estado',
|
updateStatus: 'Actualizar estado',
|
||||||
|
updatePassword: 'Actualizar contraseña',
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
@@ -533,6 +562,12 @@ const messages = {
|
|||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: 'Mi perfil',
|
title: 'Mi perfil',
|
||||||
|
avatar: 'Avatar',
|
||||||
|
password: 'Contraseña',
|
||||||
|
currentPassword: 'Contraseña actual',
|
||||||
|
newPassword: 'Nueva contraseña',
|
||||||
|
confirmNewPassword: 'Confirmar nueva contraseña',
|
||||||
|
passwordMismatch: 'Las contraseñas no coinciden.',
|
||||||
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
|
loginRequired: 'Debes iniciar sesión para ver tu perfil.',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@@ -19,21 +19,23 @@
|
|||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-tabs v-model="profileTab" align="left" class="text-primary" dense>
|
<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="profile" :label="t('admin.profile')" />
|
||||||
<q-tab name="personal" :label="t('admin.personalData')" />
|
<q-tab name="personal" :label="t('admin.personalData')" />
|
||||||
|
<q-tab v-if="isEditMode" name="password" :label="t('profile.password')" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-tab-panels v-model="profileTab" animated>
|
<q-tab-panels v-model="profileTab" animated>
|
||||||
<q-tab-panel name="profile" class="q-gutter-md">
|
<q-tab-panel v-if="isEditMode" name="avatar" class="q-gutter-md">
|
||||||
<template v-if="isEditMode">
|
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||||
<AvatarUpload v-model="profileForm.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
|
||||||
<div v-if="profileFormErrors.avatar_url" class="text-negative text-caption">
|
{{ profileFormErrors.avatar_url }}
|
||||||
{{ profileFormErrors.avatar_url }}
|
</div>
|
||||||
</div>
|
</q-tab-panel>
|
||||||
</template>
|
|
||||||
|
|
||||||
<q-list v-else separator>
|
<q-tab-panel name="profile" class="q-gutter-md">
|
||||||
|
<q-list separator>
|
||||||
<q-item>
|
<q-item>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label caption>{{ t('fields.displayName') }}</q-item-label>
|
<q-item-label caption>{{ t('fields.displayName') }}</q-item-label>
|
||||||
@@ -152,6 +154,63 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-tab-panel>
|
</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-tab-panels>
|
||||||
|
|
||||||
<q-card-actions v-if="isEditMode" align="right">
|
<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 { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { countries } from '@/data/countries';
|
import { countries } from '@/data/countries';
|
||||||
import { ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
|
import { AuthSetPasswordParamsSchema, ProfilesPersonalDataParamsSchema, ProfilesProfileParamsSchema } from '@/encore/zod';
|
||||||
import { useProfilesStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/profiles-store';
|
import {
|
||||||
|
useProfilesStore,
|
||||||
|
type PersonalDataParams,
|
||||||
|
type Profile,
|
||||||
|
type ProfileParams,
|
||||||
|
type SetPasswordParams,
|
||||||
|
} from '@/stores/profiles-store';
|
||||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||||
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
import ProfileStatusBadge from '@/components/ProfileStatusBadge.vue';
|
||||||
|
|
||||||
@@ -182,7 +247,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
type CountryOption = { label: string; value: string };
|
type CountryOption = { label: string; value: string };
|
||||||
type ProfileTab = 'profile' | 'personal';
|
type ProfileTab = 'avatar' | 'profile' | 'personal' | 'password';
|
||||||
type Focusable = { focus: () => void };
|
type Focusable = { focus: () => void };
|
||||||
|
|
||||||
const countryOptions: CountryOption[] = countries.map((c) => {
|
const countryOptions: CountryOption[] = countries.map((c) => {
|
||||||
@@ -195,6 +260,7 @@ const profileTab = ref<ProfileTab>('profile');
|
|||||||
const isEditMode = computed(() => route.query.mode === 'edit');
|
const isEditMode = computed(() => route.query.mode === 'edit');
|
||||||
const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country);
|
const countryLabel = computed(() => countryOptions.find((c) => c.value === personalForm.country)?.label ?? personalForm.country);
|
||||||
const firstNameInputRef = ref<Focusable | null>(null);
|
const firstNameInputRef = ref<Focusable | null>(null);
|
||||||
|
const currentPasswordInputRef = ref<Focusable | null>(null);
|
||||||
|
|
||||||
const profileForm = reactive<ProfileParams>({
|
const profileForm = reactive<ProfileParams>({
|
||||||
display_name: '',
|
display_name: '',
|
||||||
@@ -212,6 +278,17 @@ const personalForm = reactive<PersonalDataParams>({
|
|||||||
});
|
});
|
||||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
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(
|
watch(
|
||||||
() => profilesStore.profile,
|
() => profilesStore.profile,
|
||||||
(profile) => {
|
(profile) => {
|
||||||
@@ -230,8 +307,11 @@ watch(profileTab, () => {
|
|||||||
|
|
||||||
watch(isEditMode, (editing) => {
|
watch(isEditMode, (editing) => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
|
profileTab.value = 'avatar';
|
||||||
|
resetPasswordForm();
|
||||||
void focusFirstField();
|
void focusFirstField();
|
||||||
} else if (profilesStore.profile) {
|
} else if (profilesStore.profile) {
|
||||||
|
profileTab.value = profileTab.value === 'avatar' || profileTab.value === 'password' ? 'profile' : profileTab.value;
|
||||||
void loadProfile(profilesStore.profile);
|
void loadProfile(profilesStore.profile);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -264,14 +344,19 @@ async function loadProfile(profile: Profile) {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
if (profileTab.value === 'profile') {
|
if (profileTab.value === 'avatar') {
|
||||||
const parsed = validateProfileForm();
|
const parsed = validateProfileForm();
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
await profilesStore.update(parsed);
|
await profilesStore.update(parsed);
|
||||||
} else {
|
} else if (profileTab.value === 'personal') {
|
||||||
const parsed = validatePersonalForm();
|
const parsed = validatePersonalForm();
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
await profilesStore.savePersonalData(parsed);
|
await profilesStore.savePersonalData(parsed);
|
||||||
|
} else if (profileTab.value === 'password') {
|
||||||
|
const parsed = validatePasswordForm();
|
||||||
|
if (!parsed) return;
|
||||||
|
await profilesStore.setPassword(parsed);
|
||||||
|
resetPasswordForm();
|
||||||
}
|
}
|
||||||
await router.replace('/profile');
|
await router.replace('/profile');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -334,6 +419,41 @@ function clearPersonalFormErrors() {
|
|||||||
personalFormErrors.country = '';
|
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) {
|
function displayValue(value: string | number | null | undefined) {
|
||||||
const normalized = String(value ?? '').trim();
|
const normalized = String(value ?? '').trim();
|
||||||
return normalized || '-';
|
return normalized || '-';
|
||||||
@@ -343,6 +463,8 @@ async function focusFirstField() {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
if (profileTab.value === 'personal') {
|
if (profileTab.value === 'personal') {
|
||||||
firstNameInputRef.value?.focus();
|
firstNameInputRef.value?.focus();
|
||||||
|
} else if (profileTab.value === 'password') {
|
||||||
|
currentPasswordInputRef.value?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
|
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
|
||||||
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
|
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
|
<q-item v-close-popup clickable @click="openPasswordEdit(props.row)">
|
||||||
|
<q-item-section>{{ t('actions.updatePassword') }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-menu>
|
</q-menu>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
@@ -68,6 +71,7 @@
|
|||||||
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
|
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
|
||||||
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
|
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
|
||||||
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
|
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
|
||||||
|
<UpdatePasswordDialog v-model="passwordDialogOpen" :profile="passwordEditingProfile" />
|
||||||
<CreateProfileDialog v-model="createDialogOpen" />
|
<CreateProfileDialog v-model="createDialogOpen" />
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,6 +111,7 @@ import { useLayoutStore } from '@/stores/layout-store';
|
|||||||
import { statusInfo } from '@/stores/profiles-store';
|
import { statusInfo } from '@/stores/profiles-store';
|
||||||
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||||
|
import UpdatePasswordDialog from './dialogs/UpdatePasswordDialog.vue';
|
||||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||||
|
|
||||||
@@ -148,6 +153,14 @@ function openStatusEdit(profile: Profile) {
|
|||||||
statusDialogOpen.value = true;
|
statusDialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordDialogOpen = ref(false);
|
||||||
|
const passwordEditingProfile = ref<Profile | null>(null);
|
||||||
|
|
||||||
|
function openPasswordEdit(profile: Profile) {
|
||||||
|
passwordEditingProfile.value = profile;
|
||||||
|
passwordDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const columns = computed<QTableColumn[]>(() => [
|
const columns = computed<QTableColumn[]>(() => [
|
||||||
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
|
{ name: 'avatar_url', label: '', field: 'avatar_url', align: 'left' },
|
||||||
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
|
{ name: 'display_name', label: t('admin.columns.name'), field: 'display_name', align: 'left', sortable: true },
|
||||||
|
|||||||
95
frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue
Normal file
95
frontend/src/pages/admin/dialogs/UpdatePasswordDialog.vue
Normal 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>
|
||||||
@@ -8,6 +8,7 @@ export type ProfileParams = AdminNS.ProfileParams;
|
|||||||
export type RegisterParams = AdminNS.RegisterParams;
|
export type RegisterParams = AdminNS.RegisterParams;
|
||||||
export type PersonalData = AdminNS.PersonalData;
|
export type PersonalData = AdminNS.PersonalData;
|
||||||
export type PersonalDataParams = AdminNS.PersonalDataParams;
|
export type PersonalDataParams = AdminNS.PersonalDataParams;
|
||||||
|
export type UpdateProfilePasswordParams = AdminNS.UpdateProfilePasswordParams;
|
||||||
export type RoleOption = AuthNS.RoleOption;
|
export type RoleOption = AuthNS.RoleOption;
|
||||||
export type StatusOption = ProfilesNS.StatusOption;
|
export type StatusOption = ProfilesNS.StatusOption;
|
||||||
|
|
||||||
@@ -101,6 +102,13 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Updates the password of any user's profile. */
|
||||||
|
async function updateProfilePassword(userID: string, params: UpdateProfilePasswordParams): Promise<void> {
|
||||||
|
return withLoading(async () => {
|
||||||
|
await client.admin.UpdateProfilePassword(userID, params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
|
/** Fetches the personal data of any user. Requires the caller to be logged in as an admin. */
|
||||||
async function getPersonalData(userID: string): Promise<PersonalData> {
|
async function getPersonalData(userID: string): Promise<PersonalData> {
|
||||||
return withLoading(async () => {
|
return withLoading(async () => {
|
||||||
@@ -143,6 +151,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
updateProfile,
|
updateProfile,
|
||||||
updateProfileRole,
|
updateProfileRole,
|
||||||
updateProfileStatus,
|
updateProfileStatus,
|
||||||
|
updateProfilePassword,
|
||||||
getPersonalData,
|
getPersonalData,
|
||||||
upsertPersonalData,
|
upsertPersonalData,
|
||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { LocalStorage } from 'quasar';
|
import { LocalStorage } from 'quasar';
|
||||||
import Client, { Local, profiles as ProfilesNS } from '@/encore/client';
|
import Client, { Local, auth as AuthNS, profiles as ProfilesNS } from '@/encore/client';
|
||||||
|
|
||||||
export type Profile = ProfilesNS.Profile;
|
export type Profile = ProfilesNS.Profile;
|
||||||
export type ProfileParams = ProfilesNS.ProfileParams;
|
export type ProfileParams = ProfilesNS.ProfileParams;
|
||||||
@@ -11,6 +11,7 @@ export type LoginResponse = ProfilesNS.LoginResponse;
|
|||||||
export type Status = ProfilesNS.Status;
|
export type Status = ProfilesNS.Status;
|
||||||
export type PersonalData = ProfilesNS.PersonalData;
|
export type PersonalData = ProfilesNS.PersonalData;
|
||||||
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
|
export type PersonalDataParams = ProfilesNS.PersonalDataParams;
|
||||||
|
export type SetPasswordParams = AuthNS.SetPasswordParams;
|
||||||
|
|
||||||
/** Display info for each Status value, mirroring profiles/status.go. */
|
/** Display info for each Status value, mirroring profiles/status.go. */
|
||||||
export const STATUS_INFO: { label: string; color: string }[] = [
|
export const STATUS_INFO: { label: string; color: string }[] = [
|
||||||
@@ -132,6 +133,13 @@ export const useProfilesStore = defineStore('profiles', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Changes the authenticated caller's password after verifying the current one. */
|
||||||
|
async function setPassword(params: SetPasswordParams): Promise<void> {
|
||||||
|
return withLoading(async () => {
|
||||||
|
await client.auth.SetPassword(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Uploads an avatar image and returns its public URL. */
|
/** Uploads an avatar image and returns its public URL. */
|
||||||
async function uploadAvatar(image: Blob): Promise<string> {
|
async function uploadAvatar(image: Blob): Promise<string> {
|
||||||
return withLoading(async () => {
|
return withLoading(async () => {
|
||||||
@@ -162,6 +170,7 @@ export const useProfilesStore = defineStore('profiles', () => {
|
|||||||
remove,
|
remove,
|
||||||
fetchPersonalData,
|
fetchPersonalData,
|
||||||
savePersonalData,
|
savePersonalData,
|
||||||
|
setPassword,
|
||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user