From 7468748c141943fa000edea4098d54bf3cdff55e Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 28 Jul 2024 02:11:30 +0900 Subject: frontend: openapi --- frontend/app/.server/api/client.ts | 4 + frontend/app/.server/api/schema.d.ts | 91 +++++++++++++++++++++ frontend/app/.server/auth.ts | 133 ++++++++++++++++++++++++++++++ frontend/app/.server/session.ts | 13 +++ frontend/app/routes/dashboard.tsx | 2 +- frontend/app/routes/login.tsx | 2 +- frontend/app/services/auth.server.ts | 141 -------------------------------- frontend/app/services/session.server.ts | 13 --- 8 files changed, 243 insertions(+), 156 deletions(-) create mode 100644 frontend/app/.server/api/client.ts create mode 100644 frontend/app/.server/api/schema.d.ts create mode 100644 frontend/app/.server/auth.ts create mode 100644 frontend/app/.server/session.ts delete mode 100644 frontend/app/services/auth.server.ts delete mode 100644 frontend/app/services/session.server.ts (limited to 'frontend/app') diff --git a/frontend/app/.server/api/client.ts b/frontend/app/.server/api/client.ts new file mode 100644 index 0000000..12f2fc6 --- /dev/null +++ b/frontend/app/.server/api/client.ts @@ -0,0 +1,4 @@ +import createClient from "openapi-fetch"; +import type { paths } from "./schema"; + +export const apiClient = createClient({ baseUrl: "http://api-server/" }); diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts new file mode 100644 index 0000000..815731e --- /dev/null +++ b/frontend/app/.server/api/schema.d.ts @@ -0,0 +1,91 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** User login */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @example john */ + username: string; + /** @example password123 */ + password: string; + }; + }; + }; + responses: { + /** @description Successfully authenticated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example xxxxx.xxxxx.xxxxx */ + token: string; + }; + }; + }; + /** @description Invalid username or password */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example Invalid credentials */ + message: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + JwtPayload: { + /** @example 123 */ + user_id: number; + /** @example john */ + username: string; + /** @example John Doe */ + display_username: string; + /** @example /images/john.jpg */ + icon_path?: string | null; + /** @example false */ + is_admin: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts new file mode 100644 index 0000000..9696e90 --- /dev/null +++ b/frontend/app/.server/auth.ts @@ -0,0 +1,133 @@ +import { Authenticator } from "remix-auth"; +import { FormStrategy } from "remix-auth-form"; +import { jwtDecode } from "jwt-decode"; +import type { Session } from "@remix-run/server-runtime"; +import { sessionStorage } from "./session"; +import { apiClient } from "./api/client"; +import { components } from "./api/schema"; + +export const authenticator = new Authenticator(sessionStorage); + +async function login(username: string, password: string): Promise { + const { data, error } = await apiClient.POST("/api/login", { + body: { + username, + password, + }, + }); + if (error) { + throw new Error(error.message); + } + return data.token; +} + +authenticator.use( + new FormStrategy(async ({ form }) => { + const username = String(form.get("username")); + const password = String(form.get("password")); + return await login(username, password); + }), + "default", +); + +type JwtPayload = components["schemas"]["JwtPayload"]; + +export type User = { + userId: number; + username: string; + displayUsername: string; + iconPath: string | null; + isAdmin: boolean; +}; + +export async function isAuthenticated( + request: Request | Session, + options?: { + successRedirect?: never; + failureRedirect?: never; + headers?: never; + }, +): Promise; +export async function isAuthenticated( + request: Request | Session, + options: { + successRedirect: string; + failureRedirect?: never; + headers?: HeadersInit; + }, +): Promise; +export async function isAuthenticated( + request: Request | Session, + options: { + successRedirect?: never; + failureRedirect: string; + headers?: HeadersInit; + }, +): Promise; +export async function isAuthenticated( + request: Request | Session, + options: { + successRedirect: string; + failureRedirect: string; + headers?: HeadersInit; + }, +): Promise; +export async function isAuthenticated( + request: Request | Session, + options: + | { + successRedirect?: never; + failureRedirect?: never; + headers?: never; + } + | { + successRedirect: string; + failureRedirect?: never; + headers?: HeadersInit; + } + | { + successRedirect?: never; + failureRedirect: string; + headers?: HeadersInit; + } + | { + successRedirect: string; + failureRedirect: string; + headers?: HeadersInit; + } = {}, +): Promise { + // This function's signature should be compatible with `authenticator.isAuthenticated` but TypeScript does not infer it correctly. + let jwt; + const { successRedirect, failureRedirect, headers } = options; + if (successRedirect && failureRedirect) { + jwt = await authenticator.isAuthenticated(request, { + successRedirect, + failureRedirect, + headers, + }); + } else if (!successRedirect && failureRedirect) { + jwt = await authenticator.isAuthenticated(request, { + failureRedirect, + headers, + }); + } else if (successRedirect && !failureRedirect) { + jwt = await authenticator.isAuthenticated(request, { + successRedirect, + headers, + }); + } else { + jwt = await authenticator.isAuthenticated(request); + } + + if (!jwt) { + return null; + } + const payload = jwtDecode(jwt); + return { + userId: payload.user_id, + username: payload.username, + displayUsername: payload.display_username, + iconPath: payload.icon_path ?? null, + isAdmin: payload.is_admin, + }; +} diff --git a/frontend/app/.server/session.ts b/frontend/app/.server/session.ts new file mode 100644 index 0000000..2000853 --- /dev/null +++ b/frontend/app/.server/session.ts @@ -0,0 +1,13 @@ +import { createCookieSessionStorage } from "@remix-run/node"; + +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "albatross_session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: ["TODO"], + // secure: process.env.NODE_ENV === "production", + secure: false, // TODO + }, +}); diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx index be274eb..535642c 100644 --- a/frontend/app/routes/dashboard.tsx +++ b/frontend/app/routes/dashboard.tsx @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { isAuthenticated } from "../services/auth.server"; +import { isAuthenticated } from "../.server/auth"; import { useLoaderData } from "@remix-run/react"; export async function loader({ request }: LoaderFunctionArgs) { diff --git a/frontend/app/routes/login.tsx b/frontend/app/routes/login.tsx index cf5be14..0da2616 100644 --- a/frontend/app/routes/login.tsx +++ b/frontend/app/routes/login.tsx @@ -1,6 +1,6 @@ import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { Form } from "@remix-run/react"; -import { authenticator } from "../services/auth.server"; +import { authenticator } from "../.server/auth"; export async function loader({ request }: LoaderFunctionArgs) { return await authenticator.isAuthenticated(request, { diff --git a/frontend/app/services/auth.server.ts b/frontend/app/services/auth.server.ts deleted file mode 100644 index 144a7cd..0000000 --- a/frontend/app/services/auth.server.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Authenticator } from "remix-auth"; -import { FormStrategy } from "remix-auth-form"; -import { sessionStorage } from "./session.server"; -import { jwtDecode } from "jwt-decode"; -import type { Session } from "@remix-run/server-runtime"; - -export const authenticator = new Authenticator(sessionStorage); - -async function login(username: string, password: string): Promise { - const res = await fetch(`http://api-server/api/login`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username, password }), - }); - if (!res.ok) { - throw new Error("Invalid username or password"); - } - const user = await res.json(); - return user.token; -} - -authenticator.use( - new FormStrategy(async ({ form }) => { - const username = String(form.get("username")); - const password = String(form.get("password")); - return await login(username, password); - }), - "default", -); - -type JwtPayload = { - user_id: number; - username: string; - display_username: string; - icon_path: string | null; - is_admin: boolean; -}; - -export type User = { - userId: number; - username: string; - displayUsername: string; - iconPath: string | null; - isAdmin: boolean; -}; - -export async function isAuthenticated( - request: Request | Session, - options?: { - successRedirect?: never; - failureRedirect?: never; - headers?: never; - }, -): Promise; -export async function isAuthenticated( - request: Request | Session, - options: { - successRedirect: string; - failureRedirect?: never; - headers?: HeadersInit; - }, -): Promise; -export async function isAuthenticated( - request: Request | Session, - options: { - successRedirect?: never; - failureRedirect: string; - headers?: HeadersInit; - }, -): Promise; -export async function isAuthenticated( - request: Request | Session, - options: { - successRedirect: string; - failureRedirect: string; - headers?: HeadersInit; - }, -): Promise; -export async function isAuthenticated( - request: Request | Session, - options: - | { - successRedirect?: never; - failureRedirect?: never; - headers?: never; - } - | { - successRedirect: string; - failureRedirect?: never; - headers?: HeadersInit; - } - | { - successRedirect?: never; - failureRedirect: string; - headers?: HeadersInit; - } - | { - successRedirect: string; - failureRedirect: string; - headers?: HeadersInit; - } = {}, -): Promise { - let jwt; - - // This function's signature should be compatible with `authenticator.isAuthenticated` but TypeScript does not infer it correctly. - const { successRedirect, failureRedirect, headers } = options; - if (successRedirect && failureRedirect) { - jwt = await authenticator.isAuthenticated(request, { - successRedirect, - failureRedirect, - headers, - }); - } else if (!successRedirect && failureRedirect) { - jwt = await authenticator.isAuthenticated(request, { - failureRedirect, - headers, - }); - } else if (successRedirect && !failureRedirect) { - jwt = await authenticator.isAuthenticated(request, { - successRedirect, - headers, - }); - } else { - jwt = await authenticator.isAuthenticated(request); - } - - if (!jwt) { - return null; - } - // TODO: runtime type check - const payload = jwtDecode(jwt); - return { - userId: payload.user_id, - username: payload.username, - displayUsername: payload.display_username, - iconPath: payload.icon_path, - isAdmin: payload.is_admin, - }; -} diff --git a/frontend/app/services/session.server.ts b/frontend/app/services/session.server.ts deleted file mode 100644 index 2000853..0000000 --- a/frontend/app/services/session.server.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createCookieSessionStorage } from "@remix-run/node"; - -export const sessionStorage = createCookieSessionStorage({ - cookie: { - name: "albatross_session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["TODO"], - // secure: process.env.NODE_ENV === "production", - secure: false, // TODO - }, -}); -- cgit v1.2.3-70-g09d2