Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
// サーバーの起動
Expand Down
47 changes: 47 additions & 0 deletions server/src/router/admin.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions web/api/admin/login/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
8 changes: 8 additions & 0 deletions web/api/admin/validate/route.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 3 additions & 0 deletions web/api/internal/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -395,4 +397,5 @@ export default {
coursesMineOverlaps,
pictureOf,
picture,
adminLogin,
};
21 changes: 21 additions & 0 deletions web/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<NavigateByAuthState type="toLoginForUnauthenticated">
<div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
<div className="w-full flex-none md:w-64">
<SideNav />
</div>
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">
{children}
</div>
</div>
</NavigateByAuthState>
);
}
64 changes: 64 additions & 0 deletions web/app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg bg-white p-8 shadow-md">
<h2 className="text-center font-bold text-2xl text-gray-700">
管理者画面 ログインページ
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
{/* biome-ignore lint/a11y/noLabelWithoutControl: <explanation> */}
<label className="label">
<span className="label-text">Name</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="input input-bordered w-full"
placeholder="Enter your name"
/>
</div>
<div className="form-control">
{/* biome-ignore lint/a11y/noLabelWithoutControl: <explanation> */}
<label className="label">
<span className="label-text">Password</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input input-bordered w-full"
placeholder="Enter your password"
/>
</div>
<button type="submit" className="btn btn-primary w-full">
Login
</button>
</form>
</div>
</div>
);
}
161 changes: 161 additions & 0 deletions web/app/admin/notification/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Notification[]>([]);
const [form, setForm] = useState<Notification>({
id: 0,
title: "",
content: "",
});
const [isEditing, setIsEditing] = useState(false);

useEffect(() => {
// 初期データを設定
setNotifications(mockNotifications);
}, []);

// 入力フォームの変更処理
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
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 (
<div className="p-6">
<h1 className="mb-4 font-bold text-2xl">お知らせ配信</h1>

{/* 一覧表示 */}
<div className="mb-6">
<h2 className="mb-2 font-semibold text-xl">お知らせ一覧</h2>
<table className="w-full table-auto border-collapse">
<thead>
<tr className="border-b">
<th className="px-4 py-2 text-left">タイトル</th>
<th className="px-4 py-2 text-left">内容</th>
<th className="px-4 py-2">操作</th>
</tr>
</thead>
<tbody>
{notifications.map((notification) => (
<tr key={notification.id} className="border-b">
<td className="px-4 py-2">{notification.title}</td>
<td className="px-4 py-2">{notification.content}</td>
<td className="px-4 py-2 text-center">
{/* biome-ignore lint/a11y/useButtonType: <explanation> */}
<button
className="mr-2 text-blue-600"
onClick={() => handleEdit(notification)}
>
編集
</button>
{/* biome-ignore lint/a11y/useButtonType: <explanation> */}
<button
className="text-red-600"
onClick={() => handleDelete(notification.id)}
>
削除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>

{/* フォーム */}
<div>
<h2 className="mb-2 font-semibold text-xl">
{isEditing ? "お知らせを編集" : "新しいお知らせを作成"}
</h2>
<div className="mb-4">
{/* biome-ignore lint/nursery/useSortedClasses: <explanation> */}
{/* biome-ignore lint/a11y/noLabelWithoutControl: <explanation> */}
<label className="block mb-2">タイトル</label>
<input
className="w-full rounded border px-4 py-2"
type="text"
name="title"
value={form.title}
onChange={handleInputChange}
/>
</div>
<div className="mb-4">
{/* biome-ignore lint/a11y/noLabelWithoutControl: <explanation> */}
<label className="mb-2 block">内容</label>
{/* biome-ignore lint/style/useSelfClosingElements: <explanation> */}
<textarea
className="w-full rounded border px-4 py-2"
name="content"
value={form.content}
onChange={handleInputChange}
></textarea>
</div>
{/* biome-ignore lint/a11y/useButtonType: <explanation> */}
<button
className="rounded bg-blue-500 px-4 py-2 text-white"
onClick={isEditing ? handleUpdate : handleAdd}
>
{isEditing ? "更新" : "追加"}
</button>
</div>
</div>
);
}
Loading
Loading