🌐

remixでフロントエンドを作ってみた

2024/12/14に公開

Remixを選択した理由

ここで少し「なぜRemixを使うのか?」について触れたいと思います。
https://remix.run/

web標準に準拠

Remixは独自の記法はありますが、基本的にWeb標準に準拠したWebフレームワークになっています。
fetchやform、cookieなど、基本的にはこれまで使用されてきたweb技術をそのまま使うことができます。
これにより、標準的な書き方を使用できるため、個別の記法を改めて覚える必要がなく、前提の知識を活用できたり、初学者が学習後に、別のプロジェクトなどで生かせる知識を得やすいなどのメリットがあります。

SSRが基本

フレームワークとしては、SSG(静的サイトジェネレータ(HTMLを予め生成し、ブラウザから呼び出す際には、生成済みのHTMLを配信する仕組み))やSPA(ブラウザ側で表示の処理を担当)ではなく、SSR(サーバーサイドレンダリング(ブラウザからデータの呼び出しを受け付け、その都度サーバーサイドでレンダリングを請け負う仕組み))を採用しています。
SEOの面でも有利ですので、SSGやSPAを採用する必要がない場合は、検討してもいいかもしれません。

細かい説明などは、私以外の人がしっかりと解説していますので、そちらに託すとしたいと思います。
https://zenn.dev/acompany/articles/123c29f46d213c

Remixの辛いところ

とはいえ、Remixにも若干辛いところがあります。
それを少しまとめておこうとおもいます。

複数のファイルでページを構成

Remixではファイルベースルーティングを採用しているため、Next.jsやNuxtに近いルーティング機能を持っています。
が、Next.jsやNuxtと異なる点として、ディレクトリベースではないということです。
Next.jsやNuxtはディレクトリを作成し、その配下に作成したファイル名からURLが生成されます。
Remixはこれらとは異なり、routesディレクトリ内に配置したファイルを使用してページを構成し、ルーティングを生成します。

// 例
─ app
   ├─components
   ├─routes
   │  ├─file_1.tsx
   │  ├─file_1._index.tsx
   │  └─file_1.$uuid.tsx
   └root.tsx

この場合、「/file_1」と「/file_1/{uuid}」が生成されます。
複数のファイルでページを構成するため、routes内に作成されるファイル数が多くなり管理が難しくなりそうです。
https://remix.run/docs/en/main/discussion/routes

ファイル名が長くなりがち

上記のとおりファイル名をもとにページを構成することや例示したものからさらに回想を深くするようなルーティングを想定してる場合、ファイル名がとんでもなく長くなることがあるかもしれません。

// 例
─ app
   ├─components
   ├─routes
   │  ├─post.edit.file_1.tsx
   │  ├─post.edit.file_1._index.tsx
   │  └─post.edit.file_1.$uuid.tsx
   └root.tsx
// /post/edit/{uuid}を生成

serverとフロントで分離

これはメリットでもありますが、人によってはデメリットでもあると思います。
フロントエンドフレームワークでありながら、サーバーサイドの処理を書けるのですが、データへのアクセスに少し迷いました。
loaderやactionといった仕様があり、この中ではサーバーサイドの処理を書くことができますが、フロントエンドのコードを書いているファイルと同一ファイル内に記載するため、フロントエンド部分で定義した定数を使えるようなに感じてしまいました。

// ここはサーバーサイド
export async function loader({request, params}: LoaderFunctionArgs) {
// データ取得のロジックを記載
}

// ここはサーバーサイド
export async function action ({request, params): ActionFunctionArgs) {
// データ登録などのロジックを記載
}

// ここはフロントエンド
export default function ExamplePage() {
  // stateの管理などを記載
  return (
    // 実際の表示部分のコードを書く
  )
}

実際のコード

下記のコードは今回作ったシステムの一部です。
基本的なCRUD処理を搭載しています。
今回はUserの登録処理などですが、コンテンツの登録処理(ファイルアップロード)なども実装しています(今回は省略)。
コンテンツ登録処理以下に示すコードを参考に実装できるかもしれませんが、ファイルアップロードは、こちらを参考にしました。
https://remix.run/docs/en/main/guides/file-uploads
https://zenn.dev/otaki0413/scraps/7d1190b507f5e8
https://zenn.dev/tor_inc/articles/14826b070b29bc

なお、UIにはshadcn/uiを利用しています。
https://ui.shadcn.com/
https://ui.shadcn.com/docs/installation/remix
オフィシャルのインストールガイドもありますが、こちらの記事も参考にしました。
https://zenn.dev/harukii/articles/8c00c7f7d19c70

user_list.tsx
import {
  Outlet,
  redirect,
  useLoaderData,
  useOutletContext,
} from "@remix-run/react";
import {
  ActionFunction,
  LoaderFunctionArgs,
  type MetaFunction,
} from "@remix-run/node";
import {
  getAllUser,
  getTokenFromSession,
  logout,
  requireAccountSession,
} from "../data/auth.server";
import { UserListComponent } from "~/components/UserList";

export const meta: MetaFunction = () => {
  return [
    { title: "ユーザーリスト | Press Release" },
    {
      name: "description",
      content: "システムを利用するユーザーの登録情報です。",
    },
  ];
};

export const action: ActionFunction = async ({ request }) => {
  return logout(request);
};

export async function loader({ request }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request); // login check
  const token = await getTokenFromSession(request);

  if (!data) {
    return redirect("/auth/login");
  }

  const user_list = getAllUser(token?.access_token);
  return user_list;
}

export default function UserList() {
  const data = useLoaderData<typeof loader>();
  const login_user = useOutletContext();

  return (
    <div>
      <div className="flex justify-start h-full">
        <UserListComponent user_list={data} />
        <Outlet context={login_user} />
      </div>
    </div>
  );
}
user_list._index.tsx
export default function UserListIndex() {
  return <div className="text-black"></div>;
}
user_list.$uuid.tsx
import { LoaderFunctionArgs, redirect } from "@remix-run/node";
import {
  getTokenFromSession,
  getUserData,
  requireAccountSession,
} from "../data/auth.server";
import { Link, useLoaderData, useOutletContext } from "@remix-run/react";
import DeleteUserButton from "../components/DeleteUserButton";
import { Button } from "../components/ui/button";
import {
  Card,
  CardContent,
  CardTitle,
  CardHeader,
  CardDescription,
  CardFooter,
} from "../components/ui/card";
import {
  Drawer,
  DrawerTrigger,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerDescription,
  DrawerFooter,
  DrawerClose,
} from "../components/ui/drawer";
import formatCustomDate from "../data/date";
import { User } from "../type/authType";
import { Checkbox } from "../components/ui/checkbox";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request); // login check
  const token = await getTokenFromSession(request);
  if (!data) {
    return redirect("/auth/login");
  }

  // get params
  const uuid = params.uuid;

  const user_data = getUserData(request, uuid);
  return user_data;
}

export default function UserListItem() {
  const login_user: User = useOutletContext();
  const user = useLoaderData<typeof loader>();
  const createdAt = user?.created_at;
  const created_at = createdAt ? new Date(createdAt) : null;
  const formatted_created_at = formatCustomDate(
    created_at,
    "yyyy年MM月dd日 HH時mm分ss秒"
  );

  return (
    <div className="w-[100%] mx-auto ml-5 my-5 text-lg">
      <Card className="w-full my-3">
        <CardContent>
          <CardTitle className="my-3 mx-auto text-center">
            ユーザー情報
          </CardTitle>
          <CardHeader className="p-0">ユーザー名: {user?.username}</CardHeader>
          <CardDescription className="p-0"></CardDescription>
          <CardContent className="p-0">
            <div>Email: {user?.email}</div>
            <div>登録日: {formatted_created_at}</div>
            <div className="mt-2 flex items-center">
              <Checkbox
                checked={user?.is_active}
                className="mr-3 cursor-default"
              />
              {user?.is_active == true ? "有効" : "無効"}
            </div>
            <div className="mt-2 flex items-center">
              <Checkbox
                checked={user?.is_superuser}
                className="mr-3 cursor-default"
              />
              {user?.is_superuser == true ? "管理者" : "一般ユーザー"}
            </div>
          </CardContent>
          <CardFooter className="text-sm mt-3 p-0 justify-end">
            ID: {user?.uuid}
          </CardFooter>
          <CardFooter className="mt-3 p-0 justify-between">
            <Button variant="ghost">
              <Link to={`/edit_user/${user?.uuid}`}>EDIT</Link>
            </Button>
            {login_user?.is_superuser && (
              <Drawer>
                <DrawerTrigger asChild>
                  <Button
                    variant="ghost"
                    className="hover:bg-red-600 hover:text-white"
                  >
                    DELETE
                  </Button>
                </DrawerTrigger>
                <DrawerContent>
                  <div className="mx-auto w-full max-w-sm">
                    <DrawerHeader>
                      <DrawerTitle className="text-2xl text-red-600 text-center">
                        DANGER
                      </DrawerTitle>
                      <DrawerDescription className="text-md text-red-600 text-center">
                        削除の処理を取り消すことはできません。
                        <br />
                        本当に削除してよいですか?
                      </DrawerDescription>
                    </DrawerHeader>
                    <div className="p-4 pb-0">
                      <div className="flex items-center justify-center space-x-2">
                        <DrawerFooter>
                          <DeleteUserButton user_uuid={user?.uuid} />
                          <DrawerClose asChild>
                            <Button variant="ghost" className="text-black">
                              Cancel
                            </Button>
                          </DrawerClose>
                        </DrawerFooter>
                      </div>
                    </div>
                  </div>
                </DrawerContent>
              </Drawer>
            )}
          </CardFooter>
        </CardContent>
      </Card>
    </div>
  );
}
edit_user.tsx
import { Outlet, redirect, useOutletContext } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { logout, requireAccountSession } from "../data/auth.server";

export const action: ActionFunction = async ({ request }) => {
  return logout(request);
};

export async function loader({ request }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request); // login check

  if (!data) {
    return redirect("/auth/login");
  }
  return data;
}

export default function EditUser() {
  const login_user = useOutletContext();
  return (
    <div>
      <div>
        <Outlet context={login_user} />
      </div>
    </div>
  );
}
edit_user._index.tsx
export default function EditUserIndex() {
  return (
    <div className="text-black">
      <h1 className="text-3xl">Edit User</h1>
    </div>
  );
}
edit_user.$uuid.tsx
import {
  Form,
  Link,
  redirect,
  useFetcher,
  useLoaderData,
  useOutletContext,
  useParams,
} from "@remix-run/react";
import { requireAccountSession, updateUser } from "../data/auth.server";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import DeleteUserButton from "../components/DeleteUserButton";
import LogoutButton from "../components/LogoutButton";
import { Button } from "../components/ui/button";
import {
  Card,
  CardContent,
  CardTitle,
  CardHeader,
  CardDescription,
  CardFooter,
} from "../components/ui/card";
import {
  Drawer,
  DrawerTrigger,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerDescription,
  DrawerFooter,
  DrawerClose,
} from "../components/ui/drawer";
import customDateFormat from "../data/date";
import { Input } from "../components/ui/input";
import { useState } from "react";
import { Checkbox } from "../components/ui/checkbox";
import { User } from "../type/authType";

export async function loader({ request }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request); // login check

  if (!data) {
    return redirect("/auth/login");
  }
  return data;
}

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const user_uuid = formData.get("uuid")?.toString();

  if (!user_uuid) {
    return new Response("User ID is required.", { status: 400 });
  }
  if (typeof user_uuid !== "string") {
    return JSON.stringify({ error: "Invalid user ID" });
  }

  const username = formData.get("username")?.toString();
  const email = formData.get("email")?.toString();
  const is_active = formData.get("is_active") === "true";
  const is_superuser = formData.get("is_superuser") === "true";

  try {
    await updateUser(
      request,
      user_uuid,
      username,
      email,
      is_active,
      is_superuser
    );
    return redirect(`/mypage/${user_uuid}`);
  } catch (error: unknown) {
    return Response.json({ error: "Failed to delete user." });
  }
}

export default function EditUser_Uuid() {
  // login_user info
  const login_user: User = useOutletContext();

  const user = useLoaderData<typeof loader>();
  const created_at = user?.created_at ? new Date(user?.created_at) : null;
  const formatted_created_at = customDateFormat(
    created_at,
    "yyyy年MM月dd日 HH時mm分ss秒"
  );

  // get params
  const data = useParams();
  const uuid = data["uuid"];

  // state
  const [username, setUsername] = useState<string | undefined>(user?.username);
  const [email, setEmail] = useState<string | undefined>(user?.email);
  const [isActive, setIsActive] = useState<boolean | undefined>(
    user?.is_active
  );
  const [isSuperuser, setIsSuperuser] = useState<boolean | undefined>(
    user?.is_superuser
  );
  const fetcher = useFetcher();

  const handleIsActiveCheckboxChange = () => {
    setIsActive((prev) => !prev); // 現在の状態を反転
  };

  const handleIsSuperuserCheckboxChange = () => {
    setIsSuperuser((prev) => !prev); // 現在の状態を反転
  };

  return (
    <div className="max-w-[650px] mx-auto my-5 text-lg">
      <h1 className="text-black text-center text-xl">My Page</h1>
      <Card className="max-w-[90%] mx-3 my-3">
        <Form method="post">
          <CardContent>
            <CardTitle className="my-3 mx-auto text-center">
              ユーザー情報
            </CardTitle>
            <CardHeader className="p-0">
              ユーザー名:
              <Input
                name="username"
                defaultValue={user.username}
                placeholder="username"
                onChange={(e) => setUsername(e.target.value)}
              />
            </CardHeader>
            <CardDescription className="p-0"></CardDescription>
            <CardContent className="p-0">
              <div className="mt-2">
                Email:{" "}
                <Input
                  name="email"
                  defaultValue={user.email}
                  placeholder="Email"
                  onChange={(e) => setEmail(e.target.value)}
                />
              </div>
              <div className="mt-2 flex items-center">
                <Checkbox
                  checked={isActive}
                  className="mr-3"
                  onCheckedChange={handleIsActiveCheckboxChange}
                />
                {isActive == true ? "有効" : "無効"}
                <Input
                  type="hidden"
                  name="is_active"
                  value={isActive ? "true" : "false"}
                />
              </div>
              <div className="mt-2 flex items-center">
                <Checkbox
                  checked={isSuperuser}
                  className="mr-3"
                  onCheckedChange={handleIsSuperuserCheckboxChange}
                />
                {isSuperuser == true ? "管理者" : "一般ユーザー"}
                <Input
                  type="hidden"
                  name="is_superuser"
                  value={isSuperuser ? "true" : "false"}
                />
              </div>

              <div className="mt-2">登録日: {formatted_created_at}</div>
            </CardContent>
            <CardFooter className="text-sm mt-3 p-0 justify-end">
              ID: {uuid}
              <Input type="hidden" name="uuid" value={user.uuid} />
            </CardFooter>
            <div className="mt-4 flex justify-center">
              <Button type="submit">Edit Profile</Button>
            </div>

            <CardFooter className="mt-3 p-0 justify-between">
              <Button variant="ghost">
                <Link to={`/mypage/${uuid}`}>Mypage</Link>
              </Button>
              <LogoutButton />
              {login_user.is_superuser === true ? (
                <Drawer>
                  <DrawerTrigger asChild>
                    <Button
                      variant="ghost"
                      className="hover:bg-red-600 hover:text-white"
                    >
                      DELETE
                    </Button>
                  </DrawerTrigger>
                  <DrawerContent>
                    <div className="mx-auto w-full max-w-sm">
                      <DrawerHeader>
                        <DrawerTitle className="text-2xl text-red-600 text-center">
                          DANGER
                        </DrawerTitle>
                        <DrawerDescription className="text-md text-red-600 text-center">
                          削除の処理を取り消すことはできません。
                          <br />
                          本当に削除してよいですか?
                        </DrawerDescription>
                      </DrawerHeader>
                      <div className="p-4 pb-0">
                        <div className="flex items-center justify-center space-x-2">
                          <DrawerFooter>
                            <DeleteUserButton user_uuid={user.uuid} />
                            <DrawerClose asChild>
                              <Button variant="ghost" className="text-black">
                                Cancel
                              </Button>
                            </DrawerClose>
                          </DrawerFooter>
                        </div>
                      </div>
                    </div>
                  </DrawerContent>
                </Drawer>
              ) : (
                ""
              )}
            </CardFooter>
          </CardContent>
        </Form>
      </Card>
    </div>
  );
}
auth.tsx
import { Outlet } from "@remix-run/react";

export default function Auth() {
  return (
    <div className="w-full">
      <Outlet />
    </div>
  );
}
auth._index.tsx
import { Outlet } from "@remix-run/react";

export default function AuthIndex() {
  return (
    <div className="">
      <Outlet />
    </div>
  );
}
auth.signup.tsx
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
} from "~/components/ui/card";
import { Input } from "../components/ui/input";
import {
  signup,
  requireAccountSession,
  getLoginUser,
} from "../data/auth.server";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, Link, redirect, useActionData } from "@remix-run/react";
import { Label } from "../components/ui/label";
import { Alert, AlertTitle } from "../components/ui/alert";
import { createUserSession } from "../data/session.server";

const LoginformSchema = z.object({
  email: z.string().min(1, { message: "Input your email address." }),
  password: z.string().min(4, { message: "Please input correct password." }),
  username: z.string().min(4, { message: "Username is too short." }),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = Object.fromEntries(await request.formData());

  // validationResultはformDataをチェックした結果を返すだけ(login処理のエラーは返ってこない)
  const validationResult = LoginformSchema.safeParse(formData);

  const email = validationResult.data?.email?.toString();
  const password = validationResult.data?.password?.toString();
  const username = validationResult.data?.username?.toString();

  try {
    const token = await signup({ email, password, username });
    const user = await getLoginUser(token.access_token);
    return createUserSession(token, `/mypage/${user?.uuid}`);
  } catch (error: unknown) {
    return Response.json({
      errors: `${error}`,
      global: "アカウントの作成に失敗しました。",
    });
  }
}

export async function loader({ request }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request);
  if (data?.uuid) {
    return redirect(`/mypage/${data?.uuid}`);
  }
  return null;
}

export default function SignIn() {
  const actionData = useActionData<typeof action>();

  console.log(`errors: ${actionData?.global}`); // ok

  return (
    <div className="w-[500px] mx-auto my-[150px]">
      <Card className="w-[500px] mx-auto">
        <CardHeader>
          <CardTitle className="mx-auto">SIGN UP</CardTitle>
          <CardDescription className="mx-auto">
            Input your email address, password and username.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <Form method="post">
            <div className="grid max-w-[400px] mx-auto items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="email">Email</Label>
                <Input
                  id="email"
                  name="email"
                  type="email"
                  placeholder="Your email address"
                />
              </div>
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="email">Username</Label>
                <Input
                  id="username"
                  name="username"
                  type="username"
                  placeholder="Your username address"
                />
              </div>
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="password">Password</Label>
                <Input
                  id="password"
                  name="password"
                  type="password"
                  placeholder="Set your password"
                />

                {actionData?.global && (
                  <Alert variant="destructive">
                    <AlertTitle className="h-3">
                      {actionData?.errors}
                    </AlertTitle>
                  </Alert>
                )}
              </div>
              <div className="mt-2 flex justify-between">
                <Button asChild>
                  <Link to="/auth/login">LOGIN</Link>
                </Button>
                <Button type="submit">SIGN UP</Button>
              </div>
            </div>
          </Form>
        </CardContent>
      </Card>
    </div>
  );
}
auth.login.tsx
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
} from "~/components/ui/card";
import { Input } from "../components/ui/input";
import {
  getLoginUser,
  login,
  requireAccountSession,
} from "../data/auth.server";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, Link, redirect, useActionData } from "@remix-run/react";
import { Label } from "../components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "../components/ui/alert";
import { createUserSession } from "../data/session.server";

const LoginformSchema = z.object({
  email: z.string().email().min(1, { message: "Input your email address." }),
  password: z.string().min(4, { message: "Please input correct password." }),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = Object.fromEntries(await request.formData());

  // validationResultはformDataをチェックした結果を返すだけ(login処理のエラーは返ってこない)
  const validationResult = LoginformSchema.safeParse(formData);

  const email = validationResult.data?.email?.toString();
  const password = validationResult.data?.password?.toString();

  try {
    const token = await login({ email, password });
    const user = await getLoginUser(token.access_token);
    return createUserSession(token, `/user_list/${user?.uuid}`);
  } catch (error: unknown) {
    return Response.json({
      errors: `${error}`,
      global: "ログインに失敗しました。",
    });
  }
}

export async function loader({ request }: LoaderFunctionArgs) {
  const data = await requireAccountSession(request);
  if (data?.uuid) {
    return redirect("/user_list");
  }
  return null;
}

export default function Login() {
  const actionData = useActionData<typeof action>();

  console.log(`errors: ${actionData?.global}`); // ok

  return (
    <div className="w-[500px] mx-auto my-[150px]">
      <Card className="w-[500px] mx-auto">
        <CardHeader>
          <CardTitle className="mx-auto">LOGIN</CardTitle>
          <CardDescription className="mx-auto">
            Please input your email address and password.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <Form method="post">
            <div className="grid max-w-[400px] mx-auto items-center gap-4">
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="email">Email</Label>
                <Input
                  id="email"
                  name="email"
                  type="email"
                  placeholder="Your email address"
                />
              </div>
              <div className="flex flex-col space-y-1.5">
                <Label htmlFor="password">Password</Label>
                <Input
                  id="password"
                  name="password"
                  type="password"
                  placeholder="Set your password"
                />

                {actionData?.global && (
                  <Alert variant="destructive">
                    <AlertTitle className="h-3">
                      {actionData?.errors}
                    </AlertTitle>
                    <AlertDescription>
                      ログインに失敗しました。メールアドレスまたはパスワードを確認してください
                    </AlertDescription>
                  </Alert>
                )}
              </div>
              <div className="mt-2 flex justify-between">
                <Button asChild>
                  <Link to="/auth/signup">SIGNIN</Link>
                </Button>
                <Button type="submit">LOGIN</Button>
              </div>
            </div>
          </Form>
        </CardContent>
      </Card>
    </div>
  );
}

なお、以下の2つのロジックについては、フロントエンドでの表示部分がなく、サーバーサイドの処理のみが必要な部分のため、データ送信のロジックだけを記載しています。

delete_user.tsx
import { ActionFunctionArgs } from "@remix-run/node";
import { deleteUser } from "../data/auth.server";
import { redirect } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const user_uuid = formData.get("user_uuid");

  if (typeof user_uuid !== "string") {
    return JSON.stringify({ error: "Invalid user ID" });
  }
  try {
    await deleteUser(user_uuid);
    return redirect("/user_list");
  } catch (error: unknown) {
    return Response.json({ error: "Failed to delete user." });
  }
}
logout.tsx
import { ActionFunctionArgs, redirect } from "@remix-run/node";
import { logout } from "../data/auth.server";

// logout処理をするためだけのページ(実際には何も表示しない)
export async function action({ request }: ActionFunctionArgs) {
  return await logout(request);
}

データ処理のロジック

データ処理のロジックはサーバーサイドの処理となるため、ファイルを分け、ファイル名に「.server」が含まれています。
これにより、サーバーサイドの処理が書かれたファイルであることがわかります。

コードはこちら
import { redirect } from "@remix-run/react";
import { LoginType, SignUpType, Token, User } from "../type/authType";
import { createUserSession, sessionStorage } from "./session.server";

export async function login({ email, password }: LoginType) {
  try {
    const res = await fetch(`${process.env.API_ROOT_URL}/auth/auth_token`, {
      method: "POST",
      headers: {
        accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      throw new Error("Request failed.");
    }

    const token: Token = await res.json();
    if (!token || !token.access_token) {
      throw new Error("No token returned from API.");
    } else {
      return token;
    }
  } catch (error: unknown) {
    throw new Error("Login failed.");
  }
}

// logout
export async function logout(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );
  return redirect("/auth/login", {
    headers: {
      "Set-Cookie": await sessionStorage.destroySession(session),
    },
  });
}

export async function requireAccountSession(request: Request) {
  const token = await getTokenFromSession(request);

  const res = await getLoginUser(token?.access_token);
  return res;
}

export async function getTokenFromSession(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );

  const access_token: string | undefined = session.get("access_token");
  const refresh_token: string | undefined = session.get("refresh_token");

  if (!access_token || !refresh_token) {
    return null;
  }

  return {
    access_token: access_token,
    refresh_token: refresh_token,
  };
}

// get login user infomation
export async function getLoginUser(
  access_token: string | undefined
): Promise<User | undefined> {
  try {
    const res = await fetch(`${process.env.API_ROOT_URL}/auth/me`, {
      method: "GET",
      headers: {
        accept: "application/json",
        Authorization: `Bearer ${access_token}`,
      },
    });

    if (res.status === 401) {
      throw new Error("Unauthorized.");
    }

    if (!res.ok) {
      throw new Error("Failed to fetch user.");
    }

    return res.json();
  } catch (error: unknown) {
    redirect("/auth/login");
  }
}

// get user data
export async function getUserData(
  request: Request,
  user_uuid: string | undefined
): Promise<User | undefined> {
  const token = await getTokenFromSession(request);

  try {
    const res = await fetch(`${process.env.API_ROOT_URL}/user/${user_uuid}`, {
      method: "GET",
      headers: {
        accept: "application/json",
        Authorization: `Bearer ${token?.access_token}`,
      },
    });

    if (res.status === 401) {
      throw new Error("Unauthorized.");
    }

    if (!res.ok) {
      throw new Error("Failed to fetch user.");
    }

    return res.json();
  } catch (error: unknown) {
    redirect("/auth/login");
  }
}

// signup
export async function signup({ email, username, password }: SignUpType) {
  try {
    const res = await fetch(`${process.env.API_ROOT_URL}/user/signup`, {
      method: "POST",
      headers: {
        accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, username, password }),
    });

    if (!res.ok) {
      throw new Error("Sign up failed.");
    }

    const token: Token = await res.json();
    if (!token || !token.access_token) {
      throw new Error("No token returned from API.");
    } else {
      return token;
    }
  } catch (error: unknown) {
    throw new Error(`${error}`);
  }
}

// get all user
export async function getAllUser(
  access_token: string | undefined
): Promise<User[] | undefined> {
  const res = await fetch(`${process.env.API_ROOT_URL}/user/all_user`, {
    method: "GET",
    headers: {
      accept: "application/json",
      // Authorization: `Bearer ${access_token}`,
    },
  });

  if (res.ok) {
    return res.json();
  } else if (res.status === 401) {
    throw new Error("Unauthorized.");
  } else {
    throw new Error("Unknown Error.");
  }
}

export async function deleteUser(user_uuid: string | undefined) {
  try {
    const res = await fetch(
      `${process.env.API_ROOT_URL}/user/delete_user/${user_uuid}`,
      {
        method: "DELETE",
        headers: {
          accept: "*/*",
        },
      }
    );

    if (res.status === 401) {
      throw new Error("Unauthorized.");
    }

    if (!res.ok) {
      throw new Error("Failed to delete user.");
    }

    return res.json();
  } catch (error: unknown) {
    redirect("/auth/login");
  }
}

export async function updateUser(
  request: Request,
  user_uuid: string,
  username: string | undefined,
  email: string | undefined,
  is_active: boolean | undefined,
  is_superuser: boolean | undefined
): Promise<User | undefined> {
  const token = await getTokenFromSession(request);
  try {
    const res = await fetch(
      `${process.env.API_ROOT_URL}/user/edit_user/${user_uuid}`,
      {
        method: "PATCH",
        headers: {
          accept: "application/json",
          // Authorization: `Bearer ${token?.access_token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: username,
          email: email,
          is_active: is_active,
          is_superuser: is_superuser,
        }),
      }
    );

    if (res.status === 401) {
      throw new Error("Unauthorized.");
    }

    if (!res.ok) {
      throw new Error("Failed to update user.");
    }

    return res.json();
  } catch (error: unknown) {
    redirect(`/edit_user/${user_uuid}`);
  }
}

今後実装したいこと

公開しているシステムではないので、これ以上改修する必要はないのですが、自分としては以下の機能を追加したいと思っています。

  • 下書き状態のデータを時限付きで公開にする機能(いわゆるスケジューラ機能)
  • コンテンツの公開をした場合のメール通知機能
  • 新着コンテンツの表示機能
    私は現場経験のあるエンジニアではなく、アマチュアエンジニアなので、頼る人がいないので、Zennをはじめとした技術wikiや開発者様たちのブログ、ChatGPT(主にこれ)を活用して自分なりに勉強しながら、コードを書いています。
    今回はフロントエンドの記事でしたが、バックエンドはFastAPiを使用して構築する練習をしていますので、よければそちらの記事も参考にしてみてください(このフロントエンドに紐づくバックエンドは、FastAPIで構築しています)。
    https://zenn.dev/keita_f/articles/4493e3cfd76aec
    https://zenn.dev/keita_f/articles/6e1323fe023fa1
    https://zenn.dev/keita_f/articles/31dbec1538fb43

Discussion