Compare commits
2 Commits
72e7626122
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd858b0d8c | ||
|
|
7a2b80381d |
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -99,7 +99,7 @@ export default defineConfig((/* ctx */) => {
|
||||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: []
|
||||
plugins: ['Notify']
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const messages = {
|
||||
editProfile: 'Edit profile',
|
||||
updateRole: 'Update role',
|
||||
updateStatus: 'Update status',
|
||||
updatePassword: 'Update password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -74,9 +75,23 @@ 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: {
|
||||
@@ -154,6 +169,7 @@ const messages = {
|
||||
editProfile: 'Modifica profilo',
|
||||
updateRole: 'Aggiorna ruolo',
|
||||
updateStatus: 'Aggiorna stato',
|
||||
updatePassword: 'Aggiorna password',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -188,9 +204,23 @@ 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: {
|
||||
@@ -268,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',
|
||||
@@ -302,9 +333,23 @@ 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: {
|
||||
@@ -382,6 +427,7 @@ const messages = {
|
||||
editProfile: 'Profil bearbeiten',
|
||||
updateRole: 'Rolle ändern',
|
||||
updateStatus: 'Status ändern',
|
||||
updatePassword: 'Passwort ändern',
|
||||
},
|
||||
fields: {
|
||||
email: 'E-Mail',
|
||||
@@ -416,9 +462,23 @@ 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: {
|
||||
@@ -496,6 +556,7 @@ const messages = {
|
||||
editProfile: 'Editar perfil',
|
||||
updateRole: 'Actualizar rol',
|
||||
updateStatus: 'Actualizar estado',
|
||||
updatePassword: 'Actualizar contraseña',
|
||||
},
|
||||
fields: {
|
||||
email: 'Email',
|
||||
@@ -530,9 +591,23 @@ 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: {
|
||||
|
||||
@@ -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">
|
||||
<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 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-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">
|
||||
@@ -169,10 +228,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Notify } from 'quasar';
|
||||
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 +248,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 +261,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 +279,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 +308,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 +345,20 @@ 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);
|
||||
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 {
|
||||
@@ -334,6 +421,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 +465,8 @@ async function focusFirstField() {
|
||||
await nextTick();
|
||||
if (profileTab.value === 'personal') {
|
||||
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-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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,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';
|
||||
@@ -188,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 {
|
||||
|
||||
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 @@ 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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user