Access control

  • Working screens
  • components: 11
  • packages: 1

An admin screen where you act on records: filter, select, edit, and are told what the selection actually covers.

Reviewed 2026-09-06

On this page
Full width

What this proves

The dashboard answers “what is happening”. This screen answers “what will I do about it”, and everything difficult about it lives in the actions rather than the layout.

It works rather than pretends to: the search box really filters, the role select really narrows, the selection really counts, and the empty state really happens when you exclude everyone. An example whose input does nothing teaches the reader exactly the wrong lesson.

The bug this screen is built around

Select twelve rows. Narrow the filter to three. Press “suspend”. What did you just suspend?

In most admin tables, all twelve — including the nine you can no longer see. The selection outlives the filter, silently, and the interface never says so. Here the count is intersected with the visible list and the bar states the scope in words next to the number. Neither half is decoration: the number without the sentence still leaves the reader guessing which twelve.

Why a drawer and not a modal

Editing access is a side task: you look at the row, change one field, and go back to the list. A modal takes the list away to show one record and forces the reader to remember what they came from. The drawer leaves the table visible.

The rule is not “drawers are better”. It is that a modal is for a decision that must be finished before anything else can continue — deleting, confirming, paying. Editing a role is not that.

Disabled fields say why

The owner’s role select is disabled, and directly under it sits the reason: a workspace without an owner has nobody who can restore access. A disabled control with no explanation is indistinguishable from a broken one, and the reader’s next move is to try again harder.

Empty is not the same as no results

GrEmptyState here says “nobody matches this filter”, not “no people yet”. The first is about the filter and is fixed by a button; the second is about the workspace and is fixed by inviting someone. Showing the second when the first is true sends the reader off to solve a problem they do not have.

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/admin/App.vue. No second copy of the code exists.

examples/admin/App.vue
<script setup lang="ts">
/**
 * `/examples/admin` — управление доступом, работа C2.
 *
 * Дашборд отвечает на вопрос «что происходит». Этот экран — на вопрос «что я с
 * этим сделаю»: здесь действуют над записями, и всё трудное живёт в действиях,
 * а не в раскладке.
 *
 * Экран **работает**, а не изображает работу: поиск и фильтр действительно
 * отбирают строки, выбор действительно считается, пустое состояние
 * действительно наступает. Пример, в котором поле ввода ничего не делает, учит
 * ровно тому, чего делать не надо.
 *
 * Панель редактирования — боковая (`GrDrawer`), и она же показывает, почему
 * экран живёт во фрейме: панель монтируется в `document.body`, и на странице
 * примера без фрейма легла бы поверх документации (`adr/0016`).
 */
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity/components/GrDataTable'
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'
import { GrBreadcrumbs } from '@feugene/granularity/components/GrBreadcrumbs'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrSelect } from '@feugene/granularity/components/GrSelect'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrAvatar } from '@feugene/granularity/components/GrAvatar'
import { GrDropdownMenu } from '@feugene/granularity/components/GrDropdownMenu'
import { GrEmptyState } from '@feugene/granularity/components/GrEmptyState'
import { GrDrawer } from '@feugene/granularity/components/GrDrawer'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import type { AdminData, Person } from './data'

const props = defineProps<{ data: AdminData }>()

const query = ref('')
const role = ref<Person['role'] | ''>('')
const selected = ref<Array<string | number>>([])
const editing = ref<Person | null>(null)

/** Тон бейджа состояния. Приглашение — не отказ, поэтому тонов три. */
const STATE_TONE = { active: 'success', invited: 'warning', suspended: 'danger' } as const

const rows = computed(() => {
  const needle = query.value.trim().toLowerCase()

  return props.data.people.filter((person) => {
    if (role.value && person.role !== role.value)
      return false

    if (!needle)
      return true

    return person.name.toLowerCase().includes(needle) || person.email.toLowerCase().includes(needle)
  })
})

/**
 * Выбор не переживает фильтр.
 *
 * Классическая ошибка админки: отметили двенадцать строк, сузили фильтр,
 * нажали «приостановить» — и действие уехало на тех, кого на экране уже нет.
 * Здесь выбор всегда пересекается с видимым списком, и подпись рядом говорит
 * об этом вслух.
 */
const visibleSelection = computed(() => selected.value.filter(id => rows.value.some(person => person.id === id)))

const roleOptions = computed(() => [
  { value: '', label: props.data.roleFilter.all },
  ...props.data.roleFilter.options,
])

const columns = computed<GrDataColumn[]>(() => {
  const label = props.data.columns
  return [
    { key: 'person', label: label.person, sortable: true },
    { key: 'role', label: label.role, sortable: true },
    { key: 'state', label: label.state },
    { key: 'team', label: label.team },
    { key: 'lastSeen', label: label.lastSeen },
    { key: 'actions', label: label.actions, align: 'right' },
  ]
})

/**
 * Форма слова по числу.
 *
 * Правило — здесь, слова — в содержимом: русскому нужно три формы,
 * английскому хватает двух, и различает их счёт, а не язык страницы.
 */
function plural(n: number): string {
  const [one, few, many] = props.data.peopleForms
  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 rowMenu = computed(() => [
  { key: 'edit', label: props.data.rowMenu.edit },
  { key: 'resend', label: props.data.rowMenu.resend },
  { type: 'divider' as const },
  { key: 'remove', label: props.data.rowMenu.remove, variant: 'danger' as const },
])

function reset() {
  query.value = ''
  role.value = ''
}
</script>

<template>
  <div class="screen">
    <header>
      <GrBreadcrumbs :items="data.crumbs.map(label => ({ label }))" />
      <h1 class="screen__title">{{ data.title }}</h1>
      <p class="screen__subtitle">{{ data.subtitle }}</p>
    </header>

    <div class="screen__filters">
      <GrInput v-model="query" :placeholder="data.search.placeholder" :aria-label="data.search.label" clearable>
        <template #prefix>
          <span class="i-lucide-search block h-4 w-4" aria-hidden="true" />
        </template>
      </GrInput>

      <GrSelect v-model="role" :options="roleOptions" :aria-label="data.roleFilter.label" />
    </div>

    <!--
      Панель массового действия появляется вместе с выбором и исчезает вместе с
      ним. Постоянная строка с нулём выбранных занимала бы место круглые сутки
      ради состояния, которое бывает изредка.
    -->
    <div v-if="visibleSelection.length" class="screen__bulk">
      <strong>{{ data.bulk.selected }}: {{ visibleSelection.length }}</strong>
      <span class="screen__scope">{{ data.bulk.scope }}</span>

      <div class="screen__bulkActions">
        <GrButton size="sm" variant="outline">{{ data.bulk.invite }}</GrButton>
        <GrButton size="sm" variant="outline" tone="danger">{{ data.bulk.suspend }}</GrButton>
        <GrButton size="sm" variant="ghost" @click="selected = []">{{ data.bulk.clear }}</GrButton>
      </div>
    </div>

    <div class="screen__scroll">
      <GrDataTable
        v-if="rows.length"
        v-model:selected="selected"
        :rows="rows"
        :columns="columns"
        row-key="id"
        selectable
        initial-sort-key="person"
      >
        <template #cell-person="{ row }">
          <div class="screen__person">
            <GrAvatar :size="28" :alt="String(row.name)" />
            <span>
              <span class="screen__name">{{ row.name }}</span>
              <span class="screen__email">{{ row.email }}</span>
            </span>
          </div>
        </template>

        <template #cell-role="{ row }">
          {{ data.roleLabels[row.role as Person['role']] }}
        </template>

        <template #cell-state="{ row }">
          <GrBadge size="sm" :tone="STATE_TONE[row.state as Person['state']]">
            {{ data.stateLabels[row.state as Person['state']] }}
          </GrBadge>
        </template>

        <template #cell-actions="{ row }">
          <GrDropdownMenu
            :items="rowMenu"
            placement="bottom-end"
            @select="item => item.key === 'edit' && (editing = row as Person)"
          >
            <!--
              Кнопка идёт слотом `#trigger` и берёт `triggerProps`. Слот по
              умолчанию — это **содержимое панели**, и кнопка, положенная туда,
              уезжает внутрь закрытого меню: в таблице пусто, в разметке
              элемент есть, ошибки нет. Поймал прогон — движок ролей не нашёл
              кнопку, которую css-запрос находил.

              Имя — про меню, а не про первый его пункт: «Изменить доступ: Ада»
              обещало бы действие, а кнопка открывает список.
            -->
            <template #trigger="{ triggerProps }">
              <GrButton
                v-bind="triggerProps"
                size="sm"
                variant="ghost"
                square
                :aria-label="`${data.columns.actions}: ${row.name}`"
              >
                <span class="i-lucide-ellipsis-vertical block h-4 w-4" aria-hidden="true" />
              </GrButton>
            </template>
          </GrDropdownMenu>
        </template>
      </GrDataTable>

      <!--
        Пустое состояние отличает «никого нет» от «никто не подошёл». Первое —
        про пространство, второе — про фильтр, и лечится оно кнопкой, а не
        приглашением людей.
      -->
      <GrEmptyState v-else :title="data.empty.title" :description="data.empty.description">
        <GrButton size="sm" @click="reset">{{ data.empty.action }}</GrButton>
      </GrEmptyState>
    </div>

    <p class="screen__total">{{ rows.length }} {{ plural(rows.length) }}</p>

    <GrDrawer
      :model-value="Boolean(editing)"
      side="right"
      size="md"
      :title="`${data.drawer.titlePrefix} ${editing?.name ?? ''}`"
      @update:model-value="value => !value && (editing = null)"
    >
      <div class="screen__form">
        <GrFormField :label="data.drawer.role">
          <GrSelect
            :model-value="editing?.role"
            :options="data.roleFilter.options"
            :disabled="editing?.role === 'owner'"
            :aria-label="data.drawer.role"
          />
        </GrFormField>

        <GrFormField :label="data.drawer.team">
          <GrInput :model-value="editing?.team" :aria-label="data.drawer.team" />
        </GrFormField>

        <!-- Причина запрета названа рядом с запретом. Выключенное поле без
             объяснения читается как поломка. -->
        <p v-if="editing?.role === 'owner'" class="screen__note">{{ data.drawer.note }}</p>
      </div>

      <template #footer>
        <div class="screen__drawerActions">
          <GrButton variant="ghost" @click="editing = null">{{ data.drawer.cancel }}</GrButton>
          <GrButton @click="editing = null">{{ data.drawer.save }}</GrButton>
        </div>
      </template>
    </GrDrawer>
  </div>
</template>

<style scoped>
.screen {
  display: grid;
  align-content: start;
  gap: var(--gr-space-4);
  min-height: 100dvh;
  padding: var(--gr-space-4);
  background: var(--gr-bg);
  color: var(--gr-fg);
}

.screen__title {
  margin: var(--gr-space-3) 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__filters {
  display: grid;
  gap: var(--gr-space-3);
}

.screen__bulk {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gr-space-3);
  padding: var(--gr-space-3);
  border: 1px solid var(--gr-brd);
  border-radius: var(--gr-radius-md, 8px);
  background: var(--gr-muted);
  font-size: var(--gr-text-sm);
}

.screen__scope {
  color: var(--gr-muted-fg);
}

.screen__bulkActions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gr-space-2);
  margin-inline-start: auto;
}

.screen__scroll {
  overflow-x: auto;
}

.screen__person {
  display: flex;
  align-items: center;
  gap: var(--gr-space-3);
}

.screen__name {
  display: block;
}

.screen__email {
  display: block;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-xs);
}

.screen__total {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__form {
  display: grid;
  gap: var(--gr-space-4);
}

.screen__note {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__drawerActions {
  display: flex;
  justify-content: flex-end;
  gap: var(--gr-space-2);
}

@media (min-width: 768px) {
  .screen {
    padding: var(--gr-space-5);
  }

  .screen__filters {
    grid-template-columns: minmax(0, 1fr) 220px;
  }
}
</style>

Build this with Granularity