aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/app')
-rw-r--r--frontend/app/.server/api/client.ts49
-rw-r--r--frontend/app/.server/api/schema.d.ts177
-rw-r--r--frontend/app/.server/auth.ts45
-rw-r--r--frontend/app/.server/cookie.ts41
-rw-r--r--frontend/app/.server/session.ts16
-rw-r--r--frontend/app/routes/admin.dashboard.tsx29
-rw-r--r--frontend/app/routes/admin.games.tsx35
-rw-r--r--frontend/app/routes/admin.games_.$gameId.tsx95
-rw-r--r--frontend/app/routes/admin.tsx8
-rw-r--r--frontend/app/routes/admin.users.tsx34
-rw-r--r--frontend/app/routes/dashboard.tsx11
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">