Вход

  • Аккаунт и публикация
  • компонентов: 9
  • пакетов: 1

Рабочий экран входа, у которого достижимы все пять состояний — включая два, которых никто не рисует.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Паттерн аутентификации говорит, что на этом экране решается. Здесь решения приняты, и их можно нажать: войдите с данными, напечатанными под карточкой, ошибитесь, ошибитесь трижды.

Экран входа — не один экран. Это пять состояний: пустое, «чего-то не хватает», «пара не подходит», «слишком часто» и «вы вошли». Последние два не рисует никто. Здесь достижимы оба, потому что экран, показывающий только первое, показал самую лёгкую часть.

Одно сообщение на две неудачи

Неверный адрес и неверный пароль дают одну и ту же фразу, всегда. Два разных сообщения превращают форму входа в проверку адресов: любой скормит ей список утёкших почт и узнает, у каких из них здесь есть учётная запись.

Цена настоящая, и её стоит назвать вслух: человек, опечатавшийся в адресе, получает меньше помощи, чем мог бы. Это размен, и он сделан осознанно, а не по недосмотру.

Кнопка не гаснет никогда

Нажмите на пустой форме — она скажет, какое поле пустое. Нажмите во время паузы — ничего не сломается. Погашенная кнопка прячет причину, по которой она погашена, и читателю остаётся гадать, чего он не сделал; обычно он нажимает сильнее.

Ограничение частоты объявляется вслух

После трёх неудач экран берёт паузу на пятнадцать секунд и объявляет об этом через live="assertive". Ограничение, о котором знает только зрячий, — это не ограничение, а экран, переставший работать для всех остальных.

Счётчик при этом считает. «Попробуйте позже» — не указание, а способ не ответить на вопрос.

Поля, которые узнаёт менеджер паролей

autocomplete="username" и autocomplete="current-password" вместе с парными name. Без них менеджер не предложит ничего, и человек начнёт выбирать пароли, которые запомнит сам, — то есть ровно ту проблему, от которой уходили, только с другой стороны.

Ни maxlength, ни запрета вставки здесь нет. И то и другое — привычки систем, которые плохо хранили пароли, и обе делают менеджеры бесполезными.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/authentication/App.vue. Отдельной копии кода не существует.

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>

Собрать такое же