Compare commits

...

2 Commits

15 changed files with 403 additions and 16 deletions

View File

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

View File

@@ -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,6 +36,7 @@ func Register(ctx context.Context, p *RegisterParams) error {
} }
type SetPasswordParams struct { type SetPasswordParams struct {
CurrentPassword string `json:"current_password" encore:"sensitive"`
Password string `json:"password" encore:"sensitive"` Password string `json:"password" encore:"sensitive"`
} }
@@ -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(&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) 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")

View File

@@ -99,7 +99,7 @@ export default defineConfig((/* ctx */) => {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: [] plugins: ['Notify']
}, },
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations

View File

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

View File

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

View File

@@ -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',
@@ -74,9 +75,23 @@ const messages = {
created: 'Created', created: 'Created',
updated: 'Updated', updated: 'Updated',
}, },
notifications: {
profileCreated: 'Profile created.',
profileUpdated: 'Profile updated.',
personalDataUpdated: 'Personal data updated.',
roleUpdated: 'Role updated.',
statusUpdated: 'Status updated.',
passwordUpdated: 'Password updated.',
},
}, },
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 +169,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',
@@ -188,9 +204,23 @@ const messages = {
created: 'Creato', created: 'Creato',
updated: 'Aggiornato', updated: 'Aggiornato',
}, },
notifications: {
profileCreated: 'Profilo creato.',
profileUpdated: 'Profilo aggiornato.',
personalDataUpdated: 'Dati personali aggiornati.',
roleUpdated: 'Ruolo aggiornato.',
statusUpdated: 'Stato aggiornato.',
passwordUpdated: 'Password aggiornata.',
},
}, },
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 laccesso per visualizzare il profilo.', loginRequired: 'Devi effettuare laccesso per visualizzare il profilo.',
}, },
login: { login: {
@@ -268,6 +298,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',
@@ -302,9 +333,23 @@ const messages = {
created: 'Créé', created: 'Créé',
updated: 'Mis à jour', 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: { 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 +427,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',
@@ -416,9 +462,23 @@ const messages = {
created: 'Erstellt', created: 'Erstellt',
updated: 'Aktualisiert', updated: 'Aktualisiert',
}, },
notifications: {
profileCreated: 'Profil erstellt.',
profileUpdated: 'Profil aktualisiert.',
personalDataUpdated: 'Persönliche Daten aktualisiert.',
roleUpdated: 'Rolle aktualisiert.',
statusUpdated: 'Status aktualisiert.',
passwordUpdated: 'Passwort aktualisiert.',
},
}, },
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 +556,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',
@@ -530,9 +591,23 @@ const messages = {
created: 'Creado', created: 'Creado',
updated: 'Actualizado', updated: 'Actualizado',
}, },
notifications: {
profileCreated: 'Perfil creado.',
profileUpdated: 'Perfil actualizado.',
personalDataUpdated: 'Datos personales actualizados.',
roleUpdated: 'Rol actualizado.',
statusUpdated: 'Estado actualizado.',
passwordUpdated: 'Contraseña actualizada.',
},
}, },
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: {

View File

@@ -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>
</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>
<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">
@@ -169,10 +228,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
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 +248,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 +261,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 +279,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 +308,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 +345,20 @@ 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);
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'); await router.replace('/profile');
} catch { } catch {
@@ -334,6 +421,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 +465,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();
} }
} }

View File

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

View File

@@ -40,6 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue'; import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type RegisterParams } from '@/stores/admin-store'; import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
import AvatarUpload from '@/components/AvatarUpload.vue'; import AvatarUpload from '@/components/AvatarUpload.vue';
@@ -66,6 +67,7 @@ watch(open, (isOpen) => {
async function save() { async function save() {
try { try {
await adminStore.insertProfile({ ...createForm }); await adminStore.insertProfile({ ...createForm });
Notify.create({ type: 'positive', message: t('admin.notifications.profileCreated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

@@ -98,6 +98,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, reactive, ref, watch } from 'vue'; import { nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store'; import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
import { countries } from '@/data/countries'; import { countries } from '@/data/countries';
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod'; import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
@@ -188,10 +189,12 @@ async function save() {
const parsed = validateEditForm(); const parsed = validateEditForm();
if (!parsed) return; if (!parsed) return;
await adminStore.updateProfile(props.profile.user_id, parsed); await adminStore.updateProfile(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.profileUpdated') });
} else { } else {
const parsed = validatePersonalForm(); const parsed = validatePersonalForm();
if (!parsed) return; if (!parsed) return;
await adminStore.upsertPersonalData(props.profile.user_id, parsed); await adminStore.upsertPersonalData(props.profile.user_id, parsed);
Notify.create({ type: 'positive', message: t('admin.notifications.personalDataUpdated') });
} }
open.value = false; open.value = false;
} catch { } catch {

View 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>

View File

@@ -35,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store'; import { useAdminStore, type Profile } from '@/stores/admin-store';
type Focusable = { focus: () => void }; type Focusable = { focus: () => void };
@@ -63,6 +64,7 @@ async function save() {
if (!props.profile) return; if (!props.profile) return;
try { try {
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value); await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
Notify.create({ type: 'positive', message: t('admin.notifications.roleUpdated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

@@ -35,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Notify } from 'quasar';
import { useAdminStore, type Profile } from '@/stores/admin-store'; import { useAdminStore, type Profile } from '@/stores/admin-store';
import type { Status } from '@/stores/profiles-store'; import type { Status } from '@/stores/profiles-store';
@@ -64,6 +65,7 @@ async function save() {
if (!props.profile) return; if (!props.profile) return;
try { try {
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value); await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
Notify.create({ type: 'positive', message: t('admin.notifications.statusUpdated') });
open.value = false; open.value = false;
} catch { } catch {
// adminStore.error already holds the failure message // adminStore.error already holds the failure message

View File

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

View File

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