feat: Use dynamic site name from app settings in document title and user login form. Fixes #49

Refactor: Use common AuthLayout for resetpassword, setpassword and use login view.
Fix: Accessibility fixes to all auth forms
This commit is contained in:
Abhinav Raut
2025-04-11 01:49:21 +05:30
parent a49038f965
commit bc586fe775
10 changed files with 607 additions and 587 deletions
+21
View File
@@ -0,0 +1,21 @@
<template>
<div class="min-h-screen flex flex-col bg-background">
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[450px]">
<slot></slot>
</div>
</main>
<div class="flex justify-center mb-6">
<a
href="https://libredesk.io/"
class="text-sm text-center text-muted-foreground"
target="_blank"
rel="noopener noreferrer"
>
Powered by Libredesk
</a>
</div>
</div>
</template>
<script setup></script>
+14 -9
View File
@@ -1,16 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router'
import App from '../App.vue'
import OuterApp from '../OuterApp.vue'
import DashboardView from '../views/reports/DashboardView.vue'
import InboxLayout from '../layouts/inbox/InboxLayout.vue'
import SearchView from '../views/search/SearchView.vue'
import UserLoginView from '../views/login/UserLoginView.vue'
import App from '@/App.vue'
import OuterApp from '@/OuterApp.vue'
import DashboardView from '@/views/reports/DashboardView.vue'
import InboxLayout from '@/layouts/inbox/InboxLayout.vue'
import SearchView from '@/views/search/SearchView.vue'
import AccountLayout from '@/layouts/account/AccountLayout.vue'
import AdminLayout from '@/layouts/admin/AdminLayout.vue'
import ResetPasswordView from '@/views/outerapp/ResetPasswordView.vue'
import SetPasswordView from '@/views/outerapp/SetPasswordView.vue'
import UserLoginView from '@/views/auth/UserLoginView.vue'
import ResetPasswordView from '@/views/auth/ResetPasswordView.vue'
import SetPasswordView from '@/views/auth/SetPasswordView.vue'
import InboxView from '@/views/inbox/InboxView.vue'
import ConversationDetailView from '@/views/conversation/ConversationDetailView.vue'
import { useAppSettingsStore } from '@/stores/appSettings'
const routes = [
{
@@ -465,7 +466,11 @@ const router = createRouter({
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title + ' - Libredesk'
// Make page title with the route name and site name
const appSettingsStore = useAppSettingsStore()
const siteName = appSettingsStore.settings?.['app.site_name'] || 'Libredesk'
const pageTitle = to.meta?.title || ''
document.title = `${pageTitle} - ${siteName}`
next()
})
@@ -0,0 +1,143 @@
<template>
<AuthLayout>
<Card class="bg-card box">
<CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">{{
t('auth.resetPassword')
}}</CardTitle>
<p class="text-muted-foreground">{{ t('auth.enterEmailForReset') }}</p>
</div>
<form @submit.prevent="requestResetAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-foreground">{{
t('globals.terms.email')
}}</Label>
<Input
id="email"
type="email"
:placeholder="t('auth.enterEmail')"
v-model.trim="resetForm.email"
:class="{ 'border-destructive': emailHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.sending') }}
</span>
<span v-else>{{ t('auth.sendResetLink') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
<div class="text-center">
<router-link
to="/"
class="text-sm text-primary hover:text-primary/80 transition-all duration-200 ease-in-out"
>
{{ t('auth.backToLogin') }}
</router-link>
</div>
</CardContent>
</Card>
</AuthLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { validateEmail } from '@/utils/strings'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Label } from '@/components/ui/label'
import { useI18n } from 'vue-i18n'
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
const errorMessage = ref('')
const { t } = useI18n()
const isLoading = ref(false)
const emitter = useEmitter()
const router = useRouter()
const resetForm = ref({
email: ''
})
const validateForm = () => {
if (!validateEmail(resetForm.value.email)) {
errorMessage.value = t('globals.messages.invalidEmailAddress')
useTemporaryClass('reset-password-container', 'animate-shake')
return false
}
return true
}
const requestResetAction = async () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
try {
await api.resetPassword({
email: resetForm.value.email
})
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('auth.checkEmailForReset')
})
router.push({ name: 'login' })
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const emailHasError = computed(() => {
return !validateEmail(resetForm.value.email) && resetForm.value.email !== ''
})
</script>
+176
View File
@@ -0,0 +1,176 @@
<template>
<AuthLayout>
<Card class="bg-card box">
<CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">{{
t('auth.setNewPassword')
}}</CardTitle>
<p class="text-muted-foreground">{{ t('auth.enterNewPasswordTwice') }}</p>
</div>
<form @submit.prevent="setPasswordAction" class="space-y-4">
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-foreground">
{{
t('globals.messages.new', {
name: t('globals.terms.password').toLowerCase()
})
}}
</Label>
<Input
id="password"
type="password"
autocomplete="new-password"
:placeholder="t('auth.enterNewPassword')"
v-model="passwordForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="space-y-2">
<Label for="confirmPassword" class="text-sm font-medium text-foreground">{{
t('auth.confirmPassword')
}}</Label>
<Input
id="confirmPassword"
type="password"
autocomplete="new-password"
:placeholder="t('auth.confirmNewPassword')"
v-model="passwordForm.confirmPassword"
:class="{ 'border-destructive': confirmPasswordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.settingPassword') }}
</span>
<span v-else>{{ t('auth.setNewPassword') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
</CardContent>
</Card>
</AuthLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useI18n } from 'vue-i18n'
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
const { t } = useI18n()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const route = useRoute()
const emitter = useEmitter()
const passwordForm = ref({
password: '',
confirmPassword: '',
token: ''
})
onMounted(() => {
passwordForm.value.token = route.query.token
if (!passwordForm.value.token) {
router.push({ name: 'login' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: 'Invalid reset link. Please request a new password reset link.'
})
}
})
const validateForm = () => {
if (!passwordForm.value.password) {
errorMessage.value = t('auth.passwordRequired')
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = t('auth.passwordsDoNotMatch')
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
return true
}
const setPasswordAction = async () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
try {
await api.setPassword({
token: passwordForm.value.token,
password: passwordForm.value.password
})
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('auth.passwordSetSuccess')
})
router.push({ name: 'login' })
} catch (err) {
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const passwordHasError = computed(() => {
return passwordForm.value.password !== '' && passwordForm.value.password.length < 8
})
const confirmPasswordHasError = computed(() => {
return (
passwordForm.value.confirmPassword !== '' &&
passwordForm.value.password !== passwordForm.value.confirmPassword
)
})
</script>
+249
View File
@@ -0,0 +1,249 @@
<template>
<AuthLayout>
<Card
class="bg-card box"
:class="{ 'animate-shake': shakeCard }"
id="login-container"
ref="cardRef"
>
<CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }}
</CardTitle>
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
</div>
<div v-if="enabledOIDCProviders.length" class="space-y-4">
<Button
v-for="oidcProvider in enabledOIDCProviders"
:key="oidcProvider.id"
variant="outline"
type="button"
@click="redirectToOIDC(oidcProvider)"
class="w-full bg-card hover:bg-secondary text-foreground border-border rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
>
<img
:src="oidcProvider.logo_url"
width="20"
class="mr-2"
alt=""
v-if="oidcProvider.logo_url"
/>
{{ oidcProvider.name }}
</Button>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-border"></span>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="px-2 text-muted-foreground bg-card">{{ t('auth.orContinueWith') }}</span>
</div>
</div>
</div>
<form @submit.prevent="loginAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-foreground">{{
t('globals.terms.email')
}}</Label>
<Input
id="email"
type="text"
autocomplete="username"
:placeholder="t('auth.enterEmail')"
v-model.trim="loginForm.email"
:class="{ 'border-destructive': emailHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-foreground">{{
t('auth.password')
}}</Label>
<Input
id="password"
type="password"
autocomplete="current-password"
:placeholder="t('auth.enterPassword')"
v-model="loginForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="flex items-center justify-between">
<router-link
to="/reset-password"
class="text-sm text-primary hover:text-primary/80 transition-all duration-200 ease-in-out"
>
{{ t('auth.forgotPassword') }}
</router-link>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.loggingIn') }}
</span>
<span v-else>{{ t('auth.signInButton') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
</CardContent>
</Card>
</AuthLayout>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { validateEmail } from '@/utils/strings'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useAppSettingsStore } from '@/stores/appSettings'
import AuthLayout from '@/layouts/auth/AuthLayout.vue'
const emitter = useEmitter()
const { t } = useI18n()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const userStore = useUserStore()
const shakeCard = ref(false)
const loginForm = ref({
email: '',
password: ''
})
const oidcProviders = ref([])
const appSettingsStore = useAppSettingsStore()
// Demo build has the credentials prefilled.
const isDemoBuild = import.meta.env.VITE_DEMO_BUILD === 'true'
const demoCredentials = {
email: 'demo@libredesk.io',
password: 'demo@libredesk.io'
}
onMounted(async () => {
// Prefill the login form with demo credentials if it's a demo build
if (isDemoBuild) {
loginForm.value.email = demoCredentials.email
loginForm.value.password = demoCredentials.password
}
fetchOIDCProviders()
})
const fetchOIDCProviders = async () => {
try {
const resp = await api.getAllEnabledOIDC()
oidcProviders.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const redirectToOIDC = (provider) => {
window.location.href = `/api/v1/oidc/${provider.id}/login`
}
const validateForm = () => {
if (!validateEmail(loginForm.value.email) && loginForm.value.email !== 'System') {
errorMessage.value = t('globals.messages.invalidEmailAddress')
useTemporaryClass('login-container', 'animate-shake')
return false
}
if (!loginForm.value.password) {
errorMessage.value = t('globals.messages.cannotBeEmpty', {
name: t('globals.terms.password')
})
useTemporaryClass('login-container', 'animate-shake')
return false
}
return true
}
const loginAction = () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
api
.login({
email: loginForm.value.email,
password: loginForm.value.password
})
.then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' })
})
.catch((error) => {
errorMessage.value = handleHTTPError(error).message
useTemporaryClass('login-container', 'animate-shake')
})
.finally(() => {
isLoading.value = false
})
}
const enabledOIDCProviders = computed(() => {
return oidcProviders.value.filter((provider) => !provider.disabled)
})
const emailHasError = computed(() => {
const email = loginForm.value.email
return email !== 'System' && !validateEmail(email) && email !== ''
})
const passwordHasError = computed(
() => !loginForm.value.password && loginForm.value.password !== ''
)
</script>
-246
View File
@@ -1,246 +0,0 @@
<template>
<div class="min-h-screen flex flex-col bg-background">
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card
class="bg-card border border-border shadow-xl rounded-xl"
:class="{ 'animate-shake': shakeCard }"
id="login-container"
ref="cardRef"
>
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">{{
t('globals.app.name')
}}</CardTitle>
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
</div>
<div v-if="enabledOIDCProviders.length" class="space-y-4">
<Button
v-for="oidcProvider in enabledOIDCProviders"
:key="oidcProvider.id"
variant="outline"
type="button"
@click="redirectToOIDC(oidcProvider)"
class="w-full bg-card hover:bg-secondary text-foreground border-border rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
>
<img
:src="oidcProvider.logo_url"
width="20"
class="mr-2"
alt=""
v-if="oidcProvider.logo_url"
/>
{{ oidcProvider.name }}
</Button>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-border"></span>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="px-2 text-muted-foreground bg-card">{{
t('auth.orContinueWith')
}}</span>
</div>
</div>
</div>
<form @submit.prevent="loginAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-foreground">{{
t('globals.terms.email')
}}</Label>
<Input
id="email"
type="text"
:placeholder="t('auth.enterEmail')"
v-model.trim="loginForm.email"
:class="{ 'border-destructive': emailHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-foreground">{{
t('auth.password')
}}</Label>
<Input
id="password"
type="password"
:placeholder="t('auth.enterPassword')"
v-model="loginForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="flex items-center justify-between">
<router-link
to="/reset-password"
class="text-sm text-primary hover:text-primary/80 transition-all duration-200 ease-in-out"
>
{{ t('auth.forgotPassword') }}
</router-link>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.loggingIn') }}
</span>
<span v-else>{{ t('auth.signInButton') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
</CardContent>
</Card>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { validateEmail } from '@/utils/strings'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { useI18n } from 'vue-i18n'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter()
const { t } = useI18n()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const userStore = useUserStore()
const shakeCard = ref(false)
const loginForm = ref({
email: '',
password: ''
})
const oidcProviders = ref([])
const isDemoBuild = import.meta.env.VITE_DEMO_BUILD === 'true'
const demoCredentials = {
email: 'demo@libredesk.io',
password: 'demo@libredesk.io'
}
onMounted(async () => {
// Prefill the login form with demo credentials if it's a demo build
if (isDemoBuild) {
loginForm.value.email = demoCredentials.email
loginForm.value.password = demoCredentials.password
}
fetchOIDCProviders()
})
const fetchOIDCProviders = async () => {
try {
const resp = await api.getAllEnabledOIDC()
oidcProviders.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const redirectToOIDC = (provider) => {
window.location.href = `/api/v1/oidc/${provider.id}/login`
}
const validateForm = () => {
if (!validateEmail(loginForm.value.email) && loginForm.value.email !== 'System') {
errorMessage.value = 'Invalid email address.'
useTemporaryClass('login-container', 'animate-shake')
return false
}
if (!loginForm.value.password) {
errorMessage.value = 'Password cannot be empty.'
useTemporaryClass('login-container', 'animate-shake')
return false
}
return true
}
const loginAction = () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
api
.login({
email: loginForm.value.email,
password: loginForm.value.password
})
.then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' })
})
.catch((error) => {
errorMessage.value = handleHTTPError(error).message
useTemporaryClass('login-container', 'animate-shake')
})
.finally(() => {
isLoading.value = false
})
}
const enabledOIDCProviders = computed(() => {
return oidcProviders.value.filter((provider) => !provider.disabled)
})
const emailHasError = computed(() => {
const email = loginForm.value.email
return email !== 'System' && !validateEmail(email) && email !== ''
})
const passwordHasError = computed(
() => !loginForm.value.password && loginForm.value.password !== ''
)
</script>
@@ -1,148 +0,0 @@
<template>
<div class="min-h-screen flex flex-col bg-background">
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card class="bg-card border border-border shadow-xl rounded-xl">
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">{{
t('auth.resetPassword')
}}</CardTitle>
<p class="text-muted-foreground">{{ t('auth.enterEmailForReset') }}</p>
</div>
<form @submit.prevent="requestResetAction" class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium text-foreground">{{
t('globals.terms.email')
}}</Label>
<Input
id="email"
type="email"
:placeholder="t('auth.enterEmail')"
v-model.trim="resetForm.email"
:class="{ 'border-destructive': emailHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.sending') }}
</span>
<span v-else>{{ t('auth.sendResetLink') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
<div class="text-center">
<router-link
to="/"
class="text-sm text-primary hover:text-primary/80 transition-all duration-200 ease-in-out"
>
{{ t('auth.backToLogin') }}
</router-link>
</div>
</CardContent>
</Card>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { validateEmail } from '@/utils/strings'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Label } from '@/components/ui/label'
import { useI18n } from 'vue-i18n'
const errorMessage = ref('')
const { t } = useI18n()
const isLoading = ref(false)
const emitter = useEmitter()
const router = useRouter()
const resetForm = ref({
email: ''
})
const validateForm = () => {
if (!validateEmail(resetForm.value.email)) {
errorMessage.value = 'Invalid email address.'
useTemporaryClass('reset-password-container', 'animate-shake')
return false
}
return true
}
const requestResetAction = async () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
try {
await api.resetPassword({
email: resetForm.value.email
})
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
description: 'Please check your email for the reset link.'
})
router.push({ name: 'login' })
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const emailHasError = computed(() => {
return !validateEmail(resetForm.value.email) && resetForm.value.email !== ''
})
</script>
@@ -1,178 +0,0 @@
<template>
<div class="min-h-screen flex flex-col bg-background">
<main class="flex-1 flex items-center justify-center p-4">
<div class="w-full max-w-[400px]">
<Card class="bg-card border border-border shadow-xl rounded-xl">
<CardContent class="p-8 space-y-6">
<div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground">{{
t('auth.setNewPassword')
}}</CardTitle>
<p class="text-muted-foreground">{{ t('auth.enterNewPasswordTwice') }}</p>
</div>
<form @submit.prevent="setPasswordAction" class="space-y-4">
<div class="space-y-2">
<Label for="password" class="text-sm font-medium text-foreground">
{{
t('globals.messages.new', {
name: t('globals.terms.password')
})
}}
</Label>
<Input
id="password"
type="password"
:placeholder="t('auth.enterNewPassword')"
v-model="passwordForm.password"
:class="{ 'border-destructive': passwordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<div class="space-y-2">
<Label for="confirmPassword" class="text-sm font-medium text-foreground">{{
t('auth.confirmPassword')
}}</Label>
<Input
id="confirmPassword"
type="password"
:placeholder="t('auth.confirmNewPassword')"
v-model="passwordForm.confirmPassword"
:class="{ 'border-destructive': confirmPasswordHasError }"
class="w-full bg-card border-border text-foreground placeholder:text-muted-foreground rounded-lg py-2 px-3 focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-200 ease-in-out"
/>
</div>
<Button
class="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg py-2 transition-all duration-200 ease-in-out transform hover:scale-105"
:disabled="isLoading"
type="submit"
>
<span v-if="isLoading" class="flex items-center justify-center">
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ t('auth.settingPassword') }}
</span>
<span v-else>{{ t('auth.setNewPassword') }}</span>
</Button>
</form>
<Error
v-if="errorMessage"
:errorMessage="errorMessage"
:border="true"
class="w-full bg-destructive/10 text-destructive border-destructive/20 p-3 rounded-lg text-sm"
/>
</CardContent>
</Card>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useTemporaryClass } from '@/composables/useTemporaryClass'
import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const route = useRoute()
const emitter = useEmitter()
const passwordForm = ref({
password: '',
confirmPassword: '',
token: ''
})
onMounted(() => {
passwordForm.value.token = route.query.token
if (!passwordForm.value.token) {
router.push({ name: 'login' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Invalid reset link. Please request a new password reset link.'
})
}
})
const validateForm = () => {
if (!passwordForm.value.password) {
errorMessage.value = t('auth.passwordRequired')
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = t('auth.passwordsDoNotMatch')
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
return true
}
const setPasswordAction = async () => {
if (!validateForm()) return
errorMessage.value = ''
isLoading.value = true
try {
await api.setPassword({
token: passwordForm.value.token,
password: passwordForm.value.password
})
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('auth.passwordSetSuccess')
})
router.push({ name: 'login' })
} catch (err) {
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {
isLoading.value = false
}
}
const passwordHasError = computed(() => {
return passwordForm.value.password !== '' && passwordForm.value.password.length < 8
})
const confirmPasswordHasError = computed(() => {
return (
passwordForm.value.confirmPassword !== '' &&
passwordForm.value.password !== passwordForm.value.confirmPassword
)
})
</script>
+4 -5
View File
@@ -1,7 +1,6 @@
{
"_.code": "en",
"_.name": "English (en)",
"globals.app.name": "Libredesk",
"globals.terms.user": "User | Users",
"globals.terms.contact": "Contact | Contacts",
"globals.terms.agent": "Agent | Agents",
@@ -190,21 +189,21 @@
"auth.forgotPassword": "Forgot password?",
"auth.loggingIn": "Logging in...",
"auth.signInButton": "Sign in",
"auth.resetPassword": "Reset Password",
"auth.resetPassword": "Reset password",
"auth.enterEmailForReset": "Enter your email to receive a password reset link.",
"auth.sending": "Sending...",
"auth.sendResetLink": "Send Reset Link",
"auth.invalidEmail": "Invalid email address.",
"auth.resetLinkSent": "Reset link sent",
"auth.checkEmailForReset": "Please check your email for the reset link.",
"auth.checkEmailForReset": "Check your email for the password reset link.",
"auth.backToLogin": "Back to login",
"auth.setNewPassword": "Set new password",
"auth.enterNewPasswordTwice": "Please enter your new password twice to confirm.",
"auth.enterNewPasswordTwice": "Enter your new password twice to confirm.",
"auth.enterNewPassword": "Enter new password",
"auth.confirmPassword": "Confirm password",
"auth.confirmNewPassword": "Confirm new password",
"auth.settingPassword": "Setting password...",
"auth.invalidResetLink": "Invalid reset link. Please request a new password reset link.",
"auth.invalidResetLink": "Invalid reset link. Request a new password reset link.",
"auth.passwordRequired": "Password is required.",
"auth.passwordsDoNotMatch": "Passwords do not match.",
"auth.passwordSetSuccess": "You can now login with your new password.",
-1
View File
@@ -1,7 +1,6 @@
{
"_.code": "mr",
"_.name": "मराठी (mr)",
"globals.app.name": "लिब्रेडेस्क",
"globals.terms.user": "वापरकर्ता | वापरकर्ते",
"globals.terms.contact": "संपर्क | संपर्क",
"globals.terms.agent": "एजंट | एजंट्स",