363 lines
8.3 KiB
Vue
363 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { t, translate } from '@/i18n'
|
|
|
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import AuthShell from '@/components/AuthShell.vue'
|
|
import { ApiError, useApi } from '@/composables/useApi'
|
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
|
import { useSessionStore } from '@/stores/session'
|
|
|
|
interface AlertState {
|
|
tone: 'error' | 'info' | 'success'
|
|
icon: string
|
|
title: string
|
|
description: string
|
|
}
|
|
|
|
const wrongCredentialsAlert: AlertState = {
|
|
tone: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("That username and password don't match")
|
|
},
|
|
get description() {
|
|
return t('Check both and try again. Passwords are case-sensitive.')
|
|
},
|
|
}
|
|
|
|
const tooManyAttemptsAlert: AlertState = {
|
|
tone: 'info',
|
|
icon: 'i-lucide-timer',
|
|
get title() {
|
|
return t('Too many attempts')
|
|
},
|
|
get description() {
|
|
return t('For your security, wait a moment before trying again.')
|
|
},
|
|
}
|
|
|
|
const serverErrorAlert: AlertState = {
|
|
tone: 'error',
|
|
icon: 'i-lucide-circle-x',
|
|
get title() {
|
|
return t("Couldn't reach RenewCMS")
|
|
},
|
|
get description() {
|
|
return t('Check your connection and try again.')
|
|
},
|
|
}
|
|
|
|
const sessionExpiredAlert: AlertState = {
|
|
tone: 'info',
|
|
icon: 'i-lucide-clock',
|
|
get title() {
|
|
return t('Your session expired')
|
|
},
|
|
get description() {
|
|
return t(
|
|
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
|
)
|
|
},
|
|
}
|
|
|
|
const passwordChangedAlert: AlertState = {
|
|
tone: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
get title() {
|
|
return t('Password changed')
|
|
},
|
|
get description() {
|
|
return t('Log in with your new password.')
|
|
},
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const api = useApi()
|
|
const session = useSessionStore()
|
|
const retryAfter = useRetryAfter()
|
|
|
|
const registrationOpen = ref(false)
|
|
const username = ref('')
|
|
const password = ref('')
|
|
const usernameError = ref('')
|
|
const passwordError = ref('')
|
|
const submitting = ref(false)
|
|
const passwordShown = ref(false)
|
|
|
|
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
|
|
|
|
const alert = ref<AlertState | null>(
|
|
initialReason === 'expired'
|
|
? sessionExpiredAlert
|
|
: initialReason === 'password-changed'
|
|
? passwordChangedAlert
|
|
: null,
|
|
)
|
|
|
|
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
|
|
|
|
const submitLabel = computed(() => {
|
|
if (submitting.value) {
|
|
return t('Logging in…')
|
|
}
|
|
if (retryAfter.active.value) {
|
|
return t('Try again in {0}', [retryAfter.formatted.value])
|
|
}
|
|
return t('Log in')
|
|
})
|
|
|
|
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
|
|
|
|
async function focusField(selector: string) {
|
|
await nextTick()
|
|
await nextTick()
|
|
document.querySelector<HTMLInputElement>(selector)?.focus()
|
|
}
|
|
|
|
function focusPassword() {
|
|
focusField('input[autocomplete="current-password"]')
|
|
}
|
|
|
|
function focusUsername() {
|
|
focusField('input[autocomplete="username"]')
|
|
}
|
|
|
|
function focusFirstInvalid() {
|
|
if (usernameError.value) {
|
|
focusUsername()
|
|
return
|
|
}
|
|
if (passwordError.value) {
|
|
focusPassword()
|
|
}
|
|
}
|
|
|
|
function usernameProblem(value: string) {
|
|
const trimmed = value.trim()
|
|
if (trimmed.includes('@')) {
|
|
return 'Use your username, not your email address.'
|
|
}
|
|
if (trimmed.length < 3) {
|
|
return 'Use at least 3 characters.'
|
|
}
|
|
if (trimmed.length > 20) {
|
|
return 'Use 20 characters or fewer.'
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function validate() {
|
|
usernameError.value = usernameProblem(username.value)
|
|
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
|
return !usernameError.value && !passwordError.value
|
|
}
|
|
|
|
async function afterLoginSuccess() {
|
|
await session.retry()
|
|
|
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
|
|
if (redirect) {
|
|
await router.push(redirect)
|
|
return
|
|
}
|
|
|
|
if (session.session?.verified) {
|
|
await router.push('/home')
|
|
return
|
|
}
|
|
|
|
await router.push('/register/pending')
|
|
}
|
|
|
|
function handleLoginError(error: unknown) {
|
|
if (error instanceof ApiError && error.status === 403) {
|
|
alert.value = wrongCredentialsAlert
|
|
password.value = ''
|
|
focusPassword()
|
|
return
|
|
}
|
|
|
|
if (error instanceof ApiError && error.status === 429) {
|
|
alert.value = tooManyAttemptsAlert
|
|
retryAfter.start(error.retryAfter ?? 0)
|
|
return
|
|
}
|
|
|
|
alert.value = serverErrorAlert
|
|
}
|
|
|
|
async function onSubmit() {
|
|
if (submitDisabled.value) {
|
|
return
|
|
}
|
|
|
|
if (!validate()) {
|
|
focusFirstInvalid()
|
|
return
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
await api.request('/v1/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: username.value,
|
|
password: password.value,
|
|
}),
|
|
})
|
|
await afterLoginSuccess()
|
|
} catch (error) {
|
|
handleLoginError(error)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (initialReason === 'expired') {
|
|
focusPassword()
|
|
}
|
|
|
|
try {
|
|
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
|
|
registrationOpen.value = status.open
|
|
} catch {
|
|
registrationOpen.value = false
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<AuthShell
|
|
:title="t('Log in')"
|
|
:description="t('Welcome back. Log in to manage your articles.')"
|
|
>
|
|
<template v-if="alert" #notice>
|
|
<UAlert
|
|
:color="alert.tone"
|
|
:icon="alert.icon"
|
|
:title="alert.title"
|
|
:description="alert.description"
|
|
variant="soft"
|
|
:close="false"
|
|
:role="alertRole"
|
|
/>
|
|
</template>
|
|
|
|
<template #default="{ controlSize, controlClass, inputClass }">
|
|
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
|
|
<UFormField
|
|
:label="t('Username')"
|
|
name="username"
|
|
:error="translate(usernameError) || undefined"
|
|
:size="controlSize"
|
|
>
|
|
<template v-if="usernameError" #error="{ error }">
|
|
<span class="flex items-center gap-1.5">
|
|
<UIcon
|
|
name="i-lucide-circle-alert"
|
|
class="size-3.5 shrink-0 text-error"
|
|
/>
|
|
{{ t(error) }}
|
|
</span>
|
|
</template>
|
|
<UInput
|
|
v-model="username"
|
|
:placeholder="t('Your username')"
|
|
autocomplete="username"
|
|
:disabled="submitDisabled"
|
|
variant="outline"
|
|
color="primary"
|
|
:highlight="!!usernameError"
|
|
:size="controlSize"
|
|
:ui="{ base: inputClass, trailing: 'pe-1' }"
|
|
class="w-full"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField
|
|
:label="t('Password')"
|
|
name="password"
|
|
:error="translate(passwordError) || undefined"
|
|
:size="controlSize"
|
|
>
|
|
<template #hint>
|
|
<RouterLink
|
|
to="/register/reset/request"
|
|
class="text-toned text-sm underline underline-offset-[3px]"
|
|
>
|
|
{{ t('Forgot password?') }}
|
|
</RouterLink>
|
|
</template>
|
|
<template v-if="passwordError" #error="{ error }">
|
|
<span class="flex items-center gap-1.5">
|
|
<UIcon
|
|
name="i-lucide-circle-alert"
|
|
class="size-3.5 shrink-0 text-error"
|
|
/>
|
|
{{ t(error) }}
|
|
</span>
|
|
</template>
|
|
<UInput
|
|
v-model="password"
|
|
:type="passwordShown ? 'text' : 'password'"
|
|
autocomplete="current-password"
|
|
:disabled="submitDisabled"
|
|
variant="outline"
|
|
color="primary"
|
|
:highlight="!!passwordError"
|
|
:size="controlSize"
|
|
:ui="{
|
|
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
|
|
trailing: 'pe-1',
|
|
}"
|
|
class="w-full"
|
|
>
|
|
<template #trailing>
|
|
<UButton
|
|
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
|
variant="ghost"
|
|
color="neutral"
|
|
size="sm"
|
|
square
|
|
class="size-8 justify-center text-toned"
|
|
:aria-label="
|
|
passwordShown ? t('Hide password') : t('Show password')
|
|
"
|
|
:aria-pressed="passwordShown"
|
|
@click="passwordShown = !passwordShown"
|
|
/>
|
|
</template>
|
|
</UInput>
|
|
</UFormField>
|
|
|
|
<UButton
|
|
type="submit"
|
|
block
|
|
:size="controlSize"
|
|
:ui="{ base: controlClass }"
|
|
:loading="submitting"
|
|
:disabled="submitDisabled"
|
|
>
|
|
{{ submitLabel }}
|
|
</UButton>
|
|
</UForm>
|
|
</template>
|
|
|
|
<template v-if="registrationOpen" #footer>
|
|
<span class="flex flex-wrap items-center gap-1.5">
|
|
{{ t('First time here?') }}
|
|
<RouterLink
|
|
to="/register"
|
|
class="font-medium text-highlighted underline underline-offset-3"
|
|
>
|
|
{{ t('Create the owner account') }}
|
|
</RouterLink>
|
|
</span>
|
|
</template>
|
|
</AuthShell>
|
|
</template>
|