feat(auth): adds local account authentication

This commit is contained in:
2025-05-12 15:45:12 -04:00
parent 6d7282870d
commit fdfca7c757
24 changed files with 1446 additions and 279 deletions

View File

@@ -3,10 +3,12 @@ import {ref, markRaw} from 'vue';
import {useCreatorProfileStore} from '@/stores/creatorProfileStore.js';
import {useUserProfileStore} from "@/stores/userProfileStore.js";
import {useClient} from '@/plugins/api.js';
import {useRouter} from 'vue-router';
import SocialsDialog from './creators/SocialsDialog.vue';
import AliasDialog from "@/views/profile/account/AliasDialog.vue";
import FullnameDialog from "@/views/profile/account/FullnameDialog.vue";
import EmailDialog from "@/views/profile/account/EmailDialog.vue";
import ChangePasswordDialog from "@/views/profile/account/ChangePasswordDialog.vue";
import ChangeStripeIdDialog from '@/views/profile/creators/ChangeStripeIdDialog.vue';
import ChangeNameDialog from '@/views/profile/creators/ChangeNameDialog.vue';
import ChangeSlugDialog from '@/views/profile/creators/ChangeSlugDialog.vue';
@@ -25,6 +27,7 @@ import {useI18n} from 'vue-i18n';
import QRCodeVue from 'qrcode.vue';
const {t} = useI18n();
const router = useRouter();
const userProfileStore = useUserProfileStore()
const creatorProfileStore = useCreatorProfileStore();
const baseURL = window.location.origin;
@@ -102,6 +105,7 @@ const deleteDialogShown = ref(false);
const componentsMap = {
EmailDialog: markRaw(EmailDialog),
ChangePasswordDialog: markRaw(ChangePasswordDialog),
SocialsDialog: markRaw(SocialsDialog),
ChangeSlugDialog: markRaw(ChangeSlugDialog),
ChangeNameDialog: markRaw(ChangeNameDialog),
@@ -251,6 +255,14 @@ async function deconfigureStripe() {
</button>
</div>
<div class="content">
<button class="action" @click="openDialog('ChangePasswordDialog')">
<span class="label">{{ t('changePassword') }}</span>
<span class="value"></span>
<span class="chevron"><v-icon>mdi-chevron-right</v-icon></span>
</button>
</div>
</div>
<template v-if="creatorProfileStore.hasCreator">
@@ -665,6 +677,7 @@ async function deconfigureStripe() {
"fullName": "Full Name",
"alias": "Alias",
"email": "Email",
"changePassword": "Set Password",
"creatorInfo": "Creator Information",
"dangerZone": "Danger Zone",
"dangerZoneWarning": "The actions below can have significant impacts on your creator page. Please proceed with caution.",
@@ -693,6 +706,7 @@ async function deconfigureStripe() {
"fullName": "Nom Complet",
"alias": "Alias",
"email": "Email",
"changePassword": "Définir le mot de passe",
"creatorInfo": "Informations du Créateur",
"dangerZone": "Zone de Danger",
"dangerZoneWarning": "Les actions ci-dessous peuvent avoir des impacts significatifs sur votre page de créateur. Veuillez procéder avec précaution.",
@@ -721,6 +735,7 @@ async function deconfigureStripe() {
"fullName": "Nombre Completo",
"alias": "Alias",
"email": "Correo Electrónico",
"changePassword": "Establecer contraseña",
"creatorInfo": "Información del Creador",
"dangerZone": "Zona de Peligro",
"dangerZoneWarning": "Las acciones a continuación pueden tener impactos significativos en tu página de creador. Por favor, procede con precaución.",

View File

@@ -1,55 +0,0 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="address"
:label="t('label')"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary" @click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary" @click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['address'])
const emit = defineEmits(['close', 'save'])
const address = ref(props.address);
const requestClose = () => emit('close')
const requestSave = () => emit('save', address.value)
</script>
<i18n>
{
"en": {
"title": "Address",
"label": "Your address"
},
"fr": {
"title": "Adresse",
"label": "Votre adresse"
},
"es": {
"title": "Dirección",
"label": "Tu dirección"
}
}
</i18n>

View File

@@ -1,55 +0,0 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="birthDate"
:label="t('label')"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary" @click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary" @click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {useI18n} from 'vue-i18n';
const {t} = useI18n();
const props = defineProps(['birthDate'])
const emit = defineEmits(['close', 'save'])
const birthDate = ref(props.birthDate)
const requestClose = () => emit('close')
const requestSave = () => emit('save', birthDate.value)
</script>
<i18n>
{
"en": {
"title": "Birthday",
"label": "Your birthday"
},
"fr": {
"title": "Date de naissance",
"label": "Votre date de naissance"
},
"es": {
"title": "Fecha de nacimiento",
"label": "Tu fecha de nacimiento"
}
}
</i18n>

View File

@@ -0,0 +1,183 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('changePassword') }}
</div>
<div class="card-content">
<p class="description mb-4">{{ t('passwordDescription') }}</p>
<v-text-field
v-model="newPassword"
:label="t('newPassword')"
:type="showNewPassword ? 'text' : 'password'"
variant="outlined"
required
:hint="t('passwordRequirements')"
>
<template v-slot:append-inner>
<v-icon
@click="showNewPassword = !showNewPassword"
class="visibility-toggle"
size="small"
>
{{ showNewPassword ? 'mdi-eye-off' : 'mdi-eye' }}
</v-icon>
</template>
</v-text-field>
<v-text-field
v-model="confirmPassword"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
variant="outlined"
required
>
<template v-slot:append-inner>
<v-icon
@click="showConfirmPassword = !showConfirmPassword"
class="visibility-toggle"
size="small"
>
{{ showConfirmPassword ? 'mdi-eye-off' : 'mdi-eye' }}
</v-icon>
</template>
</v-text-field>
<div v-if="errorMessage" class="error-message mb-4">
{{ errorMessage }}
</div>
<div class="card-actions">
<button class="secondary" @click="$emit('closeRequested')">
{{ t('cancel') }}
</button>
<button class="primary" @click="handleChangePassword" :disabled="isLoading">
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore.js';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const authStore = useAuthStore();
const emit = defineEmits(['closeRequested']);
const newPassword = ref('');
const confirmPassword = ref('');
const isLoading = ref(false);
const errorMessage = ref('');
const showNewPassword = ref(false);
const showConfirmPassword = ref(false);
async function handleChangePassword() {
// Clear previous error
errorMessage.value = '';
// Validate passwords match
if (newPassword.value !== confirmPassword.value) {
errorMessage.value = t('passwordsDoNotMatch');
return;
}
// Validate password length
if (newPassword.value.length < 8) {
errorMessage.value = t('passwordTooShort');
return;
}
isLoading.value = true;
try {
// Pass empty string for current password since we're already authenticated
// This will use the set-password endpoint for OAuth users
await authStore.changePassword(newPassword.value);
// Success - close dialog
emit('closeRequested');
// You could also emit a success event if needed
// emit('success');
} catch (error) {
console.error('Failed to change password:', error);
// Use error message from response if available, or the error message itself, or fallback
errorMessage.value = error.response?.data || error.message || t('passwordUpdateFailed');
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
.dialog {
@apply max-w-md mx-auto;
}
.error-message {
@apply text-red-500 text-sm mt-2;
}
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@apply opacity-60 hover:opacity-100;
@apply absolute right-2 top-1/2 transform -translate-y-1/2;
@apply z-10;
}
/* Override Vuetify's default padding to accommodate our icon */
:deep(.v-field__append-inner) {
padding-inline-start: 0;
}
</style>
<i18n>
{
"en": {
"changePassword": "Set Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"passwordRequirements": "Password must be at least 8 characters",
"passwordDescription": "Setting a password allows you to log in directly with your email and password, even if you originally signed up with Google.",
"save": "Save",
"cancel": "Cancel",
"passwordsDoNotMatch": "New passwords do not match",
"passwordTooShort": "Password must be at least 8 characters long",
"passwordUpdateFailed": "Failed to update password. Please try again."
},
"fr": {
"changePassword": "Définir le mot de passe",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le nouveau mot de passe",
"passwordRequirements": "Le mot de passe doit comporter au moins 8 caractères",
"passwordDescription": "La définition d'un mot de passe vous permet de vous connecter directement avec votre email et mot de passe, même si vous vous êtes initialement inscrit avec Google.",
"save": "Enregistrer",
"cancel": "Annuler",
"passwordsDoNotMatch": "Les nouveaux mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit comporter au moins 8 caractères",
"passwordUpdateFailed": "Échec de la mise à jour du mot de passe. Veuillez réessayer."
},
"es": {
"changePassword": "Establecer contraseña",
"newPassword": "Nueva contraseña",
"confirmPassword": "Confirmar nueva contraseña",
"passwordRequirements": "La contraseña debe tener al menos 8 caracteres",
"passwordDescription": "Establecer una contraseña le permite iniciar sesión directamente con su correo electrónico y contraseña, incluso si originalmente se registró con Google.",
"save": "Guardar",
"cancel": "Cancelar",
"passwordsDoNotMatch": "Las nuevas contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"passwordUpdateFailed": "Error al actualizar la contraseña. Por favor, inténtelo de nuevo."
}
}
</i18n>

View File

@@ -1,55 +0,0 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<v-text-field
variant="outlined"
v-model="phone"
:label="t('label')"
></v-text-field>
</div>
<div class="card-actions">
<button class="secondary" @click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary" @click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['phone'])
const emit = defineEmits(['close', 'save'])
const phone = ref(props.phone)
const requestClose = () => emit('close')
const requestSave = () => emit('save', phone.value)
</script>
<i18n>
{
"en": {
"title": "Phone Number",
"label": "Your phone number"
},
"fr": {
"title": "Numéro de téléphone",
"label": "Votre numéro de téléphone"
},
"es": {
"title": "Número de teléfono",
"label": "Tu número de teléfono"
}
}
</i18n>

View File

@@ -1,80 +0,0 @@
<template>
<div class="card dialog">
<div class="card-title">
{{ t('title') }}
</div>
<div class="card-content">
<img
:src="portraitData"
class="mb-5 w-full transition duration-200 ease-in-out transform"
:alt="t('preview')"
/>
<v-file-input
@change="onSelectedFileChanged"
v-model="selectedFile"
variant="outlined"
accept="image/*"
:label="t('label')"
></v-file-input>
</div>
<div class="card-actions">
<button class="secondary" @click="requestClose">
{{ t('cancel') }}
</button>
<button class="primary" @click="requestSave">
{{ t('save') }}
</button>
</div>
</div>
</template>
<script setup>
import {ref} from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['portraitUrl'])
const emit = defineEmits(['close', 'save'])
const portraitData = ref(props.portraitUrl)
const selectedFile = ref({})
const onSelectedFileChanged = () => {
if (selectedFile.value) {
const reader = new FileReader()
reader.onload = (event) => {
portraitData.value = event.target.result
}
reader.readAsDataURL(selectedFile.value)
} else {
portraitData.value = null
}
}
const requestClose = () => emit('close')
const requestSave = () => emit('save', selectedFile.value)
</script>
<i18n>
{
"en": {
"title": "Profile Picture",
"preview": "Profile picture preview",
"label": "Choose a profile picture"
},
"fr": {
"title": "Photo de profil",
"preview": "Aperçu de la photo de profil",
"label": "Choisir une photo de profil"
},
"es": {
"title": "Foto de perfil",
"preview": "Vista previa de la foto de perfil",
"label": "Elegir una foto de perfil"
}
}
</i18n>