Connect backend

This commit is contained in:
Thanh Quy - wolf
2026-04-19 17:04:07 +07:00
parent dae381d182
commit 37a6ed58c9
9 changed files with 363 additions and 137 deletions
+29 -5
View File
@@ -13,13 +13,27 @@ const DEMO_OTP = "123456";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
const { completeRegistration } = useAuth(); const { completeRegistration, lastError } = useAuth();
const [step, setStep] = useState<"phone" | "otp">("phone"); const [step, setStep] = useState<"phone" | "otp">("phone");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({ phone: "", otp: "" }); 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 // Validate Vietnamese phone number
const validatePhone = (phoneNumber: string): boolean => { const validatePhone = (phoneNumber: string): boolean => {
// Vietnamese phone format: 10 digits starting with 0 // Vietnamese phone format: 10 digits starting with 0
@@ -49,7 +63,7 @@ export default function RegisterPage() {
setErrors({ phone: "", otp: "" }); setErrors({ phone: "", otp: "" });
}; };
const handleOtpSubmit = (e: FormEvent) => { const handleOtpSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!otp.trim()) { if (!otp.trim()) {
@@ -62,9 +76,17 @@ export default function RegisterPage() {
return; return;
} }
// Complete registration setSubmitting(true);
completeRegistration(phone); try {
const success = await completeRegistration(phone);
if (success) {
router.push("/"); router.push("/");
} else {
setErrors({ ...errors, otp: mapApiError(lastError) });
}
} finally {
setSubmitting(false);
}
}; };
const handleBackToPhone = () => { const handleBackToPhone = () => {
@@ -237,8 +259,9 @@ export default function RegisterPage() {
type="submit" type="submit"
style="login" style="login"
size="lg" size="lg"
disabled={submitting}
> >
Hoàn tất đăng {submitting ? "Đang đăng ký..." : "Hoàn tất đăng ký"}
</Button> </Button>
{/* Back Button */} {/* Back Button */}
@@ -247,6 +270,7 @@ export default function RegisterPage() {
onClick={handleBackToPhone} onClick={handleBackToPhone}
size="lg" size="lg"
style="login" style="login"
disabled={submitting}
> >
Thay đi số điện thoại Thay đi số điện thoại
</Button> </Button>
+28 -10
View File
@@ -8,10 +8,11 @@ import { FormEvent, useState } from "react";
export default function LoginForm() { export default function LoginForm() {
const router = useRouter(); const router = useRouter();
const { login } = useAuth(); const { login, lastError } = useAuth();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
username: "", username: "",
password: "", password: "",
@@ -39,21 +40,37 @@ export default function LoginForm() {
return isValid; 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(); e.preventDefault();
if (!validate()) return; if (!validate()) return;
const success = login(username, password); setSubmitting(true);
try {
const success = await login(username, password);
if (success) { if (success) {
router.push("/"); router.push("/");
} else { } else {
setErrors({ setErrors((prev) => ({
username: "", ...prev,
password: "", general: mapApiError(lastError),
general: "Tên đăng nhập hoặc mật khẩu không đúng", }));
}); }
} finally {
setSubmitting(false);
} }
}; };
@@ -107,8 +124,9 @@ export default function LoginForm() {
type="submit" type="submit"
style="login" style="login"
size="lg" size="lg"
disabled={submitting}
> >
Đăng nhập {submitting ? "Đang đăng nhập..." : "Đăng nhập"}
</Button> </Button>
{/* Register Button */} {/* Register Button */}
+21
View File
@@ -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;
}
}
+12
View File
@@ -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"
+53
View File
@@ -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<ApiCustomer>(`${CUSTOMER_BASE}/signup`, {
method: "POST",
body: payload,
});
}
export function quickSignup(payload: QuickSignupPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/quick_signup`, {
method: "POST",
body: payload,
});
}
export function login(payload: LoginPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/login`, {
method: "POST",
body: payload,
});
}
export function quickLogin(payload: QuickLoginPayload) {
return apiRequest<ApiCustomer>(`${CUSTOMER_BASE}/quick_login`, {
method: "POST",
body: payload,
});
}
export function me() {
return apiRequest<ApiCustomer>(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();
}
+55
View File
@@ -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<string, string>;
signal?: AbortSignal;
}
export async function apiRequest<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
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;
}
}
+119 -114
View File
@@ -3,163 +3,168 @@
import { import {
ReactNode, ReactNode,
createContext, createContext,
useCallback,
useContext, useContext,
useEffect, useEffect,
useState, useState,
} from "react"; } 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 { interface AuthContextType {
user: User | null; user: User | null;
login: (username: string, password: string) => boolean; loading: boolean;
logout: () => void; login: (phone: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
registerPhone: string | null; registerPhone: string | null;
setRegisterPhone: (phone: string | null) => void; setRegisterPhone: (phone: string | null) => void;
completeRegistration: (phone: string) => void; completeRegistration: (phone: string) => Promise<boolean>;
lastError: AuthErrorInfo | null;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Mock user database const STORAGE_KEY = "coffee-shop-user";
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",
},
},
// Staff (username and password are their names) // Stable numeric id derived from UUID string — used because local
"Nguyễn Văn An": { // User.id is typed as number (staff module uses numeric ids).
username: "Nguyễn Văn An", // Backend never reads this back; identity on the server side is cookie-based.
password: "Nguyễn Văn An", function hashUuidToNumber(uuid: string): number {
user: { let h = 0;
id: 2, for (let i = 0; i < uuid.length; i++) {
name: "Nguyễn Văn An", h = (h * 31 + uuid.charCodeAt(i)) | 0;
role: "staff" as const, }
avatar: null, return Math.abs(h);
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",
},
},
// Customers (username is phone number, password is custom) function toUser(c: ApiCustomer): User {
"0987654321": { return {
username: "0987654321", id: hashUuidToNumber(c.id),
password: "user1", apiId: c.id,
user: { name: c.name,
id: 5, role: "customer",
name: "Khách hàng",
role: "customer" as const,
avatar: null, avatar: null,
phone: "0987654321", phone: c.phone,
},
},
"0976543210": {
username: "0976543210",
password: "user1",
user: {
id: 6,
name: "Khách hàng",
role: "customer" as const,
avatar: null,
phone: "0976543210",
},
},
}; };
}
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 }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [registerPhone, setRegisterPhone] = useState<string | null>(null); const [registerPhone, setRegisterPhone] = useState<string | null>(null);
const [lastError, setLastError] = useState<AuthErrorInfo | null>(null);
// Load user from localStorage on mount const persist = useCallback((u: User | null) => {
useEffect(() => { if (u) localStorage.setItem(STORAGE_KEY, JSON.stringify(u));
const savedUser = localStorage.getItem("coffee-shop-user"); else localStorage.removeItem(STORAGE_KEY);
if (savedUser) {
try {
setUser(JSON.parse(savedUser));
} catch (e) {
console.error("Failed to parse saved user", e);
}
}
}, []); }, []);
const login = (username: string, password: string): boolean => { // Hydrate from cached user immediately, then verify against server.
const authEntry = MOCK_AUTH_DB[username as keyof typeof MOCK_AUTH_DB]; useEffect(() => {
const cached = localStorage.getItem(STORAGE_KEY);
if (authEntry && authEntry.password === password) { if (cached) {
setUser(authEntry.user); try {
localStorage.setItem("coffee-shop-user", JSON.stringify(authEntry.user)); setUser(JSON.parse(cached));
return true; } catch {
localStorage.removeItem(STORAGE_KEY);
}
} }
return false; let cancelled = false;
}; (async () => {
try {
const logout = () => { const current = await authApi.me();
if (!cancelled) {
const u = toUser(current);
setUser(u);
persist(u);
}
} catch {
if (!cancelled) {
setUser(null); setUser(null);
localStorage.removeItem("coffee-shop-user"); persist(null);
}; }
} finally {
if (!cancelled) setLoading(false);
}
})();
const completeRegistration = (phone: string) => { return () => {
// Create new customer account cancelled = true;
const newUser: User = {
id: Date.now(),
name: "Khách hàng",
role: "customer",
avatar: null,
phone,
}; };
}, [persist]);
// Add to mock database (in real app, this would be API call) const login = useCallback(
(MOCK_AUTH_DB as any)[phone] = { async (phone: string, password: string): Promise<boolean> => {
username: phone, setLastError(null);
password: "user1", // Default password for new customers try {
user: newUser, 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); const logout = useCallback(async () => {
localStorage.setItem("coffee-shop-user", JSON.stringify(newUser)); try {
await authApi.logout();
} finally {
setUser(null);
persist(null);
}
}, [persist]);
const completeRegistration = useCallback(
async (phone: string): Promise<boolean> => {
setLastError(null);
try {
const customer = await authApi.quickSignup({ phone });
const u = toUser(customer);
setUser(u);
persist(u);
setRegisterPhone(null); setRegisterPhone(null);
}; return true;
} catch (e) {
setLastError(toErrorInfo(e));
return false;
}
},
[persist],
);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
loading,
login, login,
logout, logout,
registerPhone, registerPhone,
setRegisterPhone, setRegisterPhone,
completeRegistration, completeRegistration,
lastError,
}} }}
> >
{children} {children}
+28
View File
@@ -7,6 +7,34 @@ export interface User {
role: UserRole; role: UserRole;
avatar: string | null; avatar: string | null;
phone?: string; 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 ===== // ===== MENU TYPES =====
+10
View File
@@ -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; export default nextConfig;