
This commit includes the following changes: - Deleted the `package-lock.json` file to streamline dependency management. - Updated the `financials.py` endpoint to return a comprehensive user financial summary, including net balance, total group spending, debts, and credits. - Enhanced the `expense.py` CRUD operations to handle enum values and improve error handling during expense deletion. - Introduced new schemas in `financials.py` for user financial summaries and debt/credit tracking. - Refactored the costs service to improve group balance summary calculations. These changes aim to improve the application's financial tracking capabilities and maintain cleaner dependency management.
233 lines
6.4 KiB
Vue
233 lines
6.4 KiB
Vue
<template>
|
|
<main class="flex items-center justify-center page-container">
|
|
<div class="card login-card">
|
|
<div class="card-header">
|
|
<h3>mitlist</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<form @submit.prevent="onSubmit" class="form-layout">
|
|
<div class="form-group mb-2">
|
|
<Input v-model="email" :label="t('loginPage.emailLabel')" type="email" id="email" :error="formErrors.email"
|
|
autocomplete="email" />
|
|
</div>
|
|
|
|
<div class="form-group mb-3">
|
|
<Input v-model="password" :label="t('loginPage.passwordLabel')" :type="isPwdVisible ? 'text' : 'password'"
|
|
id="password" :error="formErrors.password" autocomplete="current-password" />
|
|
<button type="button" class="absolute right-3 top-[34px] text-sm text-gray-500"
|
|
@click="isPwdVisible = !isPwdVisible" :aria-label="t('loginPage.togglePasswordVisibilityLabel')">
|
|
{{ isPwdVisible ? 'Hide' : 'Show' }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="formErrors.general" class="alert alert-error form-error-text">{{ formErrors.general }}</p>
|
|
|
|
<Button type="submit" :disabled="loading" class="w-full mt-2">
|
|
<span v-if="loading" class="animate-pulse">{{ t('loginPage.loginButton') }}</span>
|
|
<span v-else>{{ t('loginPage.loginButton') }}</span>
|
|
</Button>
|
|
|
|
<div class="divider my-3">or</div>
|
|
|
|
<Button variant="outline" color="secondary" class="w-full" :disabled="loading" @click="handleGuestLogin">
|
|
Continue as Guest
|
|
</Button>
|
|
|
|
<button type="button" class="btn btn-outline w-full mt-2" @click="openSheet">
|
|
Email Magic Link
|
|
</button>
|
|
|
|
<AuthenticationSheet v-model="isSheetOpen" ref="sheet" />
|
|
|
|
<div class="text-center mt-2">
|
|
<router-link to="/auth/signup" class="link-styled">{{ t('loginPage.signupLink') }}</router-link>
|
|
</div>
|
|
|
|
<SocialLoginButtons />
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { useNotificationStore } from '@/stores/notifications';
|
|
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
|
|
import AuthenticationSheet from '@/components/AuthenticationSheet.vue';
|
|
import { Input, Button } from '@/components/ui';
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const authStore = useAuthStore();
|
|
const notificationStore = useNotificationStore();
|
|
const { t, locale, messages } = useI18n();
|
|
|
|
const email = ref('');
|
|
const password = ref('');
|
|
const isPwdVisible = ref(false);
|
|
const loading = ref(false);
|
|
const isSheetOpen = ref(false);
|
|
const formErrors = ref<{ email?: string; password?: string; general?: string }>({});
|
|
|
|
const sheet = ref<InstanceType<typeof AuthenticationSheet>>()
|
|
function openSheet() {
|
|
isSheetOpen.value = true;
|
|
}
|
|
|
|
const isValidEmail = (val: string): boolean => {
|
|
const emailPattern = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/;
|
|
return emailPattern.test(val);
|
|
};
|
|
|
|
const validateForm = (): boolean => {
|
|
formErrors.value = {};
|
|
if (!email.value.trim()) {
|
|
formErrors.value.email = t('loginPage.errors.emailRequired');
|
|
} else if (!isValidEmail(email.value)) {
|
|
formErrors.value.email = t('loginPage.errors.emailInvalid');
|
|
}
|
|
if (!password.value) {
|
|
formErrors.value.password = t('loginPage.errors.passwordRequired');
|
|
}
|
|
return Object.keys(formErrors.value).length === 0;
|
|
};
|
|
|
|
const onSubmit = async () => {
|
|
if (!validateForm()) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
formErrors.value.general = undefined;
|
|
try {
|
|
await authStore.login(email.value, password.value);
|
|
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
|
|
const redirectPath = (route.query.redirect as string) || '/';
|
|
router.push(redirectPath);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : t('loginPage.errors.loginFailed');
|
|
formErrors.value.general = message;
|
|
console.error(message, error);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleGuestLogin = async () => {
|
|
loading.value = true;
|
|
formErrors.value.general = undefined;
|
|
try {
|
|
await authStore.loginAsGuest();
|
|
notificationStore.addNotification({ message: 'Welcome, Guest!', type: 'success' });
|
|
const redirectPath = (route.query.redirect as string) || '/';
|
|
router.push(redirectPath);
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : 'Failed to login as guest.';
|
|
formErrors.value.general = message;
|
|
console.error(message, error);
|
|
notificationStore.addNotification({ message, type: 'error' });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-container {
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.login-card {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
}
|
|
|
|
.divider {
|
|
display: flex;
|
|
align-items: center;
|
|
text-align: center;
|
|
color: #ccc;
|
|
font-size: 0.8em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.divider::before,
|
|
.divider::after {
|
|
content: '';
|
|
flex: 1;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.divider:not(:empty)::before {
|
|
margin-right: .5em;
|
|
}
|
|
|
|
.divider:not(:empty)::after {
|
|
margin-left: .5em;
|
|
}
|
|
|
|
.link-styled {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
border-bottom: 2px solid transparent;
|
|
transition: border-color var(--transition-speed) var(--transition-ease-out);
|
|
}
|
|
|
|
.link-styled:hover,
|
|
.link-styled:focus {
|
|
border-bottom-color: var(--primary);
|
|
}
|
|
|
|
.form-error-text {
|
|
color: var(--danger);
|
|
font-size: 0.85rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.alert.form-error-text {
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.input-with-icon-append {
|
|
position: relative;
|
|
display: flex;
|
|
}
|
|
|
|
.input-with-icon-append .form-input {
|
|
padding-right: 3rem;
|
|
}
|
|
|
|
.icon-append-btn {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 3rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-left: var(--border);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--dark);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.icon-append-btn:hover,
|
|
.icon-append-btn:focus {
|
|
opacity: 1;
|
|
background-color: rgba(0, 0, 0, 0.03);
|
|
}
|
|
|
|
.icon-append-btn .icon {
|
|
margin: 0;
|
|
}
|
|
</style> |