diff --git a/package-lock.json b/package-lock.json
index 6ddf1e62..1fd9bc46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,13 +9,14 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
+ "cookie": "^1.0.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@biomejs/biome": "^1.9.1",
"@types/bun": "^1.1.10",
"cspell": "^8.14.4",
- "lefthook": "^1.8.4",
+ "lefthook": "^1.8.2",
"typescript": "^5.6.2"
}
},
@@ -615,6 +616,15 @@
"node": ">= 6"
}
},
+ "node_modules/cookie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
+ "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/core-util-is": {
"version": "1.0.3",
"dev": true,
diff --git a/package.json b/package.json
index 99611cfe..470974d6 100644
--- a/package.json
+++ b/package.json
@@ -13,10 +13,11 @@
"@biomejs/biome": "^1.9.1",
"@types/bun": "^1.1.10",
"cspell": "^8.14.4",
- "typescript": "^5.6.2",
- "lefthook": "^1.8.2"
+ "lefthook": "^1.8.2",
+ "typescript": "^5.6.2"
},
"dependencies": {
+ "cookie": "^1.0.2",
"zod": "^3.23.8"
},
"trustedDependencies": ["@biomejs/biome", "lefthook"]
diff --git a/server/src/index.ts b/server/src/index.ts
index a32aa9e2..09311567 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -4,6 +4,7 @@ import csrf from "./lib/cross-origin/block-unknown-origin";
import cors from "./lib/cross-origin/multi-origin-cors";
import { initializeSocket } from "./lib/socket/socket";
import { allUrlMustBeValid, panic } from "./lib/utils";
+import adminRoutes from "./router/admin";
import chatRoutes from "./router/chat";
import coursesRoutes from "./router/courses";
import matchesRoutes from "./router/matches";
@@ -49,6 +50,7 @@ app.use("/courses", coursesRoutes);
app.use("/requests", requestsRoutes);
app.use("/matches", matchesRoutes);
app.use("/chat", chatRoutes);
+app.use("/admin", adminRoutes);
export function main() {
// サーバーの起動
diff --git a/server/src/router/admin.ts b/server/src/router/admin.ts
new file mode 100644
index 00000000..e1bf8065
--- /dev/null
+++ b/server/src/router/admin.ts
@@ -0,0 +1,47 @@
+import { serialize } from "cookie";
+import express from "express";
+import { z } from "zod";
+import { safeGetUserId } from "../firebase/auth/db";
+
+const router = express.Router();
+
+export const adminLoginForm = z.object({
+ userName: z.string(),
+ password: z.string(),
+});
+
+router.post("/login", async (req, res) => {
+ const user = await safeGetUserId(req);
+ if (!user.ok) return res.status(401).send("auth error");
+
+ const form = adminLoginForm.safeParse(req.body);
+ if (!form.success) {
+ return res.status(422).send("invalid format");
+ }
+ if (form.data.userName !== "admin" || form.data.password !== "password") {
+ return res.status(401).send("Failed to login Admin Page.");
+ }
+
+ // Set cookie on successful login
+ const cookie = serialize("authToken", "admin-token", {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "strict",
+ path: "/",
+ maxAge: 60 * 60 * 24,
+ });
+
+ res.setHeader("Set-Cookie", cookie);
+ res.status(201).json({ message: "Login successful" });
+});
+
+// 認証チェック用エンドポイント
+router.get("/login", (req, res) => {
+ const authToken = req.cookies?.authToken;
+ if (authToken === "admin-token") {
+ return res.status(200).json({ authenticated: true });
+ }
+ return res.status(401).json({ authenticated: false });
+});
+
+export default router;
diff --git a/web/api/admin/login/route.ts b/web/api/admin/login/route.ts
new file mode 100644
index 00000000..9bca7352
--- /dev/null
+++ b/web/api/admin/login/route.ts
@@ -0,0 +1,15 @@
+import { credFetch } from "../../../firebase/auth/lib";
+import endpoints from "../../internal/endpoints";
+
+export async function adminLogin(userName: string, password: string) {
+ const body = { userName, password };
+
+ const res = await credFetch("POST", endpoints.adminLogin, body);
+
+ if (!res.ok) {
+ const errorData = await res.json();
+ throw new Error(errorData.message || "ログインに失敗しました。");
+ }
+
+ return res.json();
+}
diff --git a/web/api/admin/validate/route.ts b/web/api/admin/validate/route.ts
new file mode 100644
index 00000000..040d7480
--- /dev/null
+++ b/web/api/admin/validate/route.ts
@@ -0,0 +1,8 @@
+import { ErrUnauthorized, credFetch } from "../../../firebase/auth/lib";
+import endpoints from "../../internal/endpoints";
+
+export async function adminAuth() {
+ const res = await credFetch("GET", endpoints.adminLogin);
+ if (res.status === 401) throw new ErrUnauthorized();
+ return res.json();
+}
diff --git a/web/api/internal/endpoints.ts b/web/api/internal/endpoints.ts
index 2ebc350a..90265eed 100644
--- a/web/api/internal/endpoints.ts
+++ b/web/api/internal/endpoints.ts
@@ -366,6 +366,8 @@ export const pictureOf = (guid: GUID) => `${origin}/picture/${guid}`;
*/
export const picture = `${origin}/picture`;
+export const adminLogin = `${origin}/admin/login`;
+
export default {
user,
me,
@@ -395,4 +397,5 @@ export default {
coursesMineOverlaps,
pictureOf,
picture,
+ adminLogin,
};
diff --git a/web/app/admin/layout.tsx b/web/app/admin/layout.tsx
new file mode 100644
index 00000000..bcba3d46
--- /dev/null
+++ b/web/app/admin/layout.tsx
@@ -0,0 +1,21 @@
+import SideNav from "~/components/admin/sideNave";
+import { NavigateByAuthState } from "~/components/common/NavigateByAuthState";
+
+export default function SettingsPageLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/web/app/admin/login/page.tsx b/web/app/admin/login/page.tsx
new file mode 100644
index 00000000..3ae993fe
--- /dev/null
+++ b/web/app/admin/login/page.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { adminLogin } from "../../../api/admin/login/route";
+
+export default function LoginPage() {
+ const [name, setName] = useState("");
+ const [password, setPassword] = useState("");
+ const router = useRouter();
+
+ const handleSubmit = async (e: { preventDefault: () => void }) => {
+ e.preventDefault();
+
+ try {
+ await adminLogin(name, password);
+ alert("成功しました。遷移します");
+ router.replace("/admin");
+ } catch (e) {
+ alert("ログインに失敗しました");
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/web/app/admin/notification/page.tsx b/web/app/admin/notification/page.tsx
new file mode 100644
index 00000000..7fdca202
--- /dev/null
+++ b/web/app/admin/notification/page.tsx
@@ -0,0 +1,161 @@
+"use client";
+import { useEffect, useState } from "react";
+
+// モックデータ
+const mockNotifications = [
+ {
+ id: 1,
+ title: "システムメンテナンス",
+ content: "12月20日にシステムメンテナンスを行います。",
+ },
+ {
+ id: 2,
+ title: "新機能リリース",
+ content: "新しい機能がリリースされました。",
+ },
+];
+
+// 型定義
+type Notification = {
+ id: number;
+ title: string;
+ content: string;
+};
+
+export default function NotificationAdmin() {
+ // useStateに型を明示的に指定
+ const [notifications, setNotifications] = useState([]);
+ const [form, setForm] = useState({
+ id: 0,
+ title: "",
+ content: "",
+ });
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ // 初期データを設定
+ setNotifications(mockNotifications);
+ }, []);
+
+ // 入力フォームの変更処理
+ const handleInputChange = (
+ e: React.ChangeEvent,
+ ) => {
+ const { name, value } = e.target;
+ setForm({ ...form, [name]: value });
+ };
+
+ // 新しいお知らせを追加
+ const handleAdd = () => {
+ if (!form.title || !form.content)
+ return alert("タイトルと内容を入力してください");
+ const newNotification: Notification = { ...form, id: Date.now() };
+ setNotifications([...notifications, newNotification]);
+ setForm({ id: 0, title: "", content: "" });
+ };
+
+ // お知らせを編集
+ const handleEdit = (notification: Notification) => {
+ setIsEditing(true);
+ setForm(notification);
+ };
+
+ // 編集内容を保存
+ const handleUpdate = () => {
+ if (!form.title || !form.content)
+ return alert("タイトルと内容を入力してください");
+ setNotifications(
+ notifications.map((n) => (n.id === form.id ? { ...form } : n)),
+ );
+ setForm({ id: 0, title: "", content: "" });
+ setIsEditing(false);
+ };
+
+ // お知らせを削除
+ const handleDelete = (id: number) => {
+ if (window.confirm("本当に削除しますか?")) {
+ setNotifications(notifications.filter((n) => n.id !== id));
+ }
+ };
+
+ return (
+
+
お知らせ配信
+
+ {/* 一覧表示 */}
+
+
お知らせ一覧
+
+
+
+ | タイトル |
+ 内容 |
+ 操作 |
+
+
+
+ {notifications.map((notification) => (
+
+ | {notification.title} |
+ {notification.content} |
+
+ {/* biome-ignore lint/a11y/useButtonType: */}
+
+ {/* biome-ignore lint/a11y/useButtonType: */}
+
+ |
+
+ ))}
+
+
+
+
+ {/* フォーム */}
+
+
+ {isEditing ? "お知らせを編集" : "新しいお知らせを作成"}
+
+
+ {/* biome-ignore lint/nursery/useSortedClasses: */}
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */}
+
+
+
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */}
+
+ {/* biome-ignore lint/style/useSelfClosingElements: */}
+
+
+ {/* biome-ignore lint/a11y/useButtonType:
*/}
+
+
+
+ );
+}
diff --git a/web/app/admin/page.tsx b/web/app/admin/page.tsx
new file mode 100644
index 00000000..323ac265
--- /dev/null
+++ b/web/app/admin/page.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { adminAuth } from "../../api/admin/validate/route";
+
+export default function AdminPage() {
+ const router = useRouter();
+
+ useEffect(() => {
+ const checkAuth = async () => {
+ const response = await adminAuth();
+
+ if (!response.ok) {
+ router.push("/admin/login");
+ }
+ };
+
+ checkAuth();
+ }, [router]);
+
+ return 管理者画面へようこそ
;
+}
diff --git a/web/app/admin/question/page.tsx b/web/app/admin/question/page.tsx
new file mode 100644
index 00000000..7729715a
--- /dev/null
+++ b/web/app/admin/question/page.tsx
@@ -0,0 +1,5 @@
+import React from "react";
+
+export default function Question() {
+ return お問い合わせ
;
+}
diff --git a/web/app/admin/users/page.tsx b/web/app/admin/users/page.tsx
new file mode 100644
index 00000000..573f3175
--- /dev/null
+++ b/web/app/admin/users/page.tsx
@@ -0,0 +1,5 @@
+import React from "react";
+
+export default function Users() {
+ return ユーザー一覧
;
+}
diff --git a/web/app/settings/notification/page.tsx b/web/app/settings/notification/page.tsx
new file mode 100644
index 00000000..f3d3fa1d
--- /dev/null
+++ b/web/app/settings/notification/page.tsx
@@ -0,0 +1,15 @@
+import { NavigateByAuthState } from "~/components/common/NavigateByAuthState";
+import TopNavigation from "~/components/common/TopNavigation";
+
+export default function Notification() {
+ return (
+
+
+
+ );
+}
diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx
index abf97e34..4e2e1961 100644
--- a/web/app/settings/page.tsx
+++ b/web/app/settings/page.tsx
@@ -19,6 +19,12 @@ export default function Settings() {
+
+
+ 運営からのお知らせ
+
+
+
お問い合わせ
@@ -27,7 +33,7 @@ export default function Settings() {
- よくある質問
+ よくあるご質問
diff --git a/web/components/admin/adminLoginForm.tsx b/web/components/admin/adminLoginForm.tsx
new file mode 100644
index 00000000..b77cf4dc
--- /dev/null
+++ b/web/components/admin/adminLoginForm.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+export default function adminLoginForm() {
+ return (
+
+ );
+}
diff --git a/web/components/admin/nave-links.tsx b/web/components/admin/nave-links.tsx
new file mode 100644
index 00000000..beae5c32
--- /dev/null
+++ b/web/components/admin/nave-links.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import clsx from "clsx";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { MdHome } from "react-icons/md";
+import { MdNotificationsNone } from "react-icons/md";
+import { MdSupervisedUserCircle } from "react-icons/md";
+import { MdQuestionMark } from "react-icons/md";
+
+// Map of links to display in the side navigation.
+// Depending on the size of the application, this would be stored in a database.
+const links = [
+ { name: "ホーム", href: "/admin", icon: MdHome },
+ {
+ name: "ユーザー一覧",
+ href: "/admin/users",
+ icon: MdSupervisedUserCircle,
+ },
+ {
+ name: "お知らせ配信",
+ href: "/admin/notification",
+ icon: MdNotificationsNone,
+ },
+ {
+ name: "お問い合わせ",
+ href: "/admin/question",
+ icon: MdQuestionMark,
+ },
+];
+
+export default function NavLinks() {
+ const pathname = usePathname();
+
+ return (
+ <>
+ {links.map((link) => {
+ const LinkIcon = link.icon;
+ return (
+
+
+ {link.name}
+
+ );
+ })}
+ >
+ );
+}
diff --git a/web/components/admin/sideNave.tsx b/web/components/admin/sideNave.tsx
new file mode 100644
index 00000000..2d7d64ec
--- /dev/null
+++ b/web/components/admin/sideNave.tsx
@@ -0,0 +1,26 @@
+import { MdOutlinePowerSettingsNew } from "react-icons/md";
+import NavLinks from "./nave-links";
+
+export default function SideNav() {
+ return (
+
+
+
+ {/* biome-ignore lint/style/useSelfClosingElements:
*/}
+
+
+
+
+ );
+}