feat: implement user profile management with avatars, login, and personal data
- Add avatar upload functionality with public URL access. - Implement user login with email and password authentication. - Create endpoints for fetching and updating the authenticated user's profile. - Set up database migrations for user profiles, including email and role management. - Introduce personal data management linked to user profiles. - Add email verification process with welcome email templates.
This commit is contained in:
23
frontend/src/pages/ErrorNotFound.vue
Normal file
23
frontend/src/pages/ErrorNotFound.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
404
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.4">
|
||||
{{ $t('pages.notFound') }}
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
class="q-mt-xl"
|
||||
color="white"
|
||||
text-color="blue"
|
||||
unelevated
|
||||
to="/"
|
||||
:label="$t('pages.goHome')"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
35
frontend/src/pages/ErrorUnauthorized.vue
Normal file
35
frontend/src/pages/ErrorUnauthorized.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="fullscreen bg-deep-orange text-white text-center q-pa-md flex flex-center">
|
||||
<div>
|
||||
<div style="font-size: 30vh">
|
||||
401
|
||||
</div>
|
||||
|
||||
<div class="text-h2" style="opacity:.55">
|
||||
{{ $t('pages.unauthorized') }}
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle1 q-mt-md" style="opacity:.8">
|
||||
{{ $t('pages.unauthorizedHint') }}
|
||||
</div>
|
||||
|
||||
<div class="row justify-center q-gutter-sm q-mt-xl">
|
||||
<q-btn
|
||||
color="white"
|
||||
text-color="deep-orange"
|
||||
unelevated
|
||||
to="/login"
|
||||
:label="$t('nav.login')"
|
||||
no-caps
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
color="white"
|
||||
to="/"
|
||||
:label="$t('pages.goHome')"
|
||||
no-caps
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/src/pages/IndexPage.vue
Normal file
19
frontend/src/pages/IndexPage.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<div class="column items-center">
|
||||
|
||||
<q-btn
|
||||
class="q-mt-md"
|
||||
color="primary"
|
||||
to="/second"
|
||||
:label="$t('pages.goToSecond')"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
78
frontend/src/pages/LoginPage.vue
Normal file
78
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('login.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
|
||||
<q-banner v-if="registered" class="bg-positive text-white" rounded>
|
||||
{{ t('register.success') }}
|
||||
</q-banner>
|
||||
|
||||
<q-input
|
||||
v-model="email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:rules="[(val) => !!val || t('fields.email')]"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:rules="[(val) => !!val || t('fields.password')]"
|
||||
/>
|
||||
|
||||
<q-banner v-if="store.error" class="bg-negative text-white" rounded>
|
||||
{{ store.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('nav.login')"
|
||||
:loading="store.loading"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/register" :label="t('login.needAccount')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useProfilesStore } from '@/stores/profiles-store';
|
||||
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const store = useProfilesStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const registered = route.query.registered === '1';
|
||||
|
||||
if (typeof route.query.email === 'string') {
|
||||
email.value = route.query.email;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await store.login({ user_email: email.value, password: password.value });
|
||||
await router.push('/');
|
||||
} catch {
|
||||
// store.error already holds the failure message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
frontend/src/pages/ProfilePage.vue
Normal file
69
frontend/src/pages/ProfilePage.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="text-h5 q-mb-md">{{ t('profile.title') }}</div>
|
||||
|
||||
<q-banner v-if="profilesStore.error" class="bg-negative text-white q-mb-md" rounded>
|
||||
{{ profilesStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-card v-if="profilesStore.profile" flat bordered style="max-width: 480px">
|
||||
<q-card-section class="q-gutter-md">
|
||||
<AvatarUpload v-model="form.avatar_url" :uploader="profilesStore.uploadAvatar" />
|
||||
|
||||
<q-input v-model="form.display_name" :label="t('fields.displayName')" />
|
||||
|
||||
<div class="text-caption text-grey">
|
||||
{{ profilesStore.profile.email }} · {{ profilesStore.profile.role }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn color="primary" :label="t('actions.save')" :loading="profilesStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
||||
<div v-else-if="!profilesStore.isAuthenticated" class="text-grey">
|
||||
{{ t('profile.loginRequired') }}
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useProfilesStore, type Profile } from '@/stores/profiles-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const profilesStore = useProfilesStore();
|
||||
|
||||
const form = reactive({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
function syncForm(profile: Profile | null) {
|
||||
form.display_name = profile?.display_name ?? '';
|
||||
form.avatar_url = profile?.avatar_url ?? '';
|
||||
}
|
||||
|
||||
watch(() => profilesStore.profile, syncForm, { immediate: true });
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await profilesStore.update({ ...form });
|
||||
} catch {
|
||||
// profilesStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profilesStore.isAuthenticated && !profilesStore.profile) {
|
||||
try {
|
||||
await profilesStore.me();
|
||||
} catch {
|
||||
// profilesStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
170
frontend/src/pages/RegisterPage.vue
Normal file
170
frontend/src/pages/RegisterPage.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
|
||||
<template v-if="success">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('register.welcomeTitle') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-banner class="bg-positive text-white" rounded>
|
||||
{{ t('register.confirmationSent') }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('register.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form class="column q-gutter-md" @submit.prevent="onSubmit">
|
||||
<q-input
|
||||
ref="emailInputRef"
|
||||
v-model="form.email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:error="Boolean(fieldErrors.email)"
|
||||
:error-message="fieldErrors.email"
|
||||
:loading="checkingEmail"
|
||||
@blur="checkEmailAvailability"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:error="Boolean(fieldErrors.password)"
|
||||
:error-message="fieldErrors.password"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="form.display_name"
|
||||
:label="t('fields.displayName')"
|
||||
:error="Boolean(fieldErrors.display_name)"
|
||||
:error-message="fieldErrors.display_name"
|
||||
/>
|
||||
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:label="t('actions.register')"
|
||||
:loading="loading || checkingEmail"
|
||||
class="full-width"
|
||||
no-caps
|
||||
/>
|
||||
|
||||
<q-btn flat no-caps to="/login" :label="t('register.haveAccount')" />
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</template>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Client, { Local, type registration } from '@/encore/client';
|
||||
import { RegistrationRegisterParamsSchema } from '@/encore/zod';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const { t } = useI18n();
|
||||
const client = new Client(Local);
|
||||
const emailInputRef = ref<Focusable | null>(null);
|
||||
const loading = ref(false);
|
||||
const checkingEmail = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const success = ref(false);
|
||||
const fieldErrors = reactive<Partial<Record<keyof registration.RegisterParams, string>>>({});
|
||||
const form = reactive<registration.RegisterParams>({
|
||||
email: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
emailInputRef.value?.focus();
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
const params = validateForm();
|
||||
if (!params) return;
|
||||
const emailAvailable = await checkEmailAvailability();
|
||||
if (!emailAvailable) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
try {
|
||||
await client.registration.Register(params);
|
||||
success.value = true;
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEmailAvailability(): Promise<boolean> {
|
||||
const emailResult = RegistrationRegisterParamsSchema.shape.email.safeParse(form.email);
|
||||
if (!emailResult.success) return false;
|
||||
|
||||
checkingEmail.value = true;
|
||||
fieldErrors.email = '';
|
||||
try {
|
||||
const res = await client.registration.CheckEmail(form.email);
|
||||
if (!res.mx_valid) {
|
||||
fieldErrors.email = t('register.emailDomainInvalid');
|
||||
return false;
|
||||
}
|
||||
if (!res.available) {
|
||||
fieldErrors.email = t('register.emailUnavailable');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
fieldErrors.email = err instanceof Error ? err.message : String(err);
|
||||
return false;
|
||||
} finally {
|
||||
checkingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): registration.RegisterParams | null {
|
||||
clearFieldErrors();
|
||||
const result = RegistrationRegisterParamsSchema.safeParse({ ...form });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const flattened = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(flattened) as (keyof registration.RegisterParams)[]) {
|
||||
fieldErrors[key] = flattened[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearFieldErrors() {
|
||||
fieldErrors.email = '';
|
||||
fieldErrors.password = '';
|
||||
fieldErrors.display_name = '';
|
||||
fieldErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.email = '';
|
||||
form.password = '';
|
||||
form.display_name = '';
|
||||
form.avatar_url = '';
|
||||
}
|
||||
</script>
|
||||
9
frontend/src/pages/SecondPage.vue
Normal file
9
frontend/src/pages/SecondPage.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-btn color="secondary" to="/" :label="$t('pages.goToIndex')" no-caps />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//
|
||||
</script>
|
||||
60
frontend/src/pages/WelcomePage.vue
Normal file
60
frontend/src/pages/WelcomePage.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center">
|
||||
<q-card class="q-pa-md" style="width: 100%; max-width: 440px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('welcome.title') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-inner-loading :showing="loading" />
|
||||
|
||||
<q-banner v-if="success" class="bg-positive text-white" rounded>
|
||||
{{ t('welcome.success', { email }) }}
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-else-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-else-if="!loading" class="bg-warning text-white" rounded>
|
||||
{{ t('welcome.missingToken') }}
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn color="primary" to="/login" :label="t('nav.login')" no-caps />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Client, { Local } from '@/encore/client';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const client = new Client(Local);
|
||||
const loading = ref(false);
|
||||
const success = ref(false);
|
||||
const error = ref('');
|
||||
const email = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : '';
|
||||
if (!token) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await client.registration.ConfirmWelcome(token);
|
||||
success.value = res.confirmed;
|
||||
email.value = res.email;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
11
frontend/src/pages/admin/DashboardPage.vue
Normal file
11
frontend/src/pages/admin/DashboardPage.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="text-h5">{{ t('admin.dashboard') }}</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
151
frontend/src/pages/admin/ProfilesPage.vue
Normal file
151
frontend/src/pages/admin/ProfilesPage.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">{{ t('admin.profiles') }}</div>
|
||||
<q-space />
|
||||
<q-btn color="primary" icon="add" :label="t('actions.newProfile')" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<q-banner v-if="adminStore.error" class="bg-negative text-white q-mb-md" rounded>
|
||||
{{ adminStore.error }}
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:rows="adminStore.profiles"
|
||||
:columns="columns"
|
||||
row-key="user_id"
|
||||
:loading="adminStore.loading"
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge :color="statusInfo(props.value).color">{{ statusLabel(props.value) }}</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-is_artist="props">
|
||||
<q-td :props="props">
|
||||
<q-icon
|
||||
:name="props.value ? 'check_circle' : 'cancel'"
|
||||
:color="props.value ? 'positive' : 'grey-5'"
|
||||
size="sm"
|
||||
>
|
||||
<q-tooltip>{{ props.value ? t('admin.artist') : t('admin.notArtist') }}</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-avatar_url="props">
|
||||
<q-td :props="props" auto-width>
|
||||
<q-avatar size="32px">
|
||||
<img v-if="props.value" :src="props.value" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
|
||||
<q-btn flat round dense icon="more_vert" size="sm" class="q-ml-xs">
|
||||
<q-menu>
|
||||
<q-list>
|
||||
<q-item v-close-popup clickable @click="openEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.editProfile') }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-close-popup clickable @click="openRoleEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updateRole') }}</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-close-popup clickable @click="openStatusEdit(props.row)">
|
||||
<q-item-section>{{ t('actions.updateStatus') }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<EditProfileDialog v-model="editDialogOpen" :profile="editingProfile" />
|
||||
<UpdateRoleDialog v-model="roleDialogOpen" :profile="roleEditingProfile" />
|
||||
<UpdateStatusDialog v-model="statusDialogOpen" :profile="statusEditingProfile" />
|
||||
<CreateProfileDialog v-model="createDialogOpen" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import { useAdminStore, type Profile } from '@/stores/admin-store';
|
||||
import { statusInfo } from '@/stores/profiles-store';
|
||||
import CreateProfileDialog from './dialogs/CreateProfileDialog.vue';
|
||||
import EditProfileDialog from './dialogs/EditProfileDialog.vue';
|
||||
import UpdateRoleDialog from './dialogs/UpdateRoleDialog.vue';
|
||||
import UpdateStatusDialog from './dialogs/UpdateStatusDialog.vue';
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const editDialogOpen = ref(false);
|
||||
const editingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openEdit(profile: Profile) {
|
||||
editingProfile.value = profile;
|
||||
editDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const createDialogOpen = ref(false);
|
||||
|
||||
function openCreate() {
|
||||
createDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const roleDialogOpen = ref(false);
|
||||
const roleEditingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openRoleEdit(profile: Profile) {
|
||||
roleEditingProfile.value = profile;
|
||||
roleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
const statusDialogOpen = ref(false);
|
||||
const statusEditingProfile = ref<Profile | null>(null);
|
||||
|
||||
function openStatusEdit(profile: Profile) {
|
||||
statusEditingProfile.value = profile;
|
||||
statusDialogOpen.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 },
|
||||
{ name: 'email', label: t('admin.columns.email'), field: 'email', align: 'left', sortable: true },
|
||||
{ name: 'role', label: t('admin.columns.role'), field: 'role', align: 'left', sortable: true },
|
||||
{ name: 'status', label: t('admin.columns.status'), field: 'status', align: 'left', sortable: true },
|
||||
{ name: 'is_artist', label: t('admin.columns.artist'), field: 'is_artist', align: 'center', sortable: true },
|
||||
{
|
||||
name: 'created_at',
|
||||
label: t('admin.columns.created'),
|
||||
field: 'created_at',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
format: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
label: t('admin.columns.updated'),
|
||||
field: 'updated_at',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
format: (val: string) => new Date(val).toLocaleString(),
|
||||
},
|
||||
]);
|
||||
|
||||
function statusLabel(status: number) {
|
||||
const key = `status.${status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('status.unknown', { status }) : translated;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void adminStore.listProfiles();
|
||||
void adminStore.fetchSystemOptions();
|
||||
});
|
||||
</script>
|
||||
86
frontend/src/pages/admin/dialogs/CreateProfileDialog.vue
Normal file
86
frontend/src/pages/admin/dialogs/CreateProfileDialog.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.newProfile') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-input
|
||||
ref="emailInputRef"
|
||||
v-model="createForm.email"
|
||||
:label="t('fields.email')"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
name="new-profile-email"
|
||||
/>
|
||||
<q-input
|
||||
v-model="createForm.password"
|
||||
:label="t('fields.password')"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="new-profile-password"
|
||||
/>
|
||||
<q-input v-model="createForm.display_name" :label="t('fields.displayName')" />
|
||||
<AvatarUpload v-model="createForm.avatar_url" :uploader="adminStore.uploadAvatar" />
|
||||
|
||||
<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.create')" :loading="adminStore.loading" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAdminStore, type RegisterParams } from '@/stores/admin-store';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const emailInputRef = ref<Focusable | null>(null);
|
||||
|
||||
const createForm = reactive<RegisterParams>({
|
||||
email: '',
|
||||
password: '',
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await adminStore.insertProfile({ ...createForm });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
createForm.email = '';
|
||||
createForm.password = '';
|
||||
createForm.display_name = '';
|
||||
createForm.avatar_url = '';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
emailInputRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
249
frontend/src/pages/admin/dialogs/EditProfileDialog.vue
Normal file
249
frontend/src/pages/admin/dialogs/EditProfileDialog.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section v-if="profile" class="row items-center q-gutter-sm">
|
||||
<q-avatar size="48px">
|
||||
<img v-if="editForm.avatar_url" :src="editForm.avatar_url" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
<div class="text-caption text-grey">{{ profile.role }} · {{ profile.user_id }}</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-tabs v-model="editTab" align="left" class="text-primary" dense>
|
||||
<q-tab name="profile" :label="t('admin.profile')" />
|
||||
<q-tab name="personal" :label="t('admin.personalData')" />
|
||||
</q-tabs>
|
||||
<q-separator />
|
||||
|
||||
<q-tab-panels v-model="editTab" animated>
|
||||
<q-tab-panel name="profile" class="q-gutter-md">
|
||||
<AvatarUpload v-model="editForm.avatar_url" :uploader="adminStore.uploadAvatar" />
|
||||
<div v-if="editFormErrors.avatar_url" class="text-negative text-caption">
|
||||
{{ editFormErrors.avatar_url }}
|
||||
</div>
|
||||
<q-input
|
||||
ref="displayNameInputRef"
|
||||
v-model="editForm.display_name"
|
||||
:label="t('fields.displayName')"
|
||||
:error="Boolean(editFormErrors.display_name)"
|
||||
:error-message="editFormErrors.display_name"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
|
||||
<q-tab-panel name="personal" class="q-gutter-md">
|
||||
<q-input
|
||||
ref="firstNameInputRef"
|
||||
v-model="personalForm.first_name"
|
||||
:label="t('fields.firstName')"
|
||||
:error="Boolean(personalFormErrors.first_name)"
|
||||
:error-message="personalFormErrors.first_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.last_name"
|
||||
:label="t('fields.lastName')"
|
||||
:error="Boolean(personalFormErrors.last_name)"
|
||||
:error-message="personalFormErrors.last_name"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.address"
|
||||
:label="t('fields.address')"
|
||||
:error="Boolean(personalFormErrors.address)"
|
||||
:error-message="personalFormErrors.address"
|
||||
/>
|
||||
<q-input
|
||||
v-model="personalForm.city"
|
||||
:label="t('fields.city')"
|
||||
:error="Boolean(personalFormErrors.city)"
|
||||
:error-message="personalFormErrors.city"
|
||||
/>
|
||||
<q-select
|
||||
v-model="personalForm.country"
|
||||
:options="filteredCountries"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
use-input
|
||||
clearable
|
||||
input-debounce="200"
|
||||
:label="t('fields.country')"
|
||||
:error="Boolean(personalFormErrors.country)"
|
||||
:error-message="personalFormErrors.country"
|
||||
@filter="filterCountries"
|
||||
/>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
|
||||
<q-card-section v-if="adminStore.error">
|
||||
<q-banner 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, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAdminStore, type PersonalDataParams, type Profile, type ProfileParams } from '@/stores/admin-store';
|
||||
import { countries } from '@/data/countries';
|
||||
import { AdminPersonalDataParamsSchema, AdminProfileParamsSchema } from '@/encore/zod';
|
||||
import AvatarUpload from '@/components/AvatarUpload.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
type CountryOption = { label: string; value: string };
|
||||
type EditTab = 'profile' | 'personal';
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const countryOptions: CountryOption[] = countries.map((c) => {
|
||||
const [value, label] = Object.entries(c)[0] as [string, string];
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const filteredCountries = ref<CountryOption[]>(countryOptions);
|
||||
const editTab = ref<EditTab>('profile');
|
||||
const displayNameInputRef = ref<Focusable | null>(null);
|
||||
const firstNameInputRef = ref<Focusable | null>(null);
|
||||
const editForm = reactive<ProfileParams>({
|
||||
display_name: '',
|
||||
avatar_url: '',
|
||||
});
|
||||
const editFormErrors = reactive<Partial<Record<keyof ProfileParams, string>>>({});
|
||||
const personalForm = reactive<PersonalDataParams>({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
address: '',
|
||||
city: '',
|
||||
country: '',
|
||||
});
|
||||
const personalFormErrors = reactive<Partial<Record<keyof PersonalDataParams, string>>>({});
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
void loadProfile(profile);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(editTab, () => {
|
||||
if (open.value) {
|
||||
void focusFirstField();
|
||||
}
|
||||
});
|
||||
|
||||
function filterCountries(val: string, update: (cb: () => void) => void) {
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
filteredCountries.value = needle
|
||||
? countryOptions.filter(
|
||||
(c) => c.label.toLowerCase().includes(needle) || c.value.toLowerCase().includes(needle),
|
||||
)
|
||||
: countryOptions;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProfile(profile: Profile) {
|
||||
editTab.value = 'profile';
|
||||
editForm.display_name = profile.display_name;
|
||||
editForm.avatar_url = profile.avatar_url;
|
||||
clearEditFormErrors();
|
||||
resetPersonalForm();
|
||||
try {
|
||||
const data = await adminStore.getPersonalData(profile.user_id);
|
||||
resetPersonalForm(data);
|
||||
} catch {
|
||||
// No personal data yet (404) — leave the form empty for creation.
|
||||
adminStore.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
if (editTab.value === 'profile') {
|
||||
const parsed = validateEditForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.updateProfile(props.profile.user_id, parsed);
|
||||
} else {
|
||||
const parsed = validatePersonalForm();
|
||||
if (!parsed) return;
|
||||
await adminStore.upsertPersonalData(props.profile.user_id, parsed);
|
||||
}
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
function resetPersonalForm(data?: PersonalDataParams) {
|
||||
personalForm.first_name = data?.first_name ?? '';
|
||||
personalForm.last_name = data?.last_name ?? '';
|
||||
personalForm.address = data?.address ?? '';
|
||||
personalForm.city = data?.city ?? '';
|
||||
personalForm.country = data?.country ?? '';
|
||||
clearPersonalFormErrors();
|
||||
}
|
||||
|
||||
function validateEditForm(): ProfileParams | null {
|
||||
clearEditFormErrors();
|
||||
const result = AdminProfileParamsSchema.safeParse({ ...editForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof ProfileParams)[]) {
|
||||
editFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearEditFormErrors() {
|
||||
editFormErrors.display_name = '';
|
||||
editFormErrors.avatar_url = '';
|
||||
}
|
||||
|
||||
function validatePersonalForm(): PersonalDataParams | null {
|
||||
clearPersonalFormErrors();
|
||||
const result = AdminPersonalDataParamsSchema.safeParse({ ...personalForm });
|
||||
if (result.success) return result.data;
|
||||
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
for (const key of Object.keys(fieldErrors) as (keyof PersonalDataParams)[]) {
|
||||
personalFormErrors[key] = fieldErrors[key]?.[0] ?? '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearPersonalFormErrors() {
|
||||
personalFormErrors.first_name = '';
|
||||
personalFormErrors.last_name = '';
|
||||
personalFormErrors.address = '';
|
||||
personalFormErrors.city = '';
|
||||
personalFormErrors.country = '';
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
if (editTab.value === 'profile') {
|
||||
displayNameInputRef.value?.focus();
|
||||
} else {
|
||||
firstNameInputRef.value?.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
76
frontend/src/pages/admin/dialogs/UpdateRoleDialog.vue
Normal file
76
frontend/src/pages/admin/dialogs/UpdateRoleDialog.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.updateRole') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="profile" class="q-gutter-md">
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
|
||||
<q-select
|
||||
ref="roleSelectRef"
|
||||
v-model="selectedRole"
|
||||
:options="adminStore.roles"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('fields.role')"
|
||||
/>
|
||||
|
||||
<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 selectedRole = ref('');
|
||||
const roleSelectRef = ref<Focusable | null>(null);
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
selectedRole.value = profile.role;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileRole(props.profile.user_id, selectedRole.value);
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
roleSelectRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
77
frontend/src/pages/admin/dialogs/UpdateStatusDialog.vue
Normal file
77
frontend/src/pages/admin/dialogs/UpdateStatusDialog.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<q-dialog v-model="open" @show="focusFirstField">
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ t('actions.updateStatus') }}</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="profile" class="q-gutter-md">
|
||||
<div class="text-subtitle1">{{ profile.email }}</div>
|
||||
|
||||
<q-select
|
||||
ref="statusSelectRef"
|
||||
v-model="selectedStatus"
|
||||
:options="adminStore.updatableStatuses"
|
||||
option-label="name"
|
||||
option-value="value"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('fields.status')"
|
||||
/>
|
||||
|
||||
<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';
|
||||
import type { Status } from '@/stores/profiles-store';
|
||||
|
||||
type Focusable = { focus: () => void };
|
||||
|
||||
const props = defineProps<{
|
||||
profile: Profile | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
const adminStore = useAdminStore();
|
||||
const { t } = useI18n();
|
||||
const selectedStatus = ref<Status>(0);
|
||||
const statusSelectRef = ref<Focusable | null>(null);
|
||||
|
||||
watch(
|
||||
() => [open.value, props.profile] as const,
|
||||
([isOpen, profile]) => {
|
||||
if (isOpen && profile) {
|
||||
selectedStatus.value = profile.status;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!props.profile) return;
|
||||
try {
|
||||
await adminStore.updateProfileStatus(props.profile.user_id, selectedStatus.value);
|
||||
open.value = false;
|
||||
} catch {
|
||||
// adminStore.error already holds the failure message
|
||||
}
|
||||
}
|
||||
|
||||
async function focusFirstField() {
|
||||
await nextTick();
|
||||
statusSelectRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user