Connect backend
This commit is contained in:
@@ -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ý"}
|
||||
</Button>
|
||||
|
||||
{/* 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
|
||||
</Button>
|
||||
|
||||
@@ -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"}
|
||||
</Button>
|
||||
|
||||
{/* Register Button */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+123
-118
@@ -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<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
registerPhone: string | null;
|
||||
setRegisterPhone: (phone: string | null) => void;
|
||||
completeRegistration: (phone: string) => void;
|
||||
completeRegistration: (phone: string) => Promise<boolean>;
|
||||
lastError: AuthErrorInfo | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(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<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registerPhone, setRegisterPhone] = useState<string | null>(null);
|
||||
const [lastError, setLastError] = useState<AuthErrorInfo | null>(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<boolean> => {
|
||||
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<boolean> => {
|
||||
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 (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
registerPhone,
|
||||
setRegisterPhone,
|
||||
completeRegistration,
|
||||
lastError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user