Enable auth-service/api-gateway on production, build their images in CI
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogIn, LogOut, UserRound } from "lucide-react";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { logout, me } from "@duoc-thu/api-client";
|
||||
|
||||
/** Public — visible on every page, not just `/admin`. Chat itself never
|
||||
* requires this: an anonymous visitor keeps working exactly as before
|
||||
* regardless of what this renders. Optional login exists here so `demo`
|
||||
* (the logged-in "bác sĩ" persona) can sign in from the normal chat UI. */
|
||||
export function AccountMenu() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
me()
|
||||
.then(setUser)
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link
|
||||
href="/admin/login"
|
||||
className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
<span>Đăng nhập</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary">
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
<span>{user.username}</span>
|
||||
<button
|
||||
aria-label="Đăng xuất"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
router.refresh();
|
||||
}}
|
||||
className="ml-1 text-txt-muted hover:text-txt-primary"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { login, AuthError } from "@duoc-thu/api-client";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
const user = await login({ username, password });
|
||||
if (user.role !== "admin") {
|
||||
setError("Tài khoản này không có quyền quản trị.");
|
||||
return;
|
||||
}
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof AuthError && err.status === 401
|
||||
? "Sai tên đăng nhập hoặc mật khẩu."
|
||||
: "Không thể đăng nhập lúc này. Vui lòng thử lại."
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm space-y-4 rounded-2xl border border-border-subtle bg-surface p-6 shadow-sm"
|
||||
>
|
||||
<h1 className="text-lg font-bold text-txt-primary">Đăng nhập quản trị</h1>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-txt-secondary" htmlFor="username">
|
||||
Tên đăng nhập
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-txt-secondary" htmlFor="password">
|
||||
Mật khẩu
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs font-medium text-status-danger">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg bg-accent-primary py-2 text-sm font-semibold text-txt-inverse disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Đang đăng nhập..." : "Đăng nhập"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { logout, me } from "@duoc-thu/api-client";
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// The middleware guard already redirected anyone without a valid
|
||||
// admin session before this component ever rendered — this fetch is
|
||||
// for display, not the access-control decision itself.
|
||||
me().then(setUser);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
<h1 className="text-lg font-bold text-txt-primary">Khu vực quản trị</h1>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
Đăng nhập với: <strong>{user?.username ?? "..."}</strong> (
|
||||
{user?.role ?? "..."})
|
||||
</p>
|
||||
<p className="max-w-xl text-xs text-txt-muted">
|
||||
Đây là bằng chứng cơ chế phân quyền hoạt động đúng — chưa có tính
|
||||
năng quản trị cụ thể nào ở đây, vì chưa có yêu cầu nào được nêu ra.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push("/admin/login");
|
||||
router.refresh();
|
||||
}}
|
||||
className="w-fit rounded-lg border border-border-subtle px-4 py-2 text-sm font-semibold text-txt-primary hover:bg-surface-hover"
|
||||
>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { LoginResponse } from "@duoc-thu/shared-types";
|
||||
import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from "../session";
|
||||
|
||||
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let username: string;
|
||||
let password: string;
|
||||
try {
|
||||
const body = await request.json();
|
||||
username = typeof body?.username === "string" ? body.username : "";
|
||||
password = typeof body?.password === "string" ? body.password : "";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "missing_credentials" }, { status: 400 });
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${GATEWAY_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "invalid_credentials" },
|
||||
{ status: upstream.status === 401 ? 401 : 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await upstream.json()) as LoginResponse;
|
||||
const response = NextResponse.json({ user: data.user });
|
||||
// httpOnly: never readable by client-side JS (XSS can't exfiltrate it).
|
||||
// `secure` only outside local dev — Compose/k3s both terminate TLS in
|
||||
// front of `web`, so the cookie is only ever sent in the clear on
|
||||
// localhost, matching how every other secret in this repo treats
|
||||
// local vs. deployed differently.
|
||||
response.cookies.set(SESSION_COOKIE, data.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "../session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { SESSION_COOKIE } from "../session";
|
||||
|
||||
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
|
||||
|
||||
export async function GET() {
|
||||
const token = cookies().get(SESSION_COOKIE)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${GATEWAY_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = (await upstream.json()) as AuthUser;
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Shared between the login/logout/me route handlers and `middleware.ts` —
|
||||
* kept dependency-free (no Node-only imports) so `middleware.ts` can import
|
||||
* it too; Next's Edge runtime middleware can't use arbitrary Node APIs. */
|
||||
export const SESSION_COOKIE = "dt_session";
|
||||
export const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; // matches auth-service's default JWT_EXPIRES_IN
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
|
||||
import { NavTabs } from "./_components/NavTabs";
|
||||
import { AccountMenu } from "./_components/AccountMenu";
|
||||
import { Pill, ShieldCheck, Cpu } from "lucide-react";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -45,6 +46,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
|
||||
<ThemeSelector />
|
||||
|
||||
<AccountMenu />
|
||||
|
||||
{/* System Status Pill */}
|
||||
<div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm">
|
||||
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
|
||||
|
||||
+33
-2
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
import { SESSION_COOKIE } from "./app/api/auth/session";
|
||||
|
||||
/**
|
||||
* Rate limiting for the public API surface.
|
||||
@@ -96,7 +98,36 @@ function matchRules(pathname: string) {
|
||||
return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules;
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
/** `/admin/**` (except the login page itself) requires a valid session
|
||||
* cookie carrying `role: "admin"`. Verified with the same `JWT_SECRET`
|
||||
* auth-service signs with — Edge middleware can't call out to auth-service
|
||||
* per request without adding real latency to every admin page load, and
|
||||
* `jose` (unlike `jsonwebtoken`) works in the Edge runtime this file runs
|
||||
* under, so local verification is both correct and the only option here. */
|
||||
async function guardAdmin(request: NextRequest): Promise<NextResponse | null> {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return null;
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!token || !secret) {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
||||
if (payload.role !== "admin") {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const adminRedirect = await guardAdmin(request);
|
||||
if (adminRedirect) return adminRedirect;
|
||||
|
||||
const rules = matchRules(request.nextUrl.pathname);
|
||||
if (!rules) return NextResponse.next();
|
||||
|
||||
@@ -146,5 +177,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/:path*"],
|
||||
matcher: ["/api/:path*", "/admin/:path*"],
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^13.0.0",
|
||||
"jose": "^5.9.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
|
||||
Reference in New Issue
Block a user