From 37a6ed58c98f5a340fc32570538cc45ef950aaec Mon Sep 17 00:00:00 2001 From: Thanh Quy - wolf <524H0124@student.tdtu.edu.vn> Date: Sun, 19 Apr 2026 17:04:07 +0700 Subject: [PATCH] Connect backend --- app/(main)/register/page.tsx | 36 +++- components/organisms/forms/LoginForm.tsx | 44 +++-- default.conf | 21 ++ docker-compose.yml | 12 ++ lib/api/auth.ts | 53 +++++ lib/api/client.ts | 55 ++++++ lib/auth-context.tsx | 241 ++++++++++++----------- lib/types.ts | 28 +++ next.config.ts | 10 + 9 files changed, 363 insertions(+), 137 deletions(-) create mode 100644 default.conf create mode 100644 docker-compose.yml create mode 100644 lib/api/auth.ts create mode 100644 lib/api/client.ts diff --git a/app/(main)/register/page.tsx b/app/(main)/register/page.tsx index 2325249..9eb994f 100644 --- a/app/(main)/register/page.tsx +++ b/app/(main)/register/page.tsx @@ -13,13 +13,27 @@ const DEMO_OTP = "123456"; export default function RegisterPage() { const router = useRouter(); - const { completeRegistration } = useAuth(); + const { completeRegistration, lastError } = useAuth(); const [step, setStep] = useState<"phone" | "otp">("phone"); const [phone, setPhone] = useState(""); const [otp, setOtp] = useState(""); + const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState({ phone: "", otp: "" }); + const mapApiError = ( + err: { status: number; body: string } | null, + ): string => { + if (!err) return "Đăng ký thất bại. Vui lòng thử lại."; + if (err.status === 0) + return "Không kết nối được đến máy chủ. Kiểm tra backend có đang chạy không."; + if (err.status >= 500) + return `Máy chủ lỗi (${err.status}). Backend có thể chưa sẵn sàng.`; + if (err.body === "ExistedUser") return "Số điện thoại đã được đăng ký"; + if (err.body === "InvalidPhoneNumber") return "Số điện thoại không hợp lệ"; + return err.body || "Đăng ký thất bại. Vui lòng thử lại."; + }; + // Validate Vietnamese phone number const validatePhone = (phoneNumber: string): boolean => { // Vietnamese phone format: 10 digits starting with 0 @@ -49,7 +63,7 @@ export default function RegisterPage() { setErrors({ phone: "", otp: "" }); }; - const handleOtpSubmit = (e: FormEvent) => { + const handleOtpSubmit = async (e: FormEvent) => { e.preventDefault(); if (!otp.trim()) { @@ -62,9 +76,17 @@ export default function RegisterPage() { return; } - // Complete registration - completeRegistration(phone); - router.push("/"); + setSubmitting(true); + try { + const success = await completeRegistration(phone); + if (success) { + router.push("/"); + } else { + setErrors({ ...errors, otp: mapApiError(lastError) }); + } + } finally { + setSubmitting(false); + } }; const handleBackToPhone = () => { @@ -237,8 +259,9 @@ export default function RegisterPage() { type="submit" style="login" size="lg" + disabled={submitting} > - Hoàn tất đăng ký + {submitting ? "Đang đăng ký..." : "Hoàn tất đăng ký"} {/* Back Button */} @@ -247,6 +270,7 @@ export default function RegisterPage() { onClick={handleBackToPhone} size="lg" style="login" + disabled={submitting} > Thay đổi số điện thoại diff --git a/components/organisms/forms/LoginForm.tsx b/components/organisms/forms/LoginForm.tsx index 28606a8..72b6012 100644 --- a/components/organisms/forms/LoginForm.tsx +++ b/components/organisms/forms/LoginForm.tsx @@ -8,10 +8,11 @@ import { FormEvent, useState } from "react"; export default function LoginForm() { const router = useRouter(); - const { login } = useAuth(); + const { login, lastError } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState({ username: "", password: "", @@ -39,21 +40,37 @@ export default function LoginForm() { return isValid; }; - const handleSubmit = (e: FormEvent) => { + const mapApiError = ( + err: { status: number; body: string } | null, + ): string => { + if (!err) return "Đăng nhập thất bại. Vui lòng thử lại."; + if (err.status === 0) + return "Không kết nối được đến máy chủ. Kiểm tra backend có đang chạy không."; + if (err.status >= 500) + return `Máy chủ lỗi (${err.status}). Backend có thể chưa sẵn sàng hoặc account-service chưa chạy.`; + if (err.body === "InvalidPhoneNumber") return "Số điện thoại không tồn tại"; + if (err.body === "InvalidPassword") return "Mật khẩu không đúng"; + return err.body || "Tên đăng nhập hoặc mật khẩu không đúng"; + }; + + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!validate()) return; - const success = login(username, password); - - if (success) { - router.push("/"); - } else { - setErrors({ - username: "", - password: "", - general: "Tên đăng nhập hoặc mật khẩu không đúng", - }); + setSubmitting(true); + try { + const success = await login(username, password); + if (success) { + router.push("/"); + } else { + setErrors((prev) => ({ + ...prev, + general: mapApiError(lastError), + })); + } + } finally { + setSubmitting(false); } }; @@ -107,8 +124,9 @@ export default function LoginForm() { type="submit" style="login" size="lg" + disabled={submitting} > - Đăng nhập + {submitting ? "Đang đăng nhập..." : "Đăng nhập"} {/* Register Button */} diff --git a/default.conf b/default.conf new file mode 100644 index 0000000..91a8955 --- /dev/null +++ b/default.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name localhost; + + # Điều hướng API tới Backend + location /api { + # 'backend' là tên service được định nghĩa trong docker-compose.yaml + proxy_pass http://host.docker.internal:32080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Điều hướng phần còn lại tới Frontend + location / { + # 'frontend' là tên service được định nghĩa trong docker-compose.yaml + proxy_pass http://host.docker.internal:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ab52451 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.8" + +services: + nginx: + image: nginx:stable-alpine + container_name: nginx-gateway + ports: + - "80:80" + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf:ro + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/lib/api/auth.ts b/lib/api/auth.ts new file mode 100644 index 0000000..cb0be90 --- /dev/null +++ b/lib/api/auth.ts @@ -0,0 +1,53 @@ +import { apiRequest } from "./client"; + +import type { + ApiCustomer, + LoginPayload, + QuickLoginPayload, + QuickSignupPayload, + SignupPayload, +} from "../types"; + +const CUSTOMER_BASE = "/api/customer"; + +export function signup(payload: SignupPayload) { + return apiRequest(`${CUSTOMER_BASE}/signup`, { + method: "POST", + body: payload, + }); +} + +export function quickSignup(payload: QuickSignupPayload) { + return apiRequest(`${CUSTOMER_BASE}/quick_signup`, { + method: "POST", + body: payload, + }); +} + +export function login(payload: LoginPayload) { + return apiRequest(`${CUSTOMER_BASE}/login`, { + method: "POST", + body: payload, + }); +} + +export function quickLogin(payload: QuickLoginPayload) { + return apiRequest(`${CUSTOMER_BASE}/quick_login`, { + method: "POST", + body: payload, + }); +} + +export function me() { + return apiRequest(CUSTOMER_BASE, { method: "GET" }); +} + +export function logout() { + // Backend has no logout endpoint; clearing the httpOnly `auth` cookie + // requires a server-side Set-Cookie with Max-Age=0. Until the gateway + // exposes /logout, we trigger an invalid-auth response by calling /me + // with a fresh client after clearing browser-accessible state. + // The auth cookie is httpOnly so we cannot remove it from JS; the + // server must do it. For now this is a no-op network call. + return Promise.resolve(); +} diff --git a/lib/api/client.ts b/lib/api/client.ts new file mode 100644 index 0000000..2238d8e --- /dev/null +++ b/lib/api/client.ts @@ -0,0 +1,55 @@ +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; + +export class ApiError extends Error { + status: number; + body: string; + + constructor(status: number, body: string, message?: string) { + super(message ?? body ?? `Request failed with status ${status}`); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +interface RequestOptions { + method?: HttpMethod; + body?: unknown; + headers?: Record; + signal?: AbortSignal; +} + +export async function apiRequest( + path: string, + options: RequestOptions = {}, +): Promise { + const { method = "GET", body, headers = {}, signal } = options; + + const res = await fetch(`${BASE_URL}${path}`, { + method, + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal, + }); + + const rawText = await res.text(); + + if (!res.ok) { + throw new ApiError(res.status, rawText); + } + + if (!rawText) return undefined as T; + + try { + return JSON.parse(rawText) as T; + } catch { + return rawText as unknown as T; + } +} diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx index 79b36d7..9341a34 100644 --- a/lib/auth-context.tsx +++ b/lib/auth-context.tsx @@ -3,163 +3,168 @@ import { ReactNode, createContext, + useCallback, useContext, useEffect, useState, } from "react"; -import type { User } from "./types"; +import * as authApi from "./api/auth"; +import { ApiError } from "./api/client"; + +import type { ApiCustomer, User } from "./types"; + +export interface AuthErrorInfo { + // HTTP status (0 = network / fetch failure) + status: number; + // Raw body or error message from the backend (e.g. "InvalidPassword") + body: string; +} interface AuthContextType { user: User | null; - login: (username: string, password: string) => boolean; - logout: () => void; + loading: boolean; + login: (phone: string, password: string) => Promise; + logout: () => Promise; registerPhone: string | null; setRegisterPhone: (phone: string | null) => void; - completeRegistration: (phone: string) => void; + completeRegistration: (phone: string) => Promise; + lastError: AuthErrorInfo | null; } const AuthContext = createContext(undefined); -// Mock user database -const MOCK_AUTH_DB = { - // Admin - admin: { - username: "admin", - password: "admin", - user: { - id: 1, - name: "Quản lý", - role: "manager" as const, - avatar: null, - phone: "0912345678", - }, - }, +const STORAGE_KEY = "coffee-shop-user"; - // Staff (username and password are their names) - "Nguyễn Văn An": { - username: "Nguyễn Văn An", - password: "Nguyễn Văn An", - user: { - id: 2, - name: "Nguyễn Văn An", - role: "staff" as const, - avatar: null, - phone: "0901234567", - }, - }, - "Trần Thị Bình": { - username: "Trần Thị Bình", - password: "Trần Thị Bình", - user: { - id: 3, - name: "Trần Thị Bình", - role: "staff" as const, - avatar: null, - phone: "0902345678", - }, - }, - "Lê Văn Cường": { - username: "Lê Văn Cường", - password: "Lê Văn Cường", - user: { - id: 4, - name: "Lê Văn Cường", - role: "staff" as const, - avatar: null, - phone: "0903456789", - }, - }, +// Stable numeric id derived from UUID string — used because local +// User.id is typed as number (staff module uses numeric ids). +// Backend never reads this back; identity on the server side is cookie-based. +function hashUuidToNumber(uuid: string): number { + let h = 0; + for (let i = 0; i < uuid.length; i++) { + h = (h * 31 + uuid.charCodeAt(i)) | 0; + } + return Math.abs(h); +} - // Customers (username is phone number, password is custom) - "0987654321": { - username: "0987654321", - password: "user1", - user: { - id: 5, - name: "Khách hàng", - role: "customer" as const, - avatar: null, - phone: "0987654321", - }, - }, - "0976543210": { - username: "0976543210", - password: "user1", - user: { - id: 6, - name: "Khách hàng", - role: "customer" as const, - avatar: null, - phone: "0976543210", - }, - }, -}; +function toUser(c: ApiCustomer): User { + return { + id: hashUuidToNumber(c.id), + apiId: c.id, + name: c.name, + role: "customer", + avatar: null, + phone: c.phone, + }; +} + +function toErrorInfo(e: unknown): AuthErrorInfo { + if (e instanceof ApiError) return { status: e.status, body: e.body ?? "" }; + if (e instanceof Error) return { status: 0, body: e.message }; + return { status: 0, body: String(e) }; +} export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); const [registerPhone, setRegisterPhone] = useState(null); + const [lastError, setLastError] = useState(null); - // Load user from localStorage on mount - useEffect(() => { - const savedUser = localStorage.getItem("coffee-shop-user"); - if (savedUser) { - try { - setUser(JSON.parse(savedUser)); - } catch (e) { - console.error("Failed to parse saved user", e); - } - } + const persist = useCallback((u: User | null) => { + if (u) localStorage.setItem(STORAGE_KEY, JSON.stringify(u)); + else localStorage.removeItem(STORAGE_KEY); }, []); - const login = (username: string, password: string): boolean => { - const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB]; - - if (authEntry && authEntry.password === password) { - setUser(authEntry.user); - localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user)); - return true; + // Hydrate from cached user immediately, then verify against server. + useEffect(() => { + const cached = localStorage.getItem(STORAGE_KEY); + if (cached) { + try { + setUser(JSON.parse(cached)); + } catch { + localStorage.removeItem(STORAGE_KEY); + } } - return false; - }; + let cancelled = false; + (async () => { + try { + const current = await authApi.me(); + if (!cancelled) { + const u = toUser(current); + setUser(u); + persist(u); + } + } catch { + if (!cancelled) { + setUser(null); + persist(null); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); - const logout = () => { - setUser(null); - localStorage.removeItem("coffee-shop-user"); - }; - - const completeRegistration = (phone: string) => { - // Create new customer account - const newUser: User = { - id: Date.now(), - name: "Khách hàng", - role: "customer", - avatar: null, - phone, + return () => { + cancelled = true; }; + }, [persist]); - // Add to mock database (in real app, this would be API call) - (MOCK_AUTH_DB as any)[phone] = { - username: phone, - password: "user1", // Default password for new customers - user: newUser, - }; + const login = useCallback( + async (phone: string, password: string): Promise => { + setLastError(null); + try { + const customer = await authApi.login({ phone, password }); + const u = toUser(customer); + setUser(u); + persist(u); + return true; + } catch (e) { + setLastError(toErrorInfo(e)); + return false; + } + }, + [persist], + ); - setUser(newUser); - localStorage.setItem("coffee-shop-user", JSON.stringify(newUser)); - setRegisterPhone(null); - }; + const logout = useCallback(async () => { + try { + await authApi.logout(); + } finally { + setUser(null); + persist(null); + } + }, [persist]); + + const completeRegistration = useCallback( + async (phone: string): Promise => { + setLastError(null); + try { + const customer = await authApi.quickSignup({ phone }); + const u = toUser(customer); + setUser(u); + persist(u); + setRegisterPhone(null); + return true; + } catch (e) { + setLastError(toErrorInfo(e)); + return false; + } + }, + [persist], + ); return ( {children} diff --git a/lib/types.ts b/lib/types.ts index a1f858b..957d11f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -7,6 +7,34 @@ export interface User { role: UserRole; avatar: string | null; phone?: string; + apiId?: string; // backend UUID — set when user was authenticated via API +} + +// ===== AUTH API PAYLOADS (aligned with backend DTOs) ===== +export interface SignupPayload { + name: string; + phone: string; + password: string; +} + +export interface QuickSignupPayload { + phone: string; +} + +export interface LoginPayload { + phone: string; + password: string; +} + +export interface QuickLoginPayload { + phone: string; + otp: string; +} + +export interface ApiCustomer { + id: string; + name: string; + phone: string; } // ===== MENU TYPES ===== diff --git a/next.config.ts b/next.config.ts index 798d55e..90cc425 100644 --- a/next.config.ts +++ b/next.config.ts @@ -19,6 +19,16 @@ const nextConfig: NextConfig = { }, ], }, + async rewrites() { + const gateway = + process.env.GATEWAY_INTERNAL_URL ?? "http://localhost:3000"; + return [ + { + source: "/api/:path*", + destination: `${gateway}/api/:path*`, + }, + ]; + }, }; export default nextConfig;