Перейти к содержанию

Роли сотрудников и выборка по владельцу

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Проверять полномочия до передачи заявки странице или клиенту.

Входное состояние: Рабочие запросы имеют действующий userId. Выходное состояние: Сотрудник видит свои заявки, менеджер и администратор — общий разрешённый набор. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-15 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.

Мастерская различает три роли. Обычный сотрудник работает со своими заявками, менеджер видит общий поток, администратор позже получает удаление. Это предметные правила «Смены», а не автоматическое значение любого готового компонента входа. Добавим проверенную роль к серверной личности и выразим видимость через SQL-условие. Проверка должна действовать одинаково для списка, канонической карточки и карточки в диалоге.

requireActor сначала получает userId через auth(), затем читает профиль currentUser() на сервере. Проверяем, что профиль существует и его id соответствует действующей личности. Роль берётся из publicMetadata. Несмотря на слово public, запись этих метаданных должна выполняться доверенным серверным инструментом администратора Clerk; обычному пользовательскому клиенту изменение роли не предоставляем. Доступность чтения и доверенное право записи различаются.

Допустимые значения и значение по умолчанию

Принимаем только строки admin и manager; любое другое значение становится employee. Такой договор не выдаёт дополнительные полномочия из неизвестного текста или отсутствующего поля. Если пользователь отправит параметр role=admin в URL, серверная функция его не читает. Важно проследить происхождение роли, а не только наличие правильного имени TypeScript. Тип ограничивает исходник, а доверенное получение ограничивает входное значение.

Функция возвращает Actor с двумя полями: userId и role. Страница и слой данных могут использовать этот короткий предметный объект вместо передачи полного профиля со всеми полями в браузер. Это уменьшает зависимость и объём раскрываемых сведений. В данном снимке запрос профиля может выполняться неоднократно; для рабочей среды следует учесть ограничения внешнего API и выбрать согласованное переиспользование или проверенный claim.

Не добавляем самодельный клиентский кэш роли как источник полномочия. Если доверенный администратор изменил роль, сервер должен использовать действующий договор получения. Аналогично custom claim потребовал бы настройки его содержания и понимания срока обновления токена. Здесь выбран явный серверный профиль, поэтому в исходниках нет утверждения, что роль уже помещена в каждую сессию внешнего сервиса.

Правило видимости в SQL

visibleTo(actor) возвращает равенство ownerId текущему userId для сотрудника. Для менеджера и администратора возвращается разрешающее условие. Список объединяет это условие с поиском по заголовку. База выдаёт только строки разрешённого набора. Мы не загружаем всё, чтобы затем скрыть чужие элементы в клиентской таблице: к тому моменту данные уже были бы переданы посетителю.

Карточка объединяет id с тем же условием видимости. Если заявка существует, но принадлежит другому сотруднику, поиск не вернёт её. Страница вызывает notFound для отсутствующего результата и не показывает заголовок или описание. Перехватывающий маршрут использует ту же функцию findTicket, поэтому альтернативный интерфейс не становится обходом правила. Эта связь важнее совпадения внешнего оформления двух карточек.

Поиск использует параметризованное выражение Drizzle ilike. Значение пользователя не склеивается в исполняемый текст SQL. Однако % и _ внутри значения имеют смысл шаблона LIKE, поэтому такой поиск не является точным буквальным сравнением всех символов. В учебной серии разрешена эта семантика. Она может расширить совпадения внутри разрешённого набора, но не отменяет объединённое условие владельца.

Чтение и изменение имеют разные права

Менеджер видит общий список и сможет менять статусы. Удаление в будущем оставим администратору. Таким образом, «видимый объект» не означает «доступны все действия». Серверная мутация должна подтвердить конкретное полномочие заново. При изменении статуса повторно используем видимость как правило доступного обновления; при удалении дополнительно требуется роль admin.

Кнопки должны отражать эти правила, но не становиться их единственным местом. Даже если сотрудник не видит удаление, он может самостоятельно отправить вызов действия. Поэтому роль проверяется внутри самого серверного обработчика. Проверка интерфейса помогает человеку понять возможности, серверная проверка определяет действительный результат запроса. В следующей части увидим обе роли проверки рядом.

Ожидаемая матрица доступа

У Анны и Павла есть по одной собственной заявке. Анна как employee должна видеть только свою строку, включая прямой адрес карточки. Павел не получает её заявку только потому, что вошёл. Менеджер видит обе. Пользователь с неизвестной строкой роли получает обычные права сотрудника. Эти результаты следуют из одной функции видимости, применённой до передачи данных.

Старая запись с владельцем demo-author не попадёт в список реального employee, чей userId другой. Пустой список в таком случае может быть корректным результатом полномочия, а не доказательством отсутствующей таблицы. Назначение настоящего владельца требует осмысленного решения. Не расширяйте правило до всех записей ради того, чтобы увидеть старую фикстуру; лучше создать собственную заявку после добавления формы.

Для самостоятельного разбора составьте таблицу «роль — владелец — чтение — изменение — удаление». Затем найдите место, где роль может быть доверенно записана, и место, где применяется условие владельца. Такая проверка чтением помогает обнаружить самостоятельные входы к данным до добавления интерфейса. Приложение и запросы здесь не выполнялись, поэтому матрица обозначает ожидаемый договор.

Проверьте смысл границ

Попробуйте мысленно убрать visibleTo только из модальной карточки. Список останется корректным, полная карточка откажет, но перехваченный адрес сможет открыть чужой объект. Именно поэтому проверка всей цепочки важнее одного правильного экрана. Разные способы представления ресурса должны использовать согласованную функцию доступа.

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

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

lib/actor.ts

import "server-only";
import { auth, currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
export type Role = "employee" | "manager" | "admin";
export type Actor = { userId: string; role: Role };
export async function requireActor(): Promise<Actor> {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");
  const user = await currentUser();
  if (!user || user.id !== userId) throw new Error("Не удалось получить профиль");
  const value = user.publicMetadata.role;
  const role: Role = value === "admin" || value === "manager" ? value : "employee";
  return { userId, role };
}

lib/data.ts

import "server-only";

import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { db } from "@/db/client";
import { tickets } from "@/db/schema";
import { requireActor } from "@/lib/actor";
import type { Actor } from "@/lib/actor";
import type { Ticket } from "@/lib/tickets";

export function visibleTo(actor: Actor) {
  return actor.role === "employee"
    ? eq(tickets.ownerId, actor.userId)
    : sql`true`;
}

export async function fetchTickets(q = ""): Promise<Ticket[]> {
  const actor = await requireActor();
  return db.select()
    .from(tickets)
    .where(and(
      visibleTo(actor),
      q ? ilike(tickets.title, `%${q.slice(0, 80)}%`) : undefined,
    ))
    .orderBy(desc(tickets.id))
    .limit(200);
}

export async function fetchNotice(): Promise<string> {
  return "Сотрудник видит свои заявки; " +
    "менеджер и администратор — общий список. " +
    "Вывод ограничен 200 строками.";
}

export async function findTicket(
  id: number,
): Promise<Ticket | undefined> {
  const actor = await requireActor();
  const [row] = await db.select()
    .from(tickets)
    .where(and(eq(tickets.id, id), visibleTo(actor)))
    .limit(1);
  return row;
}

app/(work)/tickets/page.tsx

import Link from "next/link";
import Form from "next/form";
import { fetchTickets, fetchNotice } from "@/lib/data";
export default async function Tickets({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
  const raw = (await searchParams).q;
  const q = (typeof raw === "string" ? raw : "").trim().slice(0, 80);
  const [tickets, notice] = await Promise.all([fetchTickets(q), fetchNotice()]);
  const rows = tickets;
  return <main><h1>Заявки</h1><p>{notice}</p><Form action="/tickets"><label htmlFor="q">Поиск по заголовку</label><input id="q" name="q" defaultValue={q} /><button>Найти</button></Form>{rows.map(ticket => <article key={ticket.id}><Link href={`/tickets/${ticket.id}`}>{ticket.title}</Link><p>{ticket.status}</p></article>)}{!rows.length && <p>Подходящих заявок нет.</p>}</main>;
}

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.