Commit 47a23d19 authored by DuyNL's avatar DuyNL
Browse files

commit code

parent a44ae2e4
Pipeline #2177 failed with stages
in 0 seconds
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
\ No newline at end of file
import { NextRequest, NextResponse } from "next/server";
import { createCsrfToken, getCsrfCookieConfig } from "@/lib/csrf";
import { buildUnauthorizedResponse, hasVerifiedSession } from "@/lib/session-guard";
export async function GET(request: NextRequest) {
if (!(await hasVerifiedSession(request))) {
return buildUnauthorizedResponse();
}
const csrfToken = createCsrfToken();
const response = NextResponse.json({ csrfToken });
const cookie = getCsrfCookieConfig();
response.cookies.set(cookie.name, csrfToken, cookie.options);
return response;
}
import { NextRequest, NextResponse } from "next/server";
import {
createCsrfToken,
csrfHeaderName,
getCsrfCookieConfig,
verifyCsrfToken,
} from "@/lib/csrf";
import { apiRequest } from "@/lib/api";
import { buildUnauthorizedResponse, hasVerifiedSession } from "@/lib/session-guard";
function buildCsrfResponse(message: string, status: number) {
const refreshedCsrfToken = createCsrfToken();
const response = NextResponse.json(
{ message, csrfToken: refreshedCsrfToken },
{ status },
);
const cookie = getCsrfCookieConfig();
response.cookies.set(cookie.name, refreshedCsrfToken, cookie.options);
return response;
}
export async function POST(request: NextRequest) {
if (!(await hasVerifiedSession(request))) {
return buildUnauthorizedResponse();
}
const csrfCookie = request.cookies.get(getCsrfCookieConfig().name)?.value;
const csrfHeader = request.headers.get(csrfHeaderName);
if (!verifyCsrfToken(csrfCookie) || !verifyCsrfToken(csrfHeader) || csrfCookie !== csrfHeader) {
return buildCsrfResponse("CSRF token không hợp lệ.", 403);
}
try {
const { ok, payload, status } = await apiRequest({
url: "/posts", //point/api/otp/generate
method: "POST",
});
const refreshedCsrfToken = createCsrfToken();
const nextPayload =
payload && typeof payload === "object"
? { ...payload, csrfToken: refreshedCsrfToken }
: { data: payload ?? null, csrfToken: refreshedCsrfToken };
const nextResponse = NextResponse.json(nextPayload, {
status: ok ? status : status || 502,
});
const cookie = getCsrfCookieConfig();
nextResponse.cookies.set(cookie.name, refreshedCsrfToken, cookie.options);
return nextResponse;
} catch (error) {
const message =
error instanceof Error ? error.message : "Không thể kết nối tới dich vu SPC.";
return buildCsrfResponse(message, 502);
}
}
import { NextRequest, NextResponse } from "next/server";
import {
createCsrfToken,
csrfHeaderName,
getCsrfCookieConfig,
verifyCsrfToken,
} from "@/lib/csrf";
import { apiRequest } from "@/lib/api";
import { buildUnauthorizedResponse, hasVerifiedSession } from "@/lib/session-guard";
function buildCsrfResponse(message: string, status: number) {
const refreshedCsrfToken = createCsrfToken();
const response = NextResponse.json(
{ message, csrfToken: refreshedCsrfToken },
{ status },
);
const cookie = getCsrfCookieConfig();
response.cookies.set(cookie.name, refreshedCsrfToken, cookie.options);
return response;
}
export async function POST(request: NextRequest) {
if (!(await hasVerifiedSession(request))) {
return buildUnauthorizedResponse();
}
const csrfCookie = request.cookies.get(getCsrfCookieConfig().name)?.value;
const csrfHeader = request.headers.get(csrfHeaderName);
if (!verifyCsrfToken(csrfCookie) || !verifyCsrfToken(csrfHeader) || csrfCookie !== csrfHeader) {
return buildCsrfResponse("CSRF token không hợp lệ.", 403);
}
let body: { otp?: string };
try {
body = (await request.json()) as { otp?: string };
} catch {
return buildCsrfResponse("Dữ liệu xác thực không hợp lệ.", 400);
}
if (!body.otp) {
return buildCsrfResponse("Vui lòng nhập mã OTP.", 400);
}
try {
const { ok, payload, status } = await apiRequest({
url: "/epoint/api/otp/verify",
method: "POST",
body: JSON.stringify({ otp: body.otp }),
});
const refreshedCsrfToken = createCsrfToken();
const nextPayload =
payload && typeof payload === "object"
? { ...payload, csrfToken: refreshedCsrfToken }
: { data: payload ?? null, csrfToken: refreshedCsrfToken };
const nextResponse = NextResponse.json(nextPayload, {
status: ok ? status : status || 502,
});
const cookie = getCsrfCookieConfig();
nextResponse.cookies.set(cookie.name, refreshedCsrfToken, cookie.options);
return nextResponse;
} catch (error) {
const message =
error instanceof Error ? error.message : "Không thể kết nối tới dich vu SPC.";
return buildCsrfResponse(message, 502);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background:
radial-gradient(circle at top, rgba(47, 102, 208, 0.12), transparent 34%),
linear-gradient(180deg, #eaf1ff 0%, #f7f9fc 18%, #f4f6fb 100%);
}
body {
margin: 0;
min-height: 100vh;
font-family: Arial, Helvetica, sans-serif;
color: #1f2937;
}
button,
input {
font: inherit;
}
import type { Metadata, Viewport } from "next";
import "./globals.css";
import Providers from "./providers";
export const metadata: Metadata = {
title: "Liên kết tài khoản EPoint và EVNSPC",
description: "Màn hình điều khoản và xác thực OTP liên kết tài khoản.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="vi">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
const OTP_LENGTH = 6;
const TERMS_URL = "https://www.evnspc.vn/";
type Screen = "consent" | "otp" | "success";
type CsrfResponse = {
csrfToken: string;
};
type OtpResponse = {
csrfToken?: string;
message?: string;
};
type PageClientProps = {
initialVerifyError: string;
isVerified: boolean;
};
async function parseClientResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await response.json()) as T;
}
return {} as T;
}
async function fetchCsrfToken() {
const response = await fetch("/api/csrf", {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error("Khong the khoi tao CSRF token.");
}
return parseClientResponse<CsrfResponse>(response);
}
async function generateOtp(csrfToken: string) {
const response = await fetch("/api/otp/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-csrf-token": csrfToken,
},
cache: "no-store",
});
const payload = await parseClientResponse<OtpResponse>(response);
if (!response.ok) {
const error = new Error(
payload.message || "Khong the gui OTP. Vui long thu lai.",
) as Error & {
csrfToken?: string;
};
error.csrfToken = payload.csrfToken;
throw error;
}
return payload;
}
async function verifyOtp(otp: string, csrfToken: string) {
const response = await fetch("/api/otp/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-csrf-token": csrfToken,
},
body: JSON.stringify({ otp }),
cache: "no-store",
});
const payload = await parseClientResponse<OtpResponse>(response);
if (!response.ok) {
const error = new Error(
payload.message || "Xac thuc OTP that bai. Vui long thu lai.",
) as Error & {
csrfToken?: string;
};
error.csrfToken = payload.csrfToken;
throw error;
}
return payload;
}
export default function PageClient({
initialVerifyError,
isVerified,
}: PageClientProps) {
const [screen, setScreen] = useState<Screen>("consent");
const [accepted, setAccepted] = useState(false);
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [otpSent, setOtpSent] = useState(false);
const [csrfToken, setCsrfToken] = useState("");
const [verifyErrorPopupOpen, setVerifyErrorPopupOpen] = useState(
Boolean(initialVerifyError),
);
const csrfQuery = useQuery({
queryKey: ["csrf-token"],
queryFn: fetchCsrfToken,
staleTime: 10 * 60 * 1000,
enabled: isVerified,
});
const otpMessage = useMemo(() => {
if (!otpSent) {
return "";
}
return "Mã OTP đã được gửi đến số điện thoại đăng ký của bạn.";
}, [otpSent]);
const generateOtpMutation = useMutation({
mutationFn: () => {
if (!csrfToken) {
throw new Error("CSRF token chua san sang. Vui long thu lai.");
}
return generateOtp(csrfToken);
},
onMutate: () => {
setError("");
},
onSuccess: (payload) => {
if (payload.csrfToken) {
setCsrfToken(payload.csrfToken);
}
setOtp("");
setOtpSent(true);
setScreen("otp");
},
onError: (mutationError: Error & { csrfToken?: string }) => {
if (mutationError.csrfToken) {
setCsrfToken(mutationError.csrfToken);
}
setError(mutationError.message);
},
});
const verifyOtpMutation = useMutation({
mutationFn: () => {
if (!csrfToken) {
throw new Error("CSRF token chua san sang. Vui long thu lai.");
}
return verifyOtp(otp, csrfToken);
},
onMutate: () => {
setError("");
},
onSuccess: (payload) => {
if (payload.csrfToken) {
setCsrfToken(payload.csrfToken);
}
setError("");
setScreen("success");
},
onError: (mutationError: Error & { csrfToken?: string }) => {
if (mutationError.csrfToken) {
setCsrfToken(mutationError.csrfToken);
}
setError(mutationError.message);
},
});
useEffect(() => {
if (csrfQuery.data?.csrfToken) {
setCsrfToken(csrfQuery.data.csrfToken);
}
}, [csrfQuery.data?.csrfToken]);
const handleLink = () => {
if (
!isVerified ||
!accepted ||
generateOtpMutation.isPending ||
csrfQuery.isLoading
) {
return;
}
generateOtpMutation.mutate();
};
const handleOtpSubmit = () => {
if (otp.length !== OTP_LENGTH) {
setError("Vui long nhap du 6 so OTP.");
return;
}
if (verifyOtpMutation.isPending) {
return;
}
verifyOtpMutation.mutate();
};
const handleSkip = () => {
window.history.back();
};
const openTerms = () => {
if (!isVerified) {
return;
}
window.open(TERMS_URL, "_blank", "noopener,noreferrer");
};
return (
<main className="flex min-h-screen items-stretch justify-center bg-white sm:items-center sm:bg-transparent sm:px-6 sm:py-6">
<section className="relative min-h-screen w-full overflow-hidden bg-white sm:min-h-0 sm:max-w-sm sm:rounded-[28px] sm:shadow-panel">
<header className="relative flex min-h-14 items-center justify-center bg-brand-blue px-4 text-white">
<button
type="button"
onClick={() => (screen === "consent" ? handleSkip() : setScreen("consent"))}
className="absolute left-4 flex h-8 w-8 items-center justify-center rounded-full text-xl leading-none transition-colors hover:bg-white/10"
aria-label="Quay lai"
>
&larr;
</button>
<div className="min-w-0 px-10 text-center">
<p className="text-sm font-semibold sm:text-[15px]">
Liên kết tài khoản EPoint và EVNSPC
</p>
</div>
</header>
<div className="px-5 py-6">
{screen !== "success" && (
<ConsentScreen
accepted={accepted}
error={screen === "consent" ? error : ""}
isDisabled={!isVerified}
isLinkLoading={generateOtpMutation.isPending || csrfQuery.isLoading}
onAcceptChange={setAccepted}
onLink={handleLink}
onSkip={handleSkip}
onOpenTerms={openTerms}
/>
)}
{screen === "success" && <SuccessScreen onBack={handleSkip} />}
</div>
{screen === "otp" && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center px-4 py-6">
<div className="pointer-events-auto absolute inset-0 z-0 bg-slate-900/35" />
<div className="pointer-events-auto relative z-10 w-full max-w-[340px] rounded-[24px] border border-slate-200 bg-white px-5 py-6 shadow-[0_20px_50px_rgba(15,23,42,0.2)]">
<OtpScreen
otp={otp}
error={error}
isResendLoading={generateOtpMutation.isPending}
isSubmitLoading={verifyOtpMutation.isPending}
message={otpMessage}
onClose={() => {
setError("");
setScreen("consent");
}}
onOtpChange={(value) => {
const nextValue = value.replace(/\D/g, "").slice(0, OTP_LENGTH);
setOtp(nextValue);
if (error) {
setError("");
}
}}
onSubmit={handleOtpSubmit}
onResend={() => {
if (generateOtpMutation.isPending || csrfQuery.isLoading) {
return;
}
setOtp("");
generateOtpMutation.mutate();
}}
/>
</div>
</div>
)}
{verifyErrorPopupOpen && initialVerifyError && (
<div className="absolute inset-0 z-30 flex items-center justify-center px-4 py-6">
<div className="absolute inset-0 bg-slate-900/35" />
<div className="relative z-10 w-full max-w-[340px] rounded-[24px] bg-white px-5 py-6 shadow-[0_20px_50px_rgba(15,23,42,0.2)]">
<div className="space-y-4 text-center">
<h2 className="text-2xl font-bold text-slate-900">Thong bao</h2>
<p className="text-[15px] leading-6 text-slate-600">
{initialVerifyError}
</p>
<button
type="button"
onClick={() => setVerifyErrorPopupOpen(false)}
className="w-full rounded-2xl bg-brand-blue px-4 py-3 text-base font-bold text-white"
>
Dong
</button>
</div>
</div>
</div>
)}
</section>
</main>
);
}
function ConsentScreen({
accepted,
error,
isDisabled,
isLinkLoading,
onAcceptChange,
onLink,
onSkip,
onOpenTerms,
}: {
accepted: boolean;
error: string;
isDisabled: boolean;
isLinkLoading: boolean;
onAcceptChange: (value: boolean) => void;
onLink: () => void;
onSkip: () => void;
onOpenTerms: () => void;
}) {
return (
<div className="space-y-5">
<div className="space-y-4 text-center">
<h1 className="text-[28px] font-bold leading-8 text-slate-900">
Điều khoản liên kết tài khoản EPoint và EVNSPC
</h1>
<div className="space-y-3 text-left text-[15px] leading-6 text-slate-700">
<div>
<p className="font-bold text-slate-900">Xác nhận và chấp thuận</p>
<p>
Tổng công ty Điện lực miền Nam chia sẻ cho ứng dụng EPOINT của công ty cổ
phần PayTech các thông tin sau: tên khách hàng, mã khách hàng, địa chỉ dùng
điện, chỉ số sản lượng địen và thông tin hóa đơn.
</p>
</div>
<p>
Bằng cách nhấn &quot;Xác nhận&quot; để hoàn tất và chấp thuận liên kết tài
khoản EPoint với EVNSPC.
</p>
<button
type="button"
onClick={onOpenTerms}
disabled={isDisabled}
className="text-left text-[15px] font-medium leading-6 text-brand-blue underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:text-blue-300 disabled:no-underline"
>
Thông tin điều khoản liên kết tài khỏan EVNSPC. Vui lòng chọn xác nhận
</button>
</div>
</div>
<div className="space-y-4">
<p className="text-center text-[15px] text-slate-700">
Bạn có muốn liên kết tài khỏan?
</p>
<label className="flex items-start gap-3 rounded-2xl bg-slate-50 px-3 py-3 text-[15px] text-slate-700">
<input
type="checkbox"
checked={accepted}
disabled={isDisabled}
onChange={(event) => onAcceptChange(event.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-brand-blue focus:ring-brand-blue"
/>
<span>Tôi đã đọc và đồng ý các điều khoản trên</span>
</label>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="flex gap-3">
<button
type="button"
onClick={onLink}
disabled={isDisabled || !accepted || isLinkLoading}
className="flex flex-1 items-center justify-center rounded-2xl bg-brand-green px-4 py-3 text-base font-bold text-white transition-colors disabled:cursor-not-allowed disabled:bg-emerald-200"
>
{isLinkLoading ? <ButtonSpinner /> : "Liên kết"}
</button>
<button
type="button"
onClick={onSkip}
disabled={isDisabled || isLinkLoading}
className="flex-1 rounded-2xl bg-brand-blue px-4 py-3 text-base font-bold text-white transition-colors hover:bg-brand-blueDark disabled:cursor-not-allowed disabled:bg-blue-300"
>
Bỏ qua
</button>
</div>
</div>
</div>
);
}
function OtpScreen({
otp,
error,
isSubmitLoading,
isResendLoading,
message,
onClose,
onOtpChange,
onSubmit,
onResend,
}: {
otp: string;
error: string;
isSubmitLoading: boolean;
isResendLoading: boolean;
message: string;
onClose: () => void;
onOtpChange: (value: string) => void;
onSubmit: () => void;
onResend: () => void;
}) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 text-left">
<h1 className="text-[28px] font-bold leading-8 text-slate-900">Nhập mã OTP</h1>
<p className="text-[15px] leading-6 text-slate-600">
Để xác thực liên kết, vui lòng nhập mã OTP vừa được gửi qua SMS
</p>
{message ? <p className="text-sm font-medium text-brand-blue">{message}</p> : null}
</div>
<button
type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full text-xl text-slate-500 transition-colors hover:bg-slate-100"
aria-label="Đóng popup OTP"
>
&times;
</button>
</div>
<div className="space-y-3">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={otp}
onChange={(event) => onOtpChange(event.target.value)}
className="w-full rounded-2xl border border-slate-200 px-4 py-4 text-center text-2xl font-bold tracking-[0.35em] text-slate-900 outline-none transition focus:border-brand-blue"
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</div>
<div className="space-y-3">
<button
type="button"
onClick={onSubmit}
disabled={isSubmitLoading}
className="w-full rounded-2xl bg-brand-green px-4 py-3 text-base font-bold text-white transition-colors hover:bg-brand-greenDark disabled:cursor-not-allowed disabled:bg-emerald-300"
>
{isSubmitLoading ? <ButtonSpinner /> : "Xac thuc"}
</button>
<button
type="button"
onClick={onResend}
disabled={isResendLoading}
className="flex w-full items-center justify-center rounded-2xl border border-brand-blue px-4 py-3 text-base font-semibold text-brand-blue transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:border-blue-300 disabled:text-blue-300"
>
{isResendLoading ? (
<ButtonSpinner color="border-brand-blue border-t-transparent" />
) : (
"Gui lai OTP"
)}
</button>
</div>
</div>
);
}
function SuccessScreen({ onBack }: { onBack: () => void }) {
return (
<div className="space-y-6 py-6 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-4xl text-brand-green">
&#10003;
</div>
<div className="space-y-2">
<h1 className="text-[28px] font-bold leading-8 text-slate-900">Lien ket thanh cong</h1>
<p className="text-[15px] leading-6 text-slate-600">
Tai khoan EPoint cua ban da duoc xac thuc va lien ket voi EVNSPC.
</p>
</div>
<button
type="button"
onClick={onBack}
className="w-full rounded-2xl bg-brand-blue px-4 py-3 text-base font-bold text-white transition-colors hover:bg-brand-blueDark"
>
Ve dashboard
</button>
</div>
);
}
function ButtonSpinner({
color = "border-white border-t-transparent",
}: {
color?: string;
}) {
return (
<span
className={`inline-block h-5 w-5 shrink-0 animate-spin rounded-full border-2 ${color}`}
aria-hidden="true"
/>
);
}
import { headers } from "next/headers";
import PageClient from "./page-client";
export default async function Page() {
const headerStore = await headers();
const encodedVerifyError = headerStore.get("x-verify-error") || "";
const initialVerifyError = encodedVerifyError
? decodeURIComponent(encodedVerifyError)
: "";
const isVerified = headerStore.get("x-session-valid") === "1";
return (
<PageClient
initialVerifyError={initialVerifyError}
isVerified={isVerified}
/>
);
}
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export default function Providers({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
type ApiError = {
message?: string;
};
export type VerifyTokenForWebViewResponse = {
status?: string;
error_message?: string;
};
type ApiRequestOptions = {
url: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: HeadersInit;
body?: BodyInit | null;
baseUrl?: string;
};
type ApiRequestResult<T> = {
ok: boolean;
status: number;
payload: T;
};
async function parseServerResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await response.json()) as T;
}
return {} as T;
}
export async function apiRequest<T extends Record<string, unknown> | ApiError>({
url,
method = "GET",
headers,
body = null,
baseUrl,
}: ApiRequestOptions): Promise<ApiRequestResult<T>> {
try {
const apiSPC = baseUrl || process.env.NEXT_PUBLIC_SPC_API;
if (!apiSPC) {
throw new Error("Thiếu cấu hình domain.");
}
const response = await fetch(`${apiSPC}${url}`, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body,
cache: "no-store",
});
const payload = await parseServerResponse<T>(response);
const vnTime = new Date().toLocaleString("vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
});
console.log(`${vnTime}: [apiRequest] ${method} ${url}:`, payload);
return {
ok: response.ok,
status: response.status,
payload,
};
} catch (error) {
const vnTime = new Date().toLocaleString("vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
});
console.error(`${vnTime}: [apiRequest] ${method} ${url} failed: `, error);
if (error instanceof Error) {
throw error;
}
throw new Error("Khong the ket noi toi API.");
}
}
export async function verifyTokenForWebView(accessToken: string) {
return apiRequest<VerifyTokenForWebViewResponse>({
baseUrl: process.env.NEXT_PUBLIC_BE_API,
url: process.env.NEXT_PUBLIC_BE_API_VERIFY ?? "/20984/gup2start/rest/accountVerifyTokenForWebView/1.0.0",
method: "POST",
body: JSON.stringify({
access_token: accessToken,
}),
});
}
import crypto from "node:crypto";
const CSRF_COOKIE_NAME = "csrf_token";
const CSRF_HEADER_NAME = "x-csrf-token";
const CSRF_TTL_MS = 15 * 60 * 1000;
function getCsrfSecret() {
const secret = process.env.CSRF_SECRET;
if (!secret) {
throw new Error("Thiếu cấu hình CSRF_SECRET trong file .env.");
}
return secret;
}
function signCsrfToken(nonce: string, issuedAt: string) {
return crypto
.createHmac("sha256", getCsrfSecret())
.update(`${nonce}.${issuedAt}`)
.digest("hex");
}
export function createCsrfToken() {
const nonce = crypto.randomBytes(24).toString("hex");
const issuedAt = Date.now().toString();
const signature = signCsrfToken(nonce, issuedAt);
return `${nonce}.${issuedAt}.${signature}`;
}
export function verifyCsrfToken(token: string | undefined | null) {
if (!token) {
return false;
}
const [nonce, issuedAt, signature] = token.split(".");
if (!nonce || !issuedAt || !signature) {
return false;
}
const expectedSignature = signCsrfToken(nonce, issuedAt);
if (signature.length !== expectedSignature.length) {
return false;
}
const isSignatureValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
if (!isSignatureValid) {
return false;
}
const issuedTime = Number(issuedAt);
if (!Number.isFinite(issuedTime)) {
return false;
}
return Date.now() - issuedTime <= CSRF_TTL_MS;
}
export function getCsrfCookieConfig() {
return {
name: CSRF_COOKIE_NAME,
options: {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/"
},
};
}
export const csrfHeaderName = CSRF_HEADER_NAME;
import { NextRequest, NextResponse } from "next/server";
import {
getSessionCookieConfig,
isSuccessfulSession,
verifySessionToken,
} from "@/lib/session";
export async function getSessionFromRequest(request: NextRequest) {
const sessionToken = request.cookies.get(getSessionCookieConfig().name)?.value;
return verifySessionToken(sessionToken);
}
export async function hasVerifiedSession(request: NextRequest) {
const session = await getSessionFromRequest(request);
return isSuccessfulSession(session);
}
export function buildUnauthorizedResponse() {
return NextResponse.json(
{ message: "Phiên đăng nhập không hợp lệ hoặc đã hết hạn." },
{ status: 401 },
);
}
const SESSION_COOKIE_NAME = "epoint_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
type SessionPayload = {
accessToken: string;
state: "success" | "fail";
errorMessage: string;
expiresAt: number;
};
function getSessionSecret() {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("Thiếu cấu hình SESSION_SECRET");
}
return secret;
}
function toBase64Url(input: ArrayBuffer | Uint8Array | string) {
const bytes =
typeof input === "string"
? new TextEncoder().encode(input)
: input instanceof Uint8Array
? input
: new Uint8Array(input);
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function fromBase64Url(input: string) {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
async function signSessionPayload(payload: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(getSessionSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(payload),
);
return toBase64Url(signature);
}
export async function createSessionToken({
accessToken,
state,
errorMessage = "",
}: {
accessToken: string;
state: "success" | "fail";
errorMessage?: string;
}) {
const payload: SessionPayload = {
accessToken,
state,
errorMessage,
expiresAt: Date.now() + SESSION_TTL_MS,
};
const encodedPayload = toBase64Url(JSON.stringify(payload));
const signature = await signSessionPayload(encodedPayload);
return `${encodedPayload}.${signature}`;
}
export async function verifySessionToken(token: string | undefined | null) {
if (!token) {
return null;
}
const [encodedPayload, signature] = token.split(".");
if (!encodedPayload || !signature) {
return null;
}
const expectedSignature = await signSessionPayload(encodedPayload);
if (signature !== expectedSignature) {
return null;
}
try {
const payload = JSON.parse(
new TextDecoder().decode(fromBase64Url(encodedPayload)),
) as SessionPayload;
if (
!payload.accessToken ||
!payload.state ||
!payload.expiresAt ||
payload.expiresAt < Date.now()
) {
return null;
}
return payload;
} catch {
return null;
}
}
export function getSessionCookieConfig() {
return {
name: SESSION_COOKIE_NAME,
options: {
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
path: "/"
},
};
}
export function isSuccessfulSession(
session: Awaited<ReturnType<typeof verifySessionToken>> | null,
) {
return Boolean(session && session.state === "success");
}
import { NextRequest, NextResponse } from "next/server";
import { verifyTokenForWebView } from "@/lib/api";
import {
createSessionToken,
getSessionCookieConfig,
isSuccessfulSession,
verifySessionToken,
} from "@/lib/session";
function getAccessTokenFromHeaders(request: NextRequest) {
const authorization = request.headers.get("authorization");
if (authorization?.toLowerCase().startsWith("bearer ")) {
return authorization.slice(7).trim();
}
return (
request.headers.get("access_token") ||
request.headers.get("x-access-token") ||
request.headers.get("access-token") ||
""
).trim();
}
function encodeHeaderValue(value: string) {
return encodeURIComponent(value);
}
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname !== "/") {
return NextResponse.next();
}
const requestHeaders = new Headers(request.headers);
const sessionCookieConfig = getSessionCookieConfig();
//check session to call api
const sessionCookie = request.cookies.get(getSessionCookieConfig().name)?.value;
const session = await verifySessionToken(sessionCookie);
const buildNextResponse = () =>
NextResponse.next({
request: {
headers: requestHeaders,
},
});
//check session to call api
if (session) {
if (isSuccessfulSession(session)) {
requestHeaders.set("x-session-valid", "1");
} else if (session.errorMessage) {
requestHeaders.set("x-verify-error", encodeHeaderValue(session.errorMessage));
}
return buildNextResponse();
}
const accessToken = getAccessTokenFromHeaders(request);
if (!accessToken) {
requestHeaders.set(
"x-verify-error",
encodeHeaderValue("Khong tim thay token xac thuc tren request header."),
);
const response = buildNextResponse();
response.cookies.delete(sessionCookieConfig.name);
return response;
}
try {
const { ok, payload } = await verifyTokenForWebView(accessToken);
if (ok && payload.status === "success") {
requestHeaders.set("x-session-valid", "1");
const response = buildNextResponse();
response.cookies.set(
sessionCookieConfig.name,
await createSessionToken({
accessToken,
state: "success",
}),
sessionCookieConfig.options,
);
return response;
}
const errorMessage = payload.error_message || "Xác thực thất bại.";
requestHeaders.set(
"x-verify-error",
encodeHeaderValue(errorMessage),
);
const response = buildNextResponse();
response.cookies.set(
sessionCookieConfig.name,
await createSessionToken({
accessToken,
state: "fail",
errorMessage,
}),
sessionCookieConfig.options,
);
return response;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Không thể xác thực.";
requestHeaders.set(
"x-verify-error",
encodeHeaderValue(errorMessage),
);
const response = buildNextResponse();
response.cookies.set(
sessionCookieConfig.name,
await createSessionToken({
accessToken,
state: "fail",
errorMessage,
}),
sessionCookieConfig.options,
);
return response;
}
}
export const config = {
matcher: ["/"],
};
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
PHP
- thêm api mới vào bảng
INSERT INTO healthnet.api_generator_config
(api_uri, api_domain, title_vi, title_en, description_vi, description_en, place_to_run, environments, `method`, authentication, version, disable_common_fields, pos_action_type, state, api_type, customize_response, create_time, update_time)
VALUES('accountVerifyTokenForWebView', 'REST', 'accountVerifyTokenForWebView', 'accountVerifyTokenForWebView', 'accountVerifyTokenForWebView', 'accountVerifyTokenForWebView', 'CUSTOMER_LOYALTY_UNIFIED,CUSTOMER_LOYALTY_ELECTRICITY', NULL, 'POST', 1, '1.0.0', NULL, NULL, 1002, 'GNA', 0, '2026-03-18 17:26:28.000', '2026-03-18 17:26:28.000');
\ No newline at end of file
This diff is collapsed.
{
"name": "spc-lending-linking-flow",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tanstack/react-query": "5.90.5",
"next": "15.5.13",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.17.57",
"@types/react": "18.3.17",
"@types/react-dom": "18.3.5",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
}
}
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
blue: "#2f66d0",
blueDark: "#214fa8",
green: "#12955e",
greenDark: "#0e7f51",
},
},
boxShadow: {
panel: "0 16px 38px rgba(15, 23, 42, 0.08)",
},
},
},
plugins: [],
};
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment