diff options
Diffstat (limited to 'frontend/app')
| -rw-r--r-- | frontend/app/.server/api/client.ts | 49 | ||||
| -rw-r--r-- | frontend/app/.server/api/schema.d.ts | 177 | ||||
| -rw-r--r-- | frontend/app/.server/auth.ts | 45 | ||||
| -rw-r--r-- | frontend/app/.server/cookie.ts | 41 | ||||
| -rw-r--r-- | frontend/app/.server/session.ts | 16 | ||||
| -rw-r--r-- | frontend/app/routes/admin.dashboard.tsx | 29 | ||||
| -rw-r--r-- | frontend/app/routes/admin.games.tsx | 35 | ||||
| -rw-r--r-- | frontend/app/routes/admin.games_.$gameId.tsx | 95 | ||||
| -rw-r--r-- | frontend/app/routes/admin.tsx | 8 | ||||
| -rw-r--r-- | frontend/app/routes/admin.users.tsx | 34 | ||||
| -rw-r--r-- | frontend/app/routes/dashboard.tsx | 11 |
11 files changed, 89 insertions, 451 deletions
diff --git a/frontend/app/.server/api/client.ts b/frontend/app/.server/api/client.ts index a78180b..0db4c14 100644 --- a/frontend/app/.server/api/client.ts +++ b/frontend/app/.server/api/client.ts @@ -1,5 +1,5 @@ import createClient from "openapi-fetch"; -import type { operations, paths } from "./schema"; +import type { paths } from "./schema"; const apiClient = createClient<paths>({ baseUrl: @@ -46,50 +46,3 @@ export async function apiGetToken(token: string) { if (error) throw new Error(error.message); return data; } - -export async function adminApiGetUsers(token: string) { - const { data, error } = await apiClient.GET("/admin/users", { - params: { - header: { Authorization: `Bearer ${token}` }, - }, - }); - if (error) throw new Error(error.message); - return data; -} - -export async function adminApiGetGames(token: string) { - const { data, error } = await apiClient.GET("/admin/games", { - params: { - header: { Authorization: `Bearer ${token}` }, - }, - }); - if (error) throw new Error(error.message); - return data; -} - -export async function adminApiGetGame(token: string, gameId: number) { - const { data, error } = await apiClient.GET("/admin/games/{game_id}", { - params: { - header: { Authorization: `Bearer ${token}` }, - path: { game_id: gameId }, - }, - }); - if (error) throw new Error(error.message); - return data; -} - -export async function adminApiPutGame( - token: string, - gameId: number, - body: operations["adminPutGame"]["requestBody"]["content"]["application/json"], -) { - const { data, error } = await apiClient.PUT("/admin/games/{game_id}", { - params: { - header: { Authorization: `Bearer ${token}` }, - path: { game_id: gameId }, - }, - body, - }); - if (error) throw new Error(error.message); - return data; -} diff --git a/frontend/app/.server/api/schema.d.ts b/frontend/app/.server/api/schema.d.ts index 1c8cead..88067a8 100644 --- a/frontend/app/.server/api/schema.d.ts +++ b/frontend/app/.server/api/schema.d.ts @@ -72,58 +72,6 @@ export interface paths { patch?: never; trace?: never; }; - "/admin/users": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List all users */ - get: operations["adminGetUsers"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/games": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List games */ - get: operations["adminGetGames"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/games/{game_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get a game */ - get: operations["adminGetGame"]; - /** Update a game */ - put: operations["adminPutGame"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record<string, never>; export interface components { @@ -433,129 +381,4 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; - adminGetUsers: { - parameters: { - query?: never; - header: { - Authorization: components["parameters"]["header_authorization"]; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of users */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - users: components["schemas"]["User"][]; - }; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - }; - }; - adminGetGames: { - parameters: { - query?: never; - header: { - Authorization: components["parameters"]["header_authorization"]; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description List of games */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - games: components["schemas"]["Game"][]; - }; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - }; - }; - adminGetGame: { - parameters: { - query?: never; - header: { - Authorization: components["parameters"]["header_authorization"]; - }; - path: { - game_id: components["parameters"]["path_game_id"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description A game */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - game: components["schemas"]["Game"]; - }; - }; - }; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - }; - }; - adminPutGame: { - parameters: { - query?: never; - header: { - Authorization: components["parameters"]["header_authorization"]; - }; - path: { - game_id: components["parameters"]["path_game_id"]; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** - * @example closed - * @enum {string} - */ - state?: "closed" | "waiting_entries" | "waiting_start" | "prepare" | "starting" | "gaming" | "finished"; - /** @example Game 1 */ - display_name?: string; - /** @example 360 */ - duration_seconds?: number; - /** @example 946684800 */ - started_at?: number | null; - /** @example 1 */ - problem_id?: number | null; - }; - }; - }; - responses: { - /** @description Successfully updated */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - }; - }; } diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts index a4811e2..2c9d23c 100644 --- a/frontend/app/.server/auth.ts +++ b/frontend/app/.server/auth.ts @@ -1,10 +1,12 @@ +import { redirect } from "@remix-run/node"; import type { Session } from "@remix-run/server-runtime"; import { jwtDecode } from "jwt-decode"; import { Authenticator } from "remix-auth"; import { FormStrategy } from "remix-auth-form"; import { apiPostLogin } from "./api/client"; import { components } from "./api/schema"; -import { sessionStorage } from "./session"; +import { createUnstructuredCookie } from "./cookie"; +import { cookieOptions, sessionStorage } from "./session"; const authenticator = new Authenticator<string>(sessionStorage); @@ -19,15 +21,40 @@ authenticator.use( export type User = components["schemas"]["User"]; +// This cookie is used to directly store the JWT for the API server. +// Remix's createCookie() returns "structured" cookies, which cannot be reused directly by non-Remix servers. +const tokenCookie = createUnstructuredCookie("albatross_token", cookieOptions); + export async function login(request: Request): Promise<never> { - return await authenticator.authenticate("default", request, { - successRedirect: "/dashboard", + const jwt = await authenticator.authenticate("default", request, { failureRedirect: "/login", }); + + const session = await sessionStorage.getSession( + request.headers.get("cookie"), + ); + session.set(authenticator.sessionKey, jwt); + + throw redirect("/dashboard", { + headers: [ + ["Set-Cookie", await sessionStorage.commitSession(session)], + ["Set-Cookie", await tokenCookie.serialize(jwt)], + ], + }); } export async function logout(request: Request | Session): Promise<never> { - return await authenticator.logout(request, { redirectTo: "/" }); + try { + return await authenticator.logout(request, { redirectTo: "/" }); + } catch (response) { + if (response instanceof Response) { + response.headers.append( + "Set-Cookie", + await tokenCookie.serialize("", { maxAge: 0, expires: new Date(0) }), + ); + } + throw response; + } } export async function ensureUserLoggedIn( @@ -40,16 +67,6 @@ export async function ensureUserLoggedIn( return { user, token }; } -export async function ensureAdminUserLoggedIn( - request: Request | Session, -): Promise<{ user: User; token: string }> { - const { user, token } = await ensureUserLoggedIn(request); - if (!user.is_admin) { - throw new Error("Forbidden"); - } - return { user, token }; -} - export async function ensureUserNotLoggedIn( request: Request | Session, ): Promise<null> { diff --git a/frontend/app/.server/cookie.ts b/frontend/app/.server/cookie.ts new file mode 100644 index 0000000..cccbe78 --- /dev/null +++ b/frontend/app/.server/cookie.ts @@ -0,0 +1,41 @@ +import { Cookie, CookieOptions } from "@remix-run/server-runtime"; +import { parse, serialize } from "cookie"; + +// Remix's createCookie() returns "structured" cookies, which are cookies that hold a JSON-encoded object. +// This is not suitable for interoperation with other systems that expect a simple string value. +// This function creates an "unstructured" cookie, a simple plain text. +export function createUnstructuredCookie( + name: string, + cookieOptions?: CookieOptions, +): Cookie { + const { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + const cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies ? cookies[name] : null; + }, + async serialize(value, serializeOptions) { + return serialize(name, value, { + ...options, + ...serializeOptions, + }); + }, + }; +} diff --git a/frontend/app/.server/session.ts b/frontend/app/.server/session.ts index 79810f4..102bcd2 100644 --- a/frontend/app/.server/session.ts +++ b/frontend/app/.server/session.ts @@ -1,13 +1,17 @@ import { createCookieSessionStorage } from "@remix-run/node"; +export const cookieOptions = { + sameSite: "lax" as const, + path: "/", + httpOnly: true, + // secure: process.env.NODE_ENV === "production", + secure: false, // TODO + secrets: ["TODO"], +}; + export const sessionStorage = createCookieSessionStorage({ cookie: { name: "albatross_session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["TODO"], - // secure: process.env.NODE_ENV === "production", - secure: false, // TODO + ...cookieOptions, }, }); diff --git a/frontend/app/routes/admin.dashboard.tsx b/frontend/app/routes/admin.dashboard.tsx deleted file mode 100644 index 8a0c9a8..0000000 --- a/frontend/app/routes/admin.dashboard.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { Form, Link } from "@remix-run/react"; -import { ensureAdminUserLoggedIn } from "../.server/auth"; - -export const meta: MetaFunction = () => [ - { title: "[Admin] Dashboard | iOSDC Japan 2024 Albatross.swift" }, -]; - -export async function loader({ request }: LoaderFunctionArgs) { - await ensureAdminUserLoggedIn(request); - return null; -} - -export default function AdminDashboard() { - return ( - <div> - <h1>[Admin] Dashboard</h1> - <p> - <Link to="/admin/users">Users</Link> - </p> - <p> - <Link to="/admin/games">Games</Link> - </p> - <Form method="post" action="/logout"> - <button type="submit">Logout</button> - </Form> - </div> - ); -} diff --git a/frontend/app/routes/admin.games.tsx b/frontend/app/routes/admin.games.tsx deleted file mode 100644 index f9d15f7..0000000 --- a/frontend/app/routes/admin.games.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; -import { adminApiGetGames } from "../.server/api/client"; -import { ensureAdminUserLoggedIn } from "../.server/auth"; - -export const meta: MetaFunction = () => [ - { title: "[Admin] Games | iOSDC Japan 2024 Albatross.swift" }, -]; - -export async function loader({ request }: LoaderFunctionArgs) { - const { token } = await ensureAdminUserLoggedIn(request); - const { games } = await adminApiGetGames(token); - return { games }; -} - -export default function AdminGames() { - const { games } = useLoaderData<typeof loader>()!; - - return ( - <div> - <div> - <h1>[Admin] Games</h1> - <ul> - {games.map((game) => ( - <li key={game.game_id}> - <Link to={`/admin/games/${game.game_id}`}> - {game.display_name} (id={game.game_id}) - </Link> - </li> - ))} - </ul> - </div> - </div> - ); -} diff --git a/frontend/app/routes/admin.games_.$gameId.tsx b/frontend/app/routes/admin.games_.$gameId.tsx deleted file mode 100644 index c4d75c1..0000000 --- a/frontend/app/routes/admin.games_.$gameId.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, - MetaFunction, -} from "@remix-run/node"; -import { Form, useLoaderData } from "@remix-run/react"; -import { adminApiGetGame, adminApiPutGame } from "../.server/api/client"; -import { ensureAdminUserLoggedIn } from "../.server/auth"; - -export const meta: MetaFunction<typeof loader> = ({ data }) => [ - { - title: data - ? `[Admin] Game Edit ${data.game.display_name} | iOSDC Japan 2024 Albatross.swift` - : "[Admin] Game Edit | iOSDC Japan 2024 Albatross.swift", - }, -]; - -export async function loader({ request, params }: LoaderFunctionArgs) { - const { token } = await ensureAdminUserLoggedIn(request); - const { gameId } = params; - const { game } = await adminApiGetGame(token, Number(gameId)); - return { game }; -} - -export async function action({ request, params }: ActionFunctionArgs) { - const { token } = await ensureAdminUserLoggedIn(request); - const { gameId } = params; - - const formData = await request.formData(); - const action = formData.get("action"); - - const nextState = - action === "open" - ? "waiting_entries" - : action === "start" - ? "prepare" - : null; - if (!nextState) { - throw new Error("Invalid action"); - } - - await adminApiPutGame(token, Number(gameId), { - state: nextState, - }); - return null; -} - -export default function AdminGameEdit() { - const { game } = useLoaderData<typeof loader>()!; - - return ( - <div> - <div> - <h1>[Admin] Game Edit {game.display_name}</h1> - <ul> - <li>ID: {game.game_id}</li> - <li>State: {game.state}</li> - <li>Display Name: {game.display_name}</li> - <li>Duration Seconds: {game.duration_seconds}</li> - <li> - Started At:{" "} - {game.started_at - ? new Date(game.started_at * 1000).toString() - : "-"} - </li> - <li>Problem ID: {game.problem ? game.problem.problem_id : "-"}</li> - </ul> - <div> - <Form method="post"> - <div> - <button - type="submit" - name="action" - value="open" - disabled={game.state !== "closed"} - > - Open - </button> - </div> - <div> - <button - type="submit" - name="action" - value="start" - disabled={game.state !== "waiting_start"} - > - Start - </button> - </div> - </Form> - </div> - </div> - </div> - ); -} diff --git a/frontend/app/routes/admin.tsx b/frontend/app/routes/admin.tsx deleted file mode 100644 index ceef37e..0000000 --- a/frontend/app/routes/admin.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type { LinksFunction } from "@remix-run/node"; -import normalizeCss from "sakura.css/css/normalize.css?url"; -import sakuraCss from "sakura.css/css/sakura.css?url"; - -export const links: LinksFunction = () => [ - { rel: "stylesheet", href: normalizeCss }, - { rel: "stylesheet", href: sakuraCss }, -]; diff --git a/frontend/app/routes/admin.users.tsx b/frontend/app/routes/admin.users.tsx deleted file mode 100644 index c403285..0000000 --- a/frontend/app/routes/admin.users.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import { adminApiGetUsers } from "../.server/api/client"; -import { ensureAdminUserLoggedIn } from "../.server/auth"; - -export const meta: MetaFunction = () => [ - { title: "[Admin] Users | iOSDC Japan 2024 Albatross.swift" }, -]; - -export async function loader({ request }: LoaderFunctionArgs) { - const { token } = await ensureAdminUserLoggedIn(request); - const { users } = await adminApiGetUsers(token); - return { users }; -} - -export default function AdminUsers() { - const { users } = useLoaderData<typeof loader>()!; - - return ( - <div> - <div> - <h1>[Admin] Users</h1> - <ul> - {users.map((user) => ( - <li key={user.user_id}> - {user.display_name} (id={user.user_id} username={user.username}) - {user.is_admin && <span> admin</span>} - </li> - ))} - </ul> - </div> - </div> - ); -} diff --git a/frontend/app/routes/dashboard.tsx b/frontend/app/routes/dashboard.tsx index 229375c..e23d7aa 100644 --- a/frontend/app/routes/dashboard.tsx +++ b/frontend/app/routes/dashboard.tsx @@ -11,7 +11,11 @@ export const meta: MetaFunction = () => [ export async function loader({ request }: LoaderFunctionArgs) { const { user, token } = await ensureUserLoggedIn(request); if (user.is_admin) { - return redirect("/admin/dashboard"); + return redirect( + process.env.NODE_ENV === "development" + ? "http://localhost:8002/admin/dashboard" + : "/admin/dashboard", + ); } const { games } = await apiGetGames(token); return { @@ -26,10 +30,7 @@ export default function Dashboard() { return ( <div className="min-h-screen p-8"> <div className="p-6 rounded shadow-md max-w-4xl mx-auto"> - <h1 className="text-3xl font-bold mb-4"> - {user.username}{" "} - {user.is_admin && <span className="text-red-500 text-lg">admin</span>} - </h1> + <h1 className="text-3xl font-bold mb-4">{user.username}</h1> <h2 className="text-2xl font-semibold mb-2">User</h2> <div className="mb-6"> <ul className="list-disc list-inside"> |
