Sign in

  • Account and publishing
  • components: 9
  • packages: 1

A working sign-in screen with all five of its states reachable, including the two nobody designs.

Reviewed 2026-09-06

On this page
Full width

What this proves

The authentication pattern says what has to be decided on this screen. Here those decisions are made, and you can press them: sign in with the credentials printed under the card, get them wrong, get them wrong three times.

A sign-in screen is not one screen. It is five states — empty, something is missing, the pair does not match, too many attempts, signed in — and the last two are the ones nobody puts in a mockup. Both are reachable here, because a screen that only ever shows the first state has shown you the easy part.

One message for two failures

Wrong address and wrong password produce the same sentence, always. Two different messages turn a sign-in form into an address checker: anyone can feed it a list of leaked emails and learn which ones have accounts here.

The cost is real and worth naming — a person who mistyped their address gets less help than they could have. That is the trade, and it is made deliberately rather than by accident.

The button never goes dark

Press it with an empty form and it tells you which field is empty. Press it during the rate-limit pause and nothing breaks. A dimmed button hides the reason it is dimmed, and the reader is left to guess what they failed to do — usually by clicking harder.

The rate limit says so out loud

After three failures the screen pauses for fifteen seconds and announces it with live="assertive". A limit that only the sighted reader knows about is not a limit — it is a screen that stopped working for everyone else.

The countdown also counts down. “Try again later” is not an instruction; it is a way of not answering the question.

Fields the password manager recognises

autocomplete="username" and autocomplete="current-password", with matching name attributes. Without them the manager offers nothing, and the person starts choosing passwords they can remember unaided — which is the whole problem, arriving by a different road.

There is no maxlength and nothing blocks paste. Both are habits inherited from systems that stored passwords badly, and both make managers unusable.

Components used

The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.

Packages:

  • @feugene/granularity

Entry point

The same file that runs here — examples/authentication/App.vue. No second copy of the code exists.

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>

Build this with Granularity