Вход
Рабочий экран входа, у которого достижимы все пять состояний — включая два, которых никто не рисует.
Что он доказывает
Паттерн аутентификации говорит, что на этом экране решается. Здесь решения приняты, и их можно нажать: войдите с данными, напечатанными под карточкой, ошибитесь, ошибитесь трижды.
Экран входа — не один экран. Это пять состояний: пустое, «чего-то не хватает», «пара не подходит», «слишком часто» и «вы вошли». Последние два не рисует никто. Здесь достижимы оба, потому что экран, показывающий только первое, показал самую лёгкую часть.
Одно сообщение на две неудачи
Неверный адрес и неверный пароль дают одну и ту же фразу, всегда. Два разных сообщения превращают форму входа в проверку адресов: любой скормит ей список утёкших почт и узнает, у каких из них здесь есть учётная запись.
Цена настоящая, и её стоит назвать вслух: человек, опечатавшийся в адресе, получает меньше помощи, чем мог бы. Это размен, и он сделан осознанно, а не по недосмотру.
Кнопка не гаснет никогда
Нажмите на пустой форме — она скажет, какое поле пустое. Нажмите во время паузы — ничего не сломается. Погашенная кнопка прячет причину, по которой она погашена, и читателю остаётся гадать, чего он не сделал; обычно он нажимает сильнее.
Ограничение частоты объявляется вслух
После трёх неудач экран берёт паузу на пятнадцать секунд и объявляет об этом
через live="assertive". Ограничение, о котором знает только зрячий, — это не
ограничение, а экран, переставший работать для всех остальных.
Счётчик при этом считает. «Попробуйте позже» — не указание, а способ не ответить на вопрос.
Поля, которые узнаёт менеджер паролей
autocomplete="username" и autocomplete="current-password" вместе с
парными name. Без них менеджер не предложит ничего, и человек начнёт
выбирать пароли, которые запомнит сам, — то есть ровно ту проблему, от которой
уходили, только с другой стороны.
Ни maxlength, ни запрета вставки здесь нет. И то и другое — привычки систем,
которые плохо хранили пароли, и обе делают менеджеры бесполезными.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCardДанныеGrFormФормыGrFormFieldФормыGrInputФормыGrButtonДействияGrAlertОбратная связьGrCheckboxФормыGrDividerДанныеGrLinkДействия
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — examples/authentication/App.vue. Отдельной копии кода не существует.
<script setup lang="ts">
/**
* `/examples/authentication` — экран входа, работа C2.
*
* Паттерн `/patterns/authentication` говорит, **что** здесь решается. Этот
* экран — те же решения, принятые и нажимаемые: пара почты и пароля не
* подходит одним сообщением на оба случая, кнопка не гаснет никогда, поля
* названы так, как их ждёт менеджер паролей, а ограничение частоты объявляется
* вслух, а не наступает молчанием.
*
* Экран входа — не один экран, а пять состояний, и все пять отсюда достижимы:
* пустое, «чего-то не хватает», «пара не подходит», «слишком часто» и
* «вы вошли». Пример, показывающий только первое, показывает самое простое.
*
* Правило множественного числа скопировано, а не вынесено в общий модуль, и
* это осознанно: читатель уносит `App.vue` целиком, а импорт из соседнего
* каталога портала унесётся сломанным. Общий помощник экономил бы двадцать
* строк на восемь примеров и ломал бы главное свойство примера — его можно
* скопировать.
*/
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import { GrCard } from '@feugene/granularity/components/GrCard'
import type { GrFormRules } from '@feugene/granularity/components/GrForm'
import { GrForm } from '@feugene/granularity/components/GrForm'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrCheckbox } from '@feugene/granularity/components/GrCheckbox'
import { GrDivider } from '@feugene/granularity/components/GrDivider'
import { GrLink } from '@feugene/granularity/components/GrLink'
import type { AuthData } from './data'
const props = defineProps<{ data: AuthData }>()
/** Столько неудач подряд — и вход закрывается на паузу. */
const LIMIT = 3
const PAUSE = 15
/**
* Модель формы, а не отдельные `ref`.
*
* `GrForm` отдаёт событие `submit` **только после своей проверки**, и «чего не
* хватает» говорит он сам — у поля, а не над формой. Первая версия этого
* экрана проверяла поля руками и не получала `submit` вовсе: кнопка нажималась,
* не отвечая ничем.
*
* Деление вышло правильнее ручного: **ошибка поля — то, что чинится в поле;
* общее сообщение — то, чего никакое поле не исправит**. Пустая почта — первое.
* Неподходящая пара и слишком частые попытки — второе.
*/
const model = reactive({ email: '', password: '' })
const remember = ref(true)
const rules = computed<GrFormRules>(() => ({
email: [
{ required: true, message: props.data.missingEmail },
{ type: 'email', message: props.data.badEmail },
],
password: [{ required: true, message: props.data.missingPassword }],
}))
const busy = ref(false)
const signedIn = ref(false)
const failures = ref(0)
const waitFor = ref(0)
const problem = ref('')
let timer: ReturnType<typeof setInterval> | undefined
onBeforeUnmount(() => clearInterval(timer))
/**
* Форма слова по числу: русскому нужно три, английскому хватает двух.
* Правило здесь, слова — в содержимом.
*/
function plural(n: number, forms: [string, string, string]): string {
const [one, few, many] = forms
const mod100 = n % 100
const mod10 = n % 10
if (mod100 >= 11 && mod100 <= 14)
return many
if (mod10 === 1)
return one
if (mod10 >= 2 && mod10 <= 4)
return few
return many
}
const pauseMessage = computed(() =>
`${props.data.rateLimited} ${waitFor.value} ${plural(waitFor.value, props.data.secondForms)}.`)
function startPause() {
waitFor.value = PAUSE
clearInterval(timer)
timer = setInterval(() => {
waitFor.value -= 1
if (waitFor.value <= 0) {
clearInterval(timer)
failures.value = 0
}
}, 1000)
}
/**
* Отправка.
*
* Кнопка не выключается ни разу — ни на пустой форме, ни на паузе. Погашенная
* кнопка прячет причину, по которой она погашена, и читателю остаётся гадать,
* чего он не сделал. Нажатие всегда отвечает словами.
*/
function submit() {
if (waitFor.value > 0)
return
problem.value = ''
busy.value = true
window.setTimeout(() => {
busy.value = false
if (model.email.trim().toLowerCase() === props.data.demoEmail && model.password === props.data.demoPassword) {
signedIn.value = true
failures.value = 0
return
}
failures.value += 1
// Одно сообщение на оба случая: разные ответы на «нет такого адреса» и «не
// тот пароль» превращают форму входа в проверку списка адресов.
problem.value = props.data.wrong
if (failures.value >= LIMIT)
startPause()
}, 400)
}
function signOut() {
signedIn.value = false
model.password = ''
problem.value = ''
}
</script>
<template>
<div class="screen">
<GrCard class="screen__card">
<template v-if="!signedIn">
<header class="screen__head">
<p class="screen__brand">{{ data.brand }}</p>
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
</header>
<!--
Сообщение живое: `live` объявляет его читалке в момент появления.
Ограничение частоты, о котором знает только зрячий, — это отказ,
выглядящий поломкой.
-->
<GrAlert v-if="waitFor > 0" tone="warning" live="assertive" :title="pauseMessage" />
<GrAlert v-else-if="problem" tone="danger" live="assertive" :title="problem" />
<GrForm :model="model" :rules="rules" class="screen__form" @submit="submit">
<GrFormField name="email" :label="data.emailLabel">
<!--
`autocomplete` и `name` — контракт с менеджером паролей. Без них
он не предложит сохранённую пару, и человек начнёт придумывать
пароли, которые сможет запомнить сам.
-->
<GrInput
v-model="model.email"
type="email"
name="username"
autocomplete="username"
:placeholder="data.emailPlaceholder"
/>
</GrFormField>
<GrFormField name="password" :label="data.passwordLabel">
<!--
Ни `maxlength`, ни запрета вставки: и то и другое ломает
менеджеры паролей, ради удобства, которого никто не просил.
-->
<GrInput
v-model="model.password"
type="password"
name="current-password"
autocomplete="current-password"
password-toggle
:password-show-label="data.passwordShow"
:password-hide-label="data.passwordHide"
/>
</GrFormField>
<div class="screen__row">
<div class="screen__remember">
<!-- Подпись флажка — содержимое, а не проп: у `GrCheckbox` метка
идёт слотом. Пояснение живёт отдельным узлом и связано с
флажком через `aria-describedby` — иначе срок сеанса
услышит только зрячий. -->
<GrCheckbox v-model="remember" aria-describedby="remember-hint">
{{ data.remember }}
</GrCheckbox>
<p id="remember-hint" class="screen__hint">{{ data.rememberHint }}</p>
</div>
<GrLink href="#forgot">{{ data.forgot }}</GrLink>
</div>
<GrButton type="submit" :loading="busy">
{{ busy ? data.submitting : data.submit }}
</GrButton>
</GrForm>
<GrDivider :label="data.or" />
<GrButton variant="outline" class="screen__sso">{{ data.sso }}</GrButton>
<p class="screen__demo">
{{ data.demoHint }}
<code>{{ data.demoEmail }}</code> · <code>{{ data.demoPassword }}</code>
</p>
</template>
<template v-else>
<div class="screen__done">
<h1 class="screen__title">{{ data.signedIn.title }}</h1>
<p class="screen__subtitle">{{ data.signedIn.description }}</p>
<GrButton variant="outline" @click="signOut">{{ data.signedIn.again }}</GrButton>
</div>
</template>
</GrCard>
</div>
</template>
<style scoped>
.screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: var(--gr-space-4);
background: var(--gr-muted);
color: var(--gr-fg);
}
.screen__card {
display: grid;
gap: var(--gr-space-4);
width: 100%;
max-width: 26rem;
padding: var(--gr-space-5);
}
.screen__brand {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__title {
margin: var(--gr-space-1) 0 0;
font-size: var(--gr-text-xl);
font-weight: 600;
}
.screen__subtitle {
margin: var(--gr-space-1) 0 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__form {
display: grid;
gap: var(--gr-space-4);
}
/* «Забыли пароль» стоит рядом с полем пароля, а не в подвале карточки: его
ищут в тот момент, когда пароль не подошёл, и искать приходится глазами. */
.screen__row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--gr-space-3);
font-size: var(--gr-text-sm);
}
.screen__remember {
display: grid;
gap: var(--gr-space-1);
}
.screen__hint {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
}
.screen__sso {
width: 100%;
}
.screen__demo {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
line-height: 1.7;
}
.screen__done {
display: grid;
justify-items: start;
gap: var(--gr-space-3);
}
</style>