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 ( + +
+
+ +
+
+ {children} +
+
+
+ ); +} 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 ( +
+
+

+ 管理者画面 ログインページ +

+
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */} + + setName(e.target.value)} + className="input input-bordered w-full" + placeholder="Enter your name" + /> +
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: */} + + setPassword(e.target.value)} + className="input input-bordered w-full" + placeholder="Enter your password" + /> +
+ +
+
+
+ ); +} 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: */} +
    +
    { + // "use server"; + // await signOut(); + // }} + > + {/* biome-ignore lint/a11y/useButtonType: */} + + +
    +
    + ); +}