Автор: Чудо Оньенма

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

Благодаря Strapi у нас есть доступ к нескольким поставщикам аутентификации, таким как Google, Twitter и т. д., что позволяет нам настраивать аутентифицированные запросы к нашему API Headless CMS для простого извлечения данных и выполнения действий, доступных только для аутентифицированных и авторизованных пользователей. С помощью аутентификации Strapi мы можем быстро настроить надежную систему аутентификации для нашего приложения и сосредоточиться на ее создании.

Давайте посмотрим, как мы можем настроить простое приложение Remix и реализовать авторизацию и аутентификацию пользователей с помощью Strapi.

Краткое введение в Headless CMS

Система управления контентом (CMS) — это программное обеспечение или служба, которая помогает вам создавать и управлять контентом для вашего веб-сайта и приложений.

Для традиционной CMS интерфейс веб-сайта или приложения встроен в CMS. Единственные доступные настройки — это готовые темы и пользовательский код. WordPress, Joomla и Drupal — хорошие примеры традиционных CMS, которые объединяют интерфейс веб-сайта с серверной частью.

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

Что такое Страпи?

Strapi является ведущей безголовой CMS с открытым исходным кодом на JavaScript. Strapi позволяет очень легко создавать пользовательские API, REST или GraphQL, которые могут использоваться любым клиентом или внешней средой по выбору.

Звучит интересно, тем более что мы будем использовать наш Strapi API и создавать аутентификацию и авторизацию с помощью Remix.

Аутентификация в Strapi

Strapi использует аутентификацию на основе токенов для аутентификации своих пользователей, предоставляя токен JWT пользователю при успешной регистрации пользователя и входе в систему. Strapi также поддерживает несколько провайдеров аутентификации, таких как Auth0, Google и т. д. В этом руководстве мы будем использовать локальную аутентификацию.

Что такое Ремикс?

Remix – это полнофункциональная веб-платформа, ориентированная на пользовательский интерфейс и работающая с основными веб-основами для обеспечения быстрого, удобного и надежного взаимодействия с пользователем. Remix включает React Router, рендеринг на стороне сервера, поддержку TypeScript, производственный сервер и оптимизацию бэкенда.

Цель

В конце этого руководства мы бы рассказали, как добавить аутентификацию в наше приложение Remix с помощью Strapi.

Предпосылки

  • Базовые знания JavaScript
  • Базовые знания ремиксов.
  • Редактор кода типа VSCode
  • Версия Node.js (¹².22.0, ^14.17.0 или ›=16.0.0). Вы можете скачать Node.js с официального сайта Node.js, если вы еще этого не сделали.
  • Установлен npm 7 или выше

Что мы строим

Мы создадим простое приложение Remix, в котором пользователи смогут регистрироваться, входить в систему и редактировать свои профили. Вот живой пример, размещенный на Netlify

Шаг 1. Настройте серверную часть с помощью Strapi

Чтобы начать процесс создания, мы начнем с настройки серверной части с помощью Strapi.

  1. Сначала мы создадим новое приложение Strapi. Перейдите в каталог по вашему выбору и выполните одну из следующих команд:
yarn create strapi-app profile-api
    #or
    npx create-strapi-app@latest profile-api
  1. Далее выбираем тип установки. Quickstart использует базу данных по умолчанию SQLite и рекомендуется. После завершения установки панель администратора Strapi должна автоматически открыться в вашем браузере.
  2. Заполните форму, чтобы создать учетную запись администратора.

  1. Это позволит нам получить доступ к панели администратора.

Создание типов коллекций

Давайте изменим тип нашей коллекции для Пользователи. Перейдите в раздел CONTENT-TYPE BUILDER › COLLECTION TYPES › USER*.* Здесь мы увидим структура пользовательского типа в Strapi.

Мы просто добавим сюда еще несколько полей. Нажмите кнопку + ДОБАВИТЬ ДРУГОЕ ПОЛЕ в правом верхнем углу, чтобы добавить следующие поля:

  • twitterUsername – Текст (короткий текст) и в разделе Дополнительные настройки выберите Уникальное поле
  • websiteUrl - Текст (Короткий текст)
  • title – Текст (краткий текст) и в разделе Дополнительные настройки выберите Обязательное поле
  • bio - Текст (Длинный текст)
  • profilePic – Медиа (отдельные медиа)
  • color — Перечисление: Значения, выбранные из Цвета попутного ветра):
  • Red
  • Orange
  • Amber и т. д. В разделе Дополнительные настройки установите значение по умолчанию на Cyan и включите Обязательное поле
  • slug – UID: Прикрепленное полеusername

Теперь у нас должно получиться что-то вроде этого:

Нажмите СОХРАНИТЬ. Это сохранит изменения в типе коллекции и перезапустит сервер.

Настройка разрешений для общедоступных и аутентифицированных пользователей

Strapi по умолчанию защищен, поэтому мы не сможем получить доступ к каким-либо данным из API, если не установим разрешения. Чтобы установить разрешения,

  1. Перейдите к разделу НАСТРОЙКИ > ПЛАГИН ПОЛЬЗОВАТЕЛЕЙ И РАЗРЕШЕНИЙ > РОЛИ.
  2. Перейдите в раздел ПУБЛИЧНЫЙ и включите следующие действия для следующего в разделе Разрешения пользователей.
  • ПОЛЬЗОВАТЕЛЬ
  • Count
  • find
  • findOne

У нас должно получиться что-то вроде этого:

  1. Нажмите СОХРАНИТЬ.
  2. Теперь перейдите к AUTHENTICATED и включите следующее в разделе Разрешения пользователя.
  • AUTH — ✅ Выбрать все
  • РАЗРЕШЕНИЯ — ✅ Выбрать все
  • ПОЛЬЗОВАТЕЛЬ ✅ — Выбрать все

  1. Нажмите СОХРАНИТЬ.

Также мы быстро создадим несколько пользовательских профилей для нашего приложения. Чтобы создать пользователей, перейдите в ДИСПЕТЧЕР КОНТЕНТА > ПОЛЬЗОВАТЕЛЬ. Затем нажмите СОЗДАТЬ НОВУЮ ЗАПИСЬ, заполните всю необходимую информацию и сохраните записи. .

Вот, например, мои пользователи:

Шаг 2: Настройка приложения Remix

Чтобы создать наш интерфейс Remix, запустите:

npx create-remix@latest

Если вы впервые устанавливаете Remix, программа спросит, хотите ли вы установить create-remix@latest. Введите y для установки

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

? Where would you like to create your app? remix-profiles
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes

Здесь мы называем приложение «remix-profiles», затем выбираем «Просто основы» для типа приложения, а в качестве цели развертывания мы выбираем «Remix App Server», мы также будем использовать TypeScript для этого проекта и пусть Remix запустится. npm install для нас.

Remix App Server — полнофункциональный сервер Node.js на базе Express. Это самый простой вариант, и мы будем использовать его в этом уроке.

Как только npm install пройдет успешно, мы перейдем в каталог remix-profiles:

cd remix-jokes

Настройка TailwindCSS

Установите tailwindcss, его одноранговые зависимости и concurrently через npm, а затем запустите команду init, чтобы сгенерировать наш файл tailwind.config.js.

npm install tailwindcss postcss autoprefixer concurrently @tailwindcss/forms @tailwindcss/aspect-ratio
    npx tailwindcss init

Теперь настройте ./tailwind.config.js:

// ./tailwind.config.js
    
    module.exports = {
      content: [
        "./app/**/*.{js,ts,jsx,tsx}",
      ],
      theme: {
        extend: {},
      },
      corePlugins: {
        aspectRatio: false,
      },
      plugins: [
        require('@tailwindcss/forms'),
        require('@tailwindcss/aspect-ratio')
      ],
    }

Теперь нам нужно обновить скрипты в нашем файле package.json, чтобы создать CSS как для разработки, так и для производства.

// ./package.json
    
    {
      "scripts": {
        "build": "npm run build:css && remix build",
        "build:css": "tailwindcss -m -i ./styles/app.css -o app/styles/app.css",
        "dev": "concurrently \"npm run dev:css\" \"remix dev\"",
        "dev:css": "tailwindcss -w -i ./styles/app.css -o app/styles/app.css",
      }
    }

После этого мы можем добавить директивы @tailwind для каждого слоя Tailwind в наш файл css. Создайте новый файл ./styles/app.css:

// ./styles/app.css/
    
    @tailwind base;
    @tailwind components;
    @tailwind utilities;

Чтобы применить это к нашему приложению, мы должны импортировать скомпилированный файл ./app/styles/app.css в наш проект в наш файл ./app/root.tsx:

// ./app/root.tsx
    
    import type { MetaFunction, LinksFunction } from "@remix-run/node";
    
    // import tatilwind styles
    import styles from "./styles/app.css"
    import {
      Links,
      LiveReload,
      Meta,
      Outlet,
      Scripts,
      ScrollRestoration,
    } from "@remix-run/react";
    export const meta: MetaFunction = () => ({
      charset: "utf-8",
      title: "New Remix App",
      viewport: "width=device-width,initial-scale=1",
    });
    export const links: LinksFunction = () => {
      return [{ rel: "stylesheet", href: styles }];
    };
    
    export default function App() {
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <Outlet />
            <ScrollRestoration />
            <Scripts />
            <LiveReload />
          </body>
        </html>
      );
    }

Потрясающий!

На этом этапе давайте кратко рассмотрим структуру нашего проекта. Это должно выглядеть примерно так:

remix-profiles
├─ .eslintrc
├─ .gitignore
├─ app
│  ├─ entry.client.tsx
│  ├─ entry.server.tsx
│  ├─ root.tsx
│  └─ routes
│     └─ index.tsx
├─ package-lock.json
├─ package.json
├─ public
│  └─ favicon.ico
├─ README.md
├─ remix.config.js
├─ styles
│  └─ app.css
├─ tailwind.config.js
└─ tsconfig.json

Теперь давайте запустим процесс сборки и запустим наше приложение:

npm run dev

Это запускает сценарии разработки, которые мы добавили в package.json, и запускает Tailwind вместе с Remix:

Нас должны приветствовать вот так:

Хорошо! Давайте перейдем к пикантным вещам и создадим наше приложение Remix.

Примечание.

  • 🚩 Все стили, добавленные в это приложение, находятся в одном файле ./styles/app.css (не скомпилированном), доступ к которому вы можете получить в репозитории проекта на GitHub.
  • 🚩 Я буду использовать TypeScript для этого проекта, я сохранил все объявления пользовательских типов, которые я создал для этого проекта, в файле ./app/utils/types.ts. Вы можете получить его на GitHub и использовать, если вы работаете с TypeScript.

Однако, если вы предпочитаете использовать JavaScript, вы можете игнорировать все это и вместо этого использовать файлы .js и .jsx.

Добавьте URL-адрес Strapi в переменную среды

Создайте файл ./.env в корне проекта и добавьте следующее:

STRAPI_API_URL="http://localhost:1337/api"
STRAPI_URL="http://localhost:1337"

Создать компонент SiteHeader

Давайте добавим в наше приложение красивый и простой заголовок с базовой навигацией.

  1. Создайте новый файл в ./app/components/SiteHeader.tsx
// ./app/components/SiteHeader.tsx
    
    // import Remix's link component
    import { Link } from "@remix-run/react";
    
    // import type definitions
    import { Profile } from "~/utils/types";
    
    // component accepts `user` prop to determine if user is logged in
    const SiteHeader = ({user} : {user?: Profile | undefined}) => {
      return (
        <header className="site-header">
          <div className="wrapper">
            <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
            <nav className="site-nav">
              <ul className="links">
                {/* show sign out link if user is logged in */}
                {user?.id ?
                  <>
                    {/* link to user profile */}
                    <li>
                      <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
                    </li>
                    <li className="link"><Link to="/sign-out">Sign out</Link></li>
                  </> :
                  <>
                    {/* show sign in and register link if user is not logged in */}
                    <li className="link"><Link to="/sign-in">Sign In</Link></li>
                    <li className="link"><Link to="/register">Register</Link></li>
                  </>
                }
              </ul>
            </nav>
          </div>
        </header>
      );
    };
    export default SiteHeader;
  1. Мы хотим, чтобы это всегда было видно в приложении, независимо от маршрута. Поэтому мы просто добавляем его в наш файл ./app/routes/root.tsx:
// ./app/root.jsx
    import type { MetaFunction, LinksFunction } from "@remix-run/node";
    
    // import compiled styles
    import styles from "./styles/app.css";
    import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
    
    // import site header component
    import SiteHeader from "./components/SiteHeader";
    
    // add site meta
    export const meta: MetaFunction = () => ({
      charset: "utf-8",
      title: "Profiles | Find & connect with people",
      viewport: "width=device-width,initial-scale=1",
    });
    
    // add links to site head
    export const links: LinksFunction = () => {
      return [{ rel: "stylesheet", href: styles }];
    };
    
    export default function App() {
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <main className="site-main">
              {/* place site header above app outlet */}
              <SiteHeader />
              <Outlet />
              <ScrollRestoration />
              <Scripts />
              <LiveReload />
            </main>
          </body>
        </html>
      );
    }

Создать компонент ProfileCard

Мы создадим компонент ProfileCard, который будет использоваться для отображения информации о пользователе. Создайте новый файл ./app/components/ProfileCard.tsx:

// ./app/components/ProfileCard.tsx
    
    import { Link } from "@remix-run/react";
    
    // type definitions for Profile response
    import { Profile } from "~/utils/types";
    
    // strapi url from environment variables
    const strapiUrl = `http://localhost:1337`;
    
    // helper function to get image url for user
    // we're also using https://ui-avatars.com api to generate images
    // the function appends the image url returned
    const getImgUrl = ({ url, username }: { url: string | undefined; username: string | "A+N" }) =>
      url ? `${strapiUrl}${url}` : `https://ui-avatars.com/api/?name=${username?.replace(" ", "+")}&background=2563eb&color=fff`;
    
    // component accepts `profile` prop which contains the user profile data and
    // `preview` prop which indicates whether the card is used in a list or
    // on its own in a dynamic page
    const ProfileCard = ({ profile, preview }: { profile: Profile; preview: boolean }) => {
      return (
        <>
          {/* add the .preview class if `preview` == true */}
          <article className={`profile ${preview ? "preview" : ""}`}>
            <div className="wrapper">
              <div className="profile-pic-cont">
                <figure className="profile-pic img-cont">
                  <img
                    src={getImgUrl({ url: profile.profilePic?.formats.small.url, username: profile.username })}
                    alt={`A photo of ${profile.username}`}
                    className="w-full"
                  />
                </figure>
              </div>
              <div className="profile-content">
                <header className="profile-header ">
                  <h3 className="username">{profile.username}</h3>
                  {/* show twitter name if it exists */}
                  {profile.twitterUsername && (
                    <a href="https://twitter.com/miracleio" className="twitter link">
                      @{profile.twitterUsername}
                    </a>
                  )}
                  {/* show bio if it exists */}
                  {profile.bio && <p className="bio">{profile.bio}</p>}
                </header>
                <ul className="links">
                  {/* show title if it exists */}
                  {profile.title && (
                    <li className="w-icon">
                      <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
                        />
                      </svg>
                      <span> {profile.title} </span>
                    </li>
                  )}
                  {/* show website url if it exists */}
                  {profile.websiteUrl && (
                    <li className="w-icon">
                      <svg className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
                        />
                      </svg>
                      <a href="http://miracleio.me" target="_blank" rel="noopener noreferrer" className="link">
                        {profile.websiteUrl}
                      </a>
                    </li>
                  )}
                </ul>
                {/* hide footer in preview mode */}
                {!preview && (
                  <footer className="grow flex items-end justify-end pt-4">
                    {/* hide link if no slug is present for the  user */}
                    {profile?.slug && (
                      <Link to={profile?.slug}>
                        <button className="cta w-icon">
                          <span>View profile</span>
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
                          </svg>
                        </button>
                      </Link>
                    )}
                  </footer>
                )}
              </div>
            </div>
          </article>
        </>
      );
    };
    export default ProfileCard;

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

Настройте серверный модуль для подключения к Strapi API

Давайте настроим модуль, который экспортирует функции getProfiles и getProfileBySlug. Создайте ./app/models/profiles.server.ts:

// ./app/models/profiles.server.tsx
    
    // import types
    import { Profile, ProfileData } from "~/utils/types"
    
    // Strapi API URL from environment varaibles
    const strapiApiUrl = process.env.STRAPI_API_URL
    
    
    // function to fetch all profiles
    export const getProfiles = async (): Promise<Array<Profile>> => {
      const profiles = await fetch(`${strapiApiUrl}/users/?populate=profilePic`)
      let response = await profiles.json()
    
      return response
    }
    
    // function to get a single profile by it's slug
    export const getProfileBySlug = async (slug: string | undefined): Promise<Profile> => {
      const profile = await fetch(`${strapiApiUrl}/users?populate=profilePic&filters[slug]=${slug}`)
      let response = await profile.json()
    
      // since the request is a filter, it returns an array
      // here we return the first itm in the array
      // since the slug is unique, it'll only return one item
      return response[0]
    }

Теперь на нашей индексной странице ./app/routes/index.tsx мы добавим следующее:

import { json } from "@remix-run/node";
    import { useLoaderData } from "@remix-run/react";
    
    // import profile card component
    import ProfileCard from "~/components/ProfileCard";
    
    // import get profiles function
    import { getProfiles } from "~/models/profiles.server";
    
    // loader data type definition
    type Loaderdata = {
      // this implies that the "profiles type is whatever type getProfiles resolves to"
      profiles: Awaited<ReturnType<typeof getProfiles>>;
    }
    
    // loader for route
    export const loader = async () => {
      return json<Loaderdata>({
        profiles: await getProfiles(),
      });
    };
    
    export default function Index() {
      const { profiles } = useLoaderData() as Loaderdata;
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Explore profiles</h2>
              <p>Find and connect with amazing people all over the world!</p>
            </header>
            {profiles.length > 0 ? (
              <ul className="profiles-list">
                {profiles.map((profile) => (
                  <li key={profile.id} className="profile-item">
                    <ProfileCard profile={profile} preview={false} />
                  </li>
                ))}
              </ul>
            ) : (
              <p>No profiles yet 🙂</p>
            )}{" "}
          </div>
        </section>
      );
    }

Здесь мы создаем функцию loader, которая вызывает функцию getProfiles, которую мы создали ранее, и загружает ответ в наш маршрут. Чтобы использовать эти данные, мы импортируем useLoaderData и вызываем его в Index() и получаем данные profiles путем деструктурирования.

У нас должно получиться что-то вроде этого:

Далее мы создадим динамический маршрут для отображения отдельных профилей.

Создание маршрутов с динамическим профилем

В файле ./app/routes/$slug.tsx мы будем использовать загрузчик params для получения slug из маршрута и запустить функцию getProfileBySlug() со значением для получения данных профиля.

// ./app/routes/$slug.tsx
    
    import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
    import { useLoaderData, useActionData } from "@remix-run/react";
    import { useEffect, useState } from "react";
    import ProfileCard from "~/components/ProfileCard";
    import { getProfileBySlug } from "~/models/profiles.server";
    import { Profile } from "~/utils/types";
    
    // type definition of Loader data
    type Loaderdata = {
      profile: Awaited<ReturnType<typeof getProfileBySlug>>;
    };
    
    // loader function to get posts by slug
    export const loader: LoaderFunction = async ({ params }) => {
      return json<Loaderdata>({
        profile: await getProfileBySlug(params.slug),
      });
    };
    
    const Profile = () => {
      const { profile } = useLoaderData() as Loaderdata;
      const errors = useActionData();
      const [profileData, setprofileData] = useState(profile);
      const [isEditing, setIsEditing] = useState(false);
    
      return (
        <section className="site-section">
          <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
            <div className="profile-cont w-full max-w-5xl m-auto">
              {profileData ? (
                <>
                  {/* Profile card with `preview` = true */}
                  <ProfileCard profile={profileData} preview={true} />
                  {/* list of actions */}
                  <ul className="actions">
                    <li className="action">
                      <button className="cta w-icon">
                        <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
                          />
                        </svg>
                        <span>Share</span>
                      </button>
                    </li>
                    <li className="action">
                      <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
                        {!isEditing ? (
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path
                              strokeLinecap="round"
                              strokeLinejoin="round"
                              d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
                            />
                          </svg>
                        ) : (
                          <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                            <path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
                          </svg>
                        )}
                        <span>{!isEditing ? "Edit" : "Cancel"}</span>
                      </button>
                    </li>
                  </ul>
                </>
              ) : (
                <p className="text-center">Oops, that profile doesn't exist... yet</p>
              )}
            </div>
          </div>
        </section>
      );
    };
    export default Profile;

Здесь мы также добавили две кнопки действий. Однако кнопка «Изменить» будет отображаться только тогда, когда пользователь войдет в систему. Мы вернемся к этому очень скоро. Вот как должна выглядеть страница для этого обычного пользователя:

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

Шаг 3: Аутентификация в Remix

Мы будем использовать традиционную аутентификацию по электронной почте и паролю для нашего приложения. Для Strapi это означает, что мы будем использовать локального поставщика аутентификации. С этим провайдером довольно просто работать.

Для входа нужно сделать POST-запрос к /api/auth/local с объектом body, содержащим идентификатор и пароль, как вы видите в этом примере из документации. Мы также могли бы легко использовать других провайдеров, если бы захотели. Страпи делает это легко.

Однако со стороны Remix нам придется кое-что сделать с Cookies, чтобы запустить аутентификацию. Strapi занимается регистрацией и аутентификацией пользователей. Таким образом, все, что нам нужно сделать в Remix, — это оставить пользователя в системе, используя файлы cookie для хранения пользовательских данных, особенно идентификатора пользователя и JWT.

Давайте начнем с создания функции входа в систему. Для этого нам нужно создать маршрут входа и форму для ввода пользователями своих данных. Для этого мы создадим повторно используемый компонент формы и назовем его <ProfileForm>.

Создать компонент ProfileForm

Эта форма будет содержать поля для входа пользователя, регистрации и обновления информации профиля. Для динамического отображения полей для аутентификации (регистрация пользователя и вход в систему) и редактирования профиля пользователя мы будем условно отображать поля ввода.

Вот обзор того, как мы этого добьемся:

<Form>
      {action != "login" && (
        <>
          {/* Profile registeration and update input fields */}
        </>
      )}
      {action != "edit" && (
        <>
          {/* User login input fields */}
        </>
      )}
    </Form>

Благодаря этому мы сможем:

  • Для действия "login" отображать только поля ввода входа, такие как email и password
  • Для действия "edit" только отображать **** поля профиля, такие как username, bio, website и т. д.
  • Для действия "create" отобразите оба поля входа и поля профиля. Это позволяет пользователям заполнять свои данные при создании учетной записи.

Однако эта «динамическая» форма не имеет решающего значения для нашего приложения. Мы просто пытаемся создать повторно используемый компонент формы для всех вариантов использования, которые у нас есть в настоящее время. Мы также можем создавать отдельные формы для разных действий.

Для реализации этого создайте новый файл ./app/components/ProfileForm.tsx:

// ./app/components/ProfileForm.tsx
    import { Form, useTransition } from "@remix-run/react";
    import { useEffect, useState } from "react";
    // custom type declarations
    import { Profile, ProfileFormProps } from "~/utils/types";
    const ProfileForm = ({ profile, onModifyData, action, errors }: ProfileFormProps) => {
      // get state of form
      const transition = useTransition();
      // state for user profile data
      const [profileData, setProfileData] = useState(profile);
      // state for user login information
      const [authData, setAuthData] = useState({ email: "", password: "" });
      // helper function to set profile data value
      const updateField = (field: object) => setProfileData((value) => ({ ...value, ...field }));
      // listen to changes to the profileData state
      // run the onModifyData() function passing the profileData to it
      //  this will snd the data to the parent component
      useEffect(() => {
        // run function if `onModifyData` is passed to the component
        if (onModifyData) {
          // depending on the action passed to the form
          // select which data to send to parent when modified
          // when action == create, send both the profile data and auth data
          if (action == "create") onModifyData({ ...profileData, ...authData });
          // when action == login, send only auth data
          else if (action == "login") onModifyData(authData);
          // send profile data by default (when action == edit)
          else onModifyData(profileData);
        }
      }, [profileData, authData]);
      return (
        <Form method={action == "edit" ? "put" : "post"} className="form">
          <fieldset disabled={transition.state == "submitting"}>
            <input value={profile?.id} type="hidden" name="id" required />
            <div className="wrapper">
              {action != "login" && (
                // profile edit input forms
                <>
                  <div className="form-group">
                    <div className="form-control">
                      <label htmlFor="username">Name</label>
                      <input
                        onChange={(e) => updateField({ username: e.target.value })}
                        value={profileData?.username}
                        id="username"
                        name="username"
                        type="text"
                        className="form-input"
                        required
                      />
                      {errors?.username ? <em className="text-red-600">{errors.username}</em> : null}
                    </div>
                    <div className="form-control">
                      <label htmlFor="twitterUsername">Twitter username</label>
                      <input
                        onChange={(e) => updateField({ twitterUsername: e.target.value })}
                        value={profileData?.twitterUsername}
                        id="twitterUsername"
                        name="twitterUsername"
                        type="text"
                        className="form-input"
                        placeholder="Without the @"
                      />
                    </div>
                  </div>
                  <div className="form-control">
                    <label htmlFor="bio">Bio</label>
                    <textarea
                      onChange={(e) => updateField({ bio: e.target.value })}
                      value={profileData?.bio}
                      name="bio"
                      id="bio"
                      cols={30}
                      rows={3}
                      className="form-textarea"
                    ></textarea>
                  </div>
                  <div className="form-group">
                    <div className="form-control">
                      <label htmlFor="job-title">Job title</label>
                      <input
                        onChange={(e) => updateField({ title: e.target.value })}
                        value={profileData?.title}
                        id="job-title"
                        name="job-title"
                        type="text"
                        className="form-input"
                      />
                      {errors?.title ? <em className="text-red-600">{errors.title}</em> : null}
                    </div>
                    <div className="form-control">
                      <label htmlFor="website">Website link</label>
                      <input
                        onChange={(e) => updateField({ websiteUrl: e.target.value })}
                        value={profileData?.websiteUrl}
                        id="website"
                        name="website"
                        type="url"
                        className="form-input"
                      />
                    </div>
                  </div>
                </>
              )}
              {action != "edit" && (
                // user auth input forms
                <>
                  <div className="form-control">
                    <label htmlFor="job-title">Email</label>
                    <input
                      onChange={(e) => setAuthData((data) => ({ ...data, email: e.target.value }))}
                      value={authData.email}
                      id="email"
                      name="email"
                      type="email"
                      className="form-input"
                      required
                    />
                    {errors?.email ? <em className="text-red-600">{errors.email}</em> : null}
                  </div>
                  <div className="form-control">
                    <label htmlFor="job-title">Password</label>
                    <input
                      onChange={(e) => setAuthData((data) => ({ ...data, password: e.target.value }))}
                      value={authData.password}
                      id="password"
                      name="password"
                      type="password"
                      className="form-input"
                    />
                    {errors?.password ? <em className="text-red-600">{errors.password}</em> : null}
                  </div>
                  {errors?.ValidationError ? <em className="text-red-600">{errors.ValidationError}</em> : null}
                  {errors?.ApplicationError ? <em className="text-red-600">{errors.ApplicationError}</em> : null}
                </>
              )}
              <div className="action-cont mt-4">
                <button className="cta"> {transition.state == "submitting" ? "Submitting" : "Submit"} </button>
              </div>
            </div>
          </fieldset>
        </Form>
      );
    };
    export default ProfileForm;

В этом компоненте у нас есть следующие реквизиты:

  • profile — содержит информацию о профиле пользователя для заполнения формы.
  • onModifyData — передать измененные данные родителю в зависимости от типа action.
  • action - определить действие формы
  • errors - ошибки, переданные в форму от родителя (после отправки формы)

Затем мы инициализируем и назначаем useTransition() для transition, которое мы будем использовать для получения состояния формы при ее отправке. Мы также устанавливаем состояния — profileData и authData, которые мы используем useEffect() для передачи значения состояния родительскому компоненту.

Наконец, мы возвращаем шаблон для компонента и условно отображаем поля ввода аутентификации и другие поля профиля в зависимости от типа действия, как объяснялось ранее.

Теперь, когда у нас есть готовый компонент формы, давайте начнем с создания функции входа в систему.

Создать функцию входа

Мы начнем с создания функции с именем signIn, которая будет отправлять детали аутентификации в конечную точку аутентификации Strapi. В ./app/models/profiles.server.ts создайте новую функцию: signIn()

// ./app/models/profiles.server.ts
    // import types
    import { LoginActionData, LoginResponse, Profile, ProfileData } from "~/utils/types"
    
    // ...
    
    // function to sign in
    export const signIn = async (data: LoginActionData): Promise<LoginResponse> => {
      // make POST request to Strapi Auth URL
      const profile = await fetch(`${strapiApiUrl}/auth/local`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify(data)
      })
      let response = await profile.json()
    
      // return login response
      return response
    }

Эта функция отправляет запрос на вход и возвращает данные пользователя, если детали, отправленные в body, совпадают. Следующее, что нам нужно сделать, это сохранить пользовательские данные в сеансе.

Сохранить сеанс пользователя с помощью CreateUserSession

В app/utils/session.server.ts мы напишем функцию createUserSession, которая принимает идентификатор пользователя и маршрут для перенаправления. Он должен сделать следующее:

  • создать новый сеанс (через функцию getSession хранилища файлов cookie)
  • установить поле userId в сеансе
  • перенаправить на заданный маршрут, установив заголовок Set-Cookie (через функцию хранения файлов cookie commitSession)

Для этого создайте новый файл: ./app/utils/session.server.ts

// ./app/utils/session.server.ts
    
    import { createCookieSessionStorage, redirect } from "@remix-run/node";
    import { LoginResponse } from "./types";
    // initialize createCookieSession
    const { getSession, commitSession, destroySession } = createCookieSessionStorage({
      cookie: {
        name: "userSession",
        // normally you want this to be `secure: true`
        // but that doesn't work on localhost for Safari
        // https://web.dev/when-to-use-local-https/
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60 * 24 * 30,
        httpOnly: true,
      }
    })
    // fucntion to save user data to session
    export const createUserSession = async (userData: LoginResponse, redirectTo: string) => {
      const session = await getSession()
      session.set("userData", userData);
      console.log({ session });
      return redirect(redirectTo, {
        headers: {
          "Set-Cookie": await commitSession(session)
        }
      })
    }

Большой. Теперь мы можем создать нашу страницу входа и использовать наш компонент <ProfileForm>.

Создайте новый файл ./app/routes/sign-in.tsx:

// ./app/routes/sign-in.tsx
    
    import { ActionFunction, json, redirect } from "@remix-run/node";
    import { useActionData } from "@remix-run/react";
    import ProfileForm from "~/components/ProfileForm";
    import { signIn } from "~/models/profiles.server";
    import { createUserSession } from "~/utils/session.server";
    import { LoginErrorResponse, LoginActionData } from "~/utils/types";
    export const action: ActionFunction = async ({ request }) => {
      try {
        // get request form data
        const formData = await request.formData();
        // get form values
        const identifier = formData.get("email");
        const password = formData.get("password");
    
        // error object
        // each error property is assigned null if it has a value
        const errors: LoginActionData = {
          identifier: identifier ? null : "Email is required",
          password: password ? null : "Password is required",
        };
        // return true if any property in the error object has a value
        const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
    
        // throw the errors object if any error
        if (hasErrors) throw errors;
    
        // sign in user with identifier and password
        let { jwt, user, error } = await signIn({ identifier, password });
    
        // throw strapi error message if strapi returns an error
        if (error) throw { [error.name]: error.message };
        // create user session
        return createUserSession({ jwt, user }, "/");
      } catch (error) {
        // return error response
        return json<LoginErrorResponse>(error);
      }
    };
    const Login = () => {
      const errors = useActionData();
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Sign in </h2>
              <p>You have to log in to edit your profile</p>
            </header>
            {/* set form action to `login` and pass errors if any */}
            <ProfileForm action="login" errors={errors} />
          </div>
        </section>
      );
    };
    export default Login;

Здесь у нас есть функция action, которая получает значение identifier и пароль с помощью formData после отправки формы и передает значения signIn(). Если ошибок нет, функция action создает сеанс с пользовательскими данными, возвращая createUserSession().

Если есть ошибки, мы throw ошибку и возвращаем в блоке catch. Затем ошибки автоматически отображаются в форме, поскольку мы передаем их в качестве реквизита в <ProfileForm>.

Теперь, если мы войдем в систему, используя адрес электронной почты и пароль пользователей, которых мы создали ранее в Strapi, запрос на вход будет отправлен, и в случае успеха будет создана сессия. Вы можете просмотреть файлы cookie на вкладке приложения devtools.

Теперь все сделанные запросы будут содержать куки в Headers:

Кроме того, компоненты <ProfileForm> могут обрабатывать переданные ему ошибки. Это показывает ValidationError, возвращаемый Strapi, когда пользователь вводит неправильный пароль.

Потрясающий. Теперь нам нужно получить пользовательские данные из сеанса, чтобы пользователь знал, что он вошел в систему.

Получить данные пользователя из сеанса файлов cookie

Чтобы получить информацию о пользователе из сеанса, мы создадим еще несколько функций: getUserSession(request), getUserData(request) и logout() в ./app/utils/session.server.ts.

// ./app/utils/session.server.ts
    // ...
    
    // get cookies from request
    const getUserSession = (request: Request) => {
      return getSession(request.headers.get("Cookie"))
    }
    // function to get user data from session
    export const getUserData = async (request: Request): Promise<LoginResponse | null> => {
      const session = await getUserSession(request)
      const userData = session.get("userData")
      console.log({userData});
      if(!userData) return null
      return userData
    }
    
    // function to remove user data from session, logging user out
    export const logout = async (request: Request) => {
      const session = await getUserSession(request);
      return redirect("/sign-in", {
        headers: {
          "Set-Cookie": await destroySession(session)
        }
      })
    }

Что нам нужно знать, так это сообщить пользователю, что он вошел в систему, показывая имя пользователя и скрывая ссылки «войти» и «зарегистрироваться» в заголовке сайта. Для этого мы создадим функцию загрузки в ./app/root.jsx, чтобы получить пользовательские данные из сеанса и передать их компоненту <SiteHeader>.

// ./app/root.jsx
    // ...
    import { getUserData } from "./utils/session.server";
    type LoaderData = {
      userData: Awaited<ReturnType<typeof getUserData>>;
    };
    
    // loader function to get and return userdata
    export const loader: LoaderFunction = async ({ request }) => {
      return json<LoaderData>({
        userData: await getUserData(request),
      });
    };
    export default function App() {
      const { userData } = useLoaderData() as LoaderData;
      return (
        <html lang="en">
          <head>
            <Meta />
            <Links />
          </head>
          <body>
            <main className="site-main">
              {/* place site header above app outlet, pass user data as props */}
              <SiteHeader user={userData?.user} />
              {/* ... */}
            </main>
          </body>
        </html>
      );
    }

Помните, что компонент условно отображает ссылки «Вход», «Регистрация» и «Выход» в зависимости от пользовательских данных, переданных компоненту. Теперь, когда мы передали пользовательские данные, мы должны получить что-то вроде этого:

Создайте функцию выхода из системы

Первое, что мы сделаем, это изменим наш компонент <SiteHeader> в ./app/components/SiteHeader.tsx. Мы заменим ссылку Sign out на <Form> вот так:

// ./app/components/SiteHeader.tsx
    // import Remix's link component
    import { Form, Link, useTransition } from "@remix-run/react";
    // import type definitions
    import { Profile } from "~/utils/types";
    // component accepts `user` prop to determine if user is logged in
    const SiteHeader = ({user} : {user?: Profile | undefined}) => {
      const transition = useTransition()
      return (
        <header className="site-header">
          <div className="wrapper">
            <figure className="site-logo"><Link to="/"><h1>Profiles</h1></Link></figure>
            <nav className="site-nav">
              <ul className="links">
                {/* show sign out link if user is logged in */}
                {user?.id ?
                  <>
                    {/* link to user profile */}
                    <li>
                      <Link to={`/${user?.slug}`}> Hey, {user?.username}! </Link>
                    </li>
                    {/* Form component to send POST request to the sign out route */}
                    <Form action="/sign-out" method="post" className="link">
                      <button type="submit" disabled={transition.state != "idle"} >
                        {transition.state == "idle" ? "Sign Out" : "Loading..."}
                      </button>
                    </Form>
                  </> :
                  <>
                    {/* show sign in and register link if user is not logged in */}
                    {/* ...  */}
                  </>
                }
              </ul>
            </nav>
          </div>
        </header>
      );
    };
    export default SiteHeader;

Затем мы создадим маршрут ./app/routes/sign-out.tsx и введем следующий код:

// ./app/routes/sign-out.tsx
    import { ActionFunction, LoaderFunction, redirect } from "@remix-run/node";
    import { logout } from "~/utils/session.server";
    
    // action to get the /sign-out request action from the sign out form
    export const action: ActionFunction = async ({ request }) => {
      return logout(request);
    };
    
    // loader to redirect to "/"
    export const loader: LoaderFunction = async () => {
      return redirect("/");
    };

Теперь, если мы нажмем кнопку выхода. Он отправляет форму с action=``"``/sign-out``", которая обрабатывается функцией action в ./app/routes/sign-out.tsx. Затем загрузчик на странице выхода перенаправляет пользователя на «/» по умолчанию, когда пользователь посещает этот маршрут.

Теперь займемся регистрацией пользователей.

Регистрация пользователя

Это очень похоже на то, что мы делали для входа в систему. Во-первых, мы создаем функцию register() в ./app/models/profiles.server.ts:

// ./app/models/profiles.server.ts
    // import types
    import slugify from "~/utils/slugify"
    import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
    
    // ...
    
    // function to register user
    export const register = async (data: RegisterActionData): Promise<LoginResponse> => {
      // generate slug from username
      let slug = slugify(data.username?.toString())
      data.slug = slug
    
      // make POST request to Strapi Register Auth URL
      const profile = await fetch(`${strapiApiUrl}/auth/local/register`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify(data)
      })
    
      // get response from request
      let response = await profile.json()
    
      // return register response
      return response
    }

Теперь создайте новый файл ./app/routes/register.jsx для маршрута /register:

// ./app/routes/register.tsx
    import { ActionFunction, json } from "@remix-run/node";
    import { useActionData } from "@remix-run/react";
    import ProfileForm from "~/components/ProfileForm";
    import { register } from "~/models/profiles.server";
    import { createUserSession } from "~/utils/session.server";
    import { ErrorResponse, RegisterActionData } from "~/utils/types";
    export const action: ActionFunction = async ({ request }) => {
      try {
        // get request form data
        const formData = await request.formData();
        // get form input values
        const email = formData.get("email");
        const password = formData.get("password");
        const username = formData.get("username");
        const title = formData.get("job-title");
        const twitterUsername = formData.get("twitterUsername");
        const bio = formData.get("bio");
        const websiteUrl = formData.get("website");
        const errors: RegisterActionData = {
          email: email ? null : "Email is required",
          password: password ? null : "Password is required",
          username: username ? null : "Username is required",
          title: title ? null : "Job title is required",
        };
        const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
        if (hasErrors) throw errors;
        console.log({ email, password, username, title, twitterUsername, bio, websiteUrl });
        // function to register user with user details
        const { jwt, user, error } = await register({ email, password, username, title, twitterUsername, bio, websiteUrl });
        console.log({ jwt, user, error });
        // throw strapi error message if strapi returns an error
        if (error) throw { [error.name]: error.message };
        // create user session
        return createUserSession({ jwt, user }, "/");
      } catch (error) {
        // return error response
        return json(error);
      }
    };
    const Register = () => {
      const errors = useActionData();
      console.log({ errors });
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Register</h2>
              <p>Create a new profile</p>
            </header>
            {/* set form action to `login` and pass errors if any */}
            <ProfileForm action="create" errors={errors} />
          </div>
        </section>
      );
    };
    export default Register;

Вот что у нас должно быть сейчас:

Теперь, когда мы можем регистрировать пользователей и входить в систему, давайте разрешим пользователям редактировать свои профили после входа в систему.

Добавить функцию сброса пароля

Нам нужно настроить Strapi. Давайте установим nodemailer для отправки электронных писем пользователям. Вернитесь в папку проекта Strapi, остановите сервер и установите поставщика Strapi Nodemailer:

npm install @strapi/provider-email-nodemailer --save

Теперь создайте новый файл ./config/plugins.js

module.exports = ({ env }) => ({
      email: {
        config: {
          provider: 'nodemailer',
          providerOptions: {
            host: env('SMTP_HOST', 'smtp.gmail.com'),
            port: env('SMTP_PORT', 465),
            auth: {
              user: env('GMAIL_USER'),
              pass: env('GMAIL_PASSWORD'),
            },
            // ... any custom nodemailer options
          },
          settings: {
            defaultFrom: '[email protected]',
            defaultReplyTo: '[email protected]',
          },
        },
      },
    });

В этом примере я буду использовать Gmail; вы можете использовать любого провайдера электронной почты по вашему выбору. Вы можете найти инструкции в Документации Strapi.

Добавьте переменные среды в файл ./.env:

SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
[email protected]
GMAIL_PASSWORD=<generated-pass>

Вы можете узнать больше о том, как генерировать пароли Gmail, которые работают с Nodemailer.

Запустите сервер:

yarn develop

На панели администратора Strapi перейдите в раздел НАСТРОЙКИ > ПЛАГИНЫ ПОЛЬЗОВАТЕЛЕЙ И РАЗРЕШЕНИЙ > РОЛИ > ПУБЛИЧНЫЕ > ПОЛЬЗОВАТЕЛИ-РАЗРЕШЕНИЯ включите действия forgotPassword и resetPassword.

Мы также можем изменить шаблон электронной почты для сброса пароля в Strapi. Перейдите к:

Затем мы вернемся к нашему проекту Remix и добавим новые функции для забытого и сброса пароля.

Добавьте функцию «Забыли пароль»

Создайте новую функцию sendResetMail в ./app/models/profiles.server.ts:

// ./app/models/profiles.server.ts
    // ...
    
    // function to send password reset email
    export const sendResetMail = async (email: string | File | null | undefined) => {
      const response = await (await fetch(`${strapiApiUrl}/auth/forgot-password`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ email })
      })).json()
      return response
    }

Теперь создайте страницу забытого пароля, создайте новый файл ./app/routes/forgot-password:

import { ActionFunction, json } from "@remix-run/node";
    import { Form, useActionData, useTransition } from "@remix-run/react";
    import { sendResetMail } from "~/models/profiles.server";
    
    // action function to get form values and run reset mail function
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
      const email = formData.get("email");
      const response = await sendResetMail(email);
      return json(response);
    };
    const ForgotPass = () => {
      const transition = useTransition();
      const data = useActionData();
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Forgot password</h2>
              <p>Click the button below to send the reset link to your registerd email</p>
            </header>
            <Form method="post" className="form">
              <div className="wrapper">
                <p>{data?.ok ? "Link sent! Check your mail. Can't find it in the inbox? Check Spam" : ""}</p>
                <div className="form-control">
                  <label htmlFor="email">Email</label>
                  <input id="email" name="email" type="email" className="form-input" required />
                </div>
                <div className="action-cont mt-4">
                  <button className="cta"> {transition.state == "submitting" ? "Sending" : "Send link"} </button>
                </div>
              </div>
            </Form>
          </div>
        </section>
      );
    };
    export default ForgotPass;

Вот как выглядит страница:

Добавить функцию «Сбросить пароль»

Сначала создайте новую функцию resetPass в ./app/models/profiles.session.ts.

// ./app/models/profiles.server.ts
    // ...
    
    // function to reset password
    export const resetPass = async ({ password, passwordConfirmation, code }: { password: File | string | null | undefined, passwordConfirmation: File | string | null | undefined, code: File | string | null | undefined }) => {
      const response = await (await fetch(`${strapiApiUrl}/auth/reset-password`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          password,
          passwordConfirmation,
          code
        })
      })).json()
      return response
    }

Эта функция отправляет запрос на /api/auth/reset-password с паролем, подтверждением и code, который отправляется на почту пользователя. Создайте новую страницу сброса пароля, чтобы отправить запрос с паролем и кодом, ./app/routes/reset-password.tsx

// ./app/routes/reset-password.tsx
    
    import { ActionFunction, json, LoaderFunction, redirect } from "@remix-run/node";
    import { Form, useActionData, useLoaderData, useTransition } from "@remix-run/react";
    import { resetPass } from "~/models/profiles.server";
    type LoaderData = {
      code: string | undefined;
    };
    // get code from URL parameters
    export const loader: LoaderFunction = async ({ request }) => {
      const url = new URL(request.url);
      const code = url.searchParams.get("code");
      // take user to homepage if there's no code in the url
      if (!code) return redirect("/");
      return json<LoaderData>({
        code: code,
      });
    };
    // get password and code and send reset password request
    export const action: ActionFunction = async ({ request }) => {
      const formData = await request.formData();
      const code = formData.get("code");
      const password = formData.get("password");
      const passwordConfirmation = formData.get("confirmPassword");
      const response = await resetPass({ password, passwordConfirmation, code });
      // return error is passwords don't match
      if (password != passwordConfirmation) return json({ confirmPassword: "Passwords should match" });
      return json(response);
    };
    const ResetPass = () => {
      const transition = useTransition();
      const error = useActionData();
      const { code } = useLoaderData() as LoaderData;
      return (
        <section className="site-section profiles-section">
          <div className="wrapper">
            <header className="section-header">
              <h2 className="text-4xl">Reset password</h2>
              <p>Enter your new password</p>
            </header>
            <Form method="post" className="form">
              <input value={code} type="hidden" id="code" name="code" required />
              <div className="wrapper">
                <div className="form-control">
                  <label htmlFor="job-title">Password</label>
                  <input id="password" name="password" type="password" className="form-input" required />
                </div>
                <div className="form-control">
                  <label htmlFor="job-title">Confirm password</label>
                  <input id="confirmPassword" name="confirmPassword" type="password" className="form-input" required />
                  {error?.confirmPassword ? <em className="text-red-600">{error.confirmPassword}</em> : null}
                </div>
                <div className="action-cont mt-4">
                  <button className="cta"> {transition.state == "submitting" ? "Sending" : "Reset password"} </button>
                </div>
              </div>
            </Form>
          </div>
        </section>
      );
    };
    export default ResetPass;

Посмотрите в действии:

Шаг 4. Добавьте функцию «Редактировать профиль» для аутентифицированных пользователей

Во-первых, мы создаем новую функцию updateProfile(), которая принимает пользовательский ввод и JWT token в качестве аргументов. Вернувшись в ./app/models/profiles.server.ts, добавьте функцию updateProfile():

// ./app/models/profiles.server.ts
    // import types
    import slugify from "~/utils/slugify"
    import { LoginActionData, LoginResponse, Profile, ProfileData, RegisterActionData } from "~/utils/types"
    
    // ...
    
    // function to update a profile
    export const updateProfile = async (data: ProfileData, token: string | undefined): Promise<Profile> => {
      // get id from data
      const { id } = data
      // PUT request to update data
      const profile = await fetch(`${strapiApiUrl}/users/${id}`, {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          // set the auth token to the user's jwt
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(data)
      })
      let response = await profile.json()
      return response
    }

Здесь мы отправляем запрос на обновление пользовательских данных с набором Authorization в файле headers. Мы передадим token функции updateProfile, которая будет получена из сеанса пользователя.

На нашей странице ./app/routes/$slug.tsx нам нужно действие для вызова этой функции и передачи необходимых аргументов. Мы добавим наш компонент <ProfileForm> и установим действие "``edit``". Эта форма будет отображаться только в том случае, если данные пользователя, выполнившего вход, совпадают с данными пользователя на маршруте текущего профиля. Мы также покажем кнопку редактирования и <ProfileForm>, если идентификатор профиля совпадает с вошедшим в систему пользователем, и добавим функцию action для обработки отправки и проверки формы.

// ./app/routes/$slug.tsx
    import { json, LoaderFunction, ActionFunction, redirect } from "@remix-run/node";
    import { useLoaderData, useActionData } from "@remix-run/react";
    import { useEffect, useState } from "react";
    import { updateProfile } from "~/models/profiles.server";
    import { getProfileBySlug } from "~/models/profiles.server";
    import { getUserData } from "~/utils/session.server";
    import { Profile } from "~/utils/types";
    import ProfileCard from "~/components/ProfileCard";
    import ProfileForm from "~/components/ProfileForm";
    // type definition of Loader data
    type Loaderdata = {
      userData: Awaited<ReturnType<typeof getUserData>>;
      profile: Awaited<ReturnType<typeof getProfileBySlug>>;
    };
    // action data type
    type EditActionData =
      | {
          id: string | null;
          username: string | null;
          title: string | null;
        }
      | undefined;
    // loader function to get posts by slug
    export const loader: LoaderFunction = async ({ params, request }) => {
      return json<Loaderdata>({
        userData: await getUserData(request),
        profile: await getProfileBySlug(params.slug),
      });
    };
    // action to handle form submission
    export const action: ActionFunction = async ({ request }) => {
      // get user data
      const data = await getUserData(request)
      // get request form data
      const formData = await request.formData();
      // get form values
      const id = formData.get("id");
      const username = formData.get("username");
      const twitterUsername = formData.get("twitterUsername");
      const bio = formData.get("bio");
      const title = formData.get("job-title");
      const websiteUrl = formData.get("website");
    
      // error object
      // each error property is assigned null if it has a value
      const errors: EditActionData = {
        id: id ? null : "Id is required",
        username: username ? null : "username is required",
        title: title ? null : "title is required",
      };
      // return true if any property in the error object has a value
      const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);
      // return the error object
      if (hasErrors) return json<EditActionData>(errors);
      // run the update profile function
      // pass the user jwt to the function
      await updateProfile({ id, username, twitterUsername, bio, title, websiteUrl }, data?.jwt);
      // redirect users to home page
      return null;
    };
    const Profile = () => {
      const { profile, userData } = useLoaderData() as Loaderdata;
      const errors = useActionData();
      const [profileData, setprofileData] = useState(profile);
      const [isEditing, setIsEditing] = useState(false);
      console.log({ userData, profile });
    
      return (
        <section className="site-section">
          <div className="wrapper flex items-center py-16 min-h-[calc(100vh-4rem)]">
            <div className="profile-cont w-full max-w-5xl m-auto">
              {profileData ? (
                <>
                  {/* Profile card with `preview` = true */}
                  <ProfileCard profile={profileData} preview={true} />
                  {/* list of actions */}
                  <ul className="actions">
                    <li className="action">
                      <button className="cta w-icon">
                        <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                          <path
                            strokeLinecap="round"
                            strokeLinejoin="round"
                            d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
                          />
                        </svg>
                        <span>Share</span>
                      </button>
                    </li>
                    {userData?.user?.id == profile.id && (
                      <li className="action">
                        <button onClick={() => setIsEditing(!isEditing)} className="cta w-icon">
                          {!isEditing ? (
                            <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                              <path
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
                              />
                            </svg>
                          ) : (
                            <svg xmlns="http://www.w3.org/2000/svg" className="icon stroke" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                              <path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
                            </svg>
                          )}
                          <span>{!isEditing ? "Edit" : "Cancel"}</span>
                        </button>
                      </li>
                    )}
                  </ul>
                </>
              ) : (
                <p className="text-center">Oops, that profile doesn't exist... yet</p>
              )}
              {/* display dynamic form component when user clicks on edit */}
              {userData?.user?.id == profile?.id && isEditing && (
                <ProfileForm errors={errors} profile={profile} action={"edit"} onModifyData={(value: Profile) => setprofileData(value)} />
              )}
            </div>
          </div>
        </section>
      );
    };
    export default Profile;

Теперь, когда мы вошли в систему как определенный пользователь, мы можем редактировать эти пользовательские данные, как показано здесь:

Заключение

До сих пор мы видели, как мы можем создать приложение Remix с аутентификацией, используя Strapi в качестве Headless CMS. Давайте подытожим, чего нам удалось достичь на данный момент.

  • Мы создали и настроили Strapi, настроили тип коллекции User и изменили разрешения для общедоступных и аутентифицированных пользователей.
  • Мы создали новое приложение Remix с Tailwind для стилизации.
  • В Strapi мы использовали локальный провайдер (адрес электронной почты и пароль) для аутентификации.
  • В Remix мы использовали файлы cookie для хранения пользовательских данных и JWT, что позволяет нам отправлять аутентифицированные запросы к Strapi.
  • Мы добавили функции забытого пароля и сброса пароля, настроив плагин Strapi Email.

Я уверен, что вы смогли почерпнуть одну или две новые вещи из этого урока. Если вы где-то застряли, исходный код приложений Strapi и Remix доступен на GitHub и указан в разделе ресурсов.

Ресурсы

Вот несколько статей, которые, я думаю, будут вам полезны:

Как и было обещано, код внешнего интерфейса Remix и внутреннего интерфейса Strapi доступен на GitHub:

Кроме того, вот живой пример, размещенный на Netlify.

Удачного кодирования!