Commit 9a313fcf authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/zalo-phone-auth' into 'develop'

feat(auth): streamline login with Zalo SDK and Claymorphism UI

See merge request !32
parents 653c0d50 e693bcf4
import { useState } from "react";
import { getAccessToken, getPhoneNumber } from "zmp-sdk";
import { useNavigate, useSnackbar } from "zmp-ui";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { getErrorMessage } from "@/lib/error-message";
import { useI18n } from "@/i18n";
interface UseZaloLoginReturn {
handleZaloLogin: () => Promise<void>;
isLoading: boolean;
}
export function useZaloLogin(): UseZaloLoginReturn {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const { t } = useI18n();
const handleZaloLogin = async () => {
setIsLoading(true);
try {
// 1. Lấy Zalo access token (string trực tiếp)
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error(t("auth.zalo.tokenFailed"));
}
// 2. Lấy số điện thoại từ Zalo SDK
// `number` là field deprecated nhưng trả về SĐT thực trên Mini App
// `token` là flow mới: cần gửi lên server Zalo để đổi lấy SĐT
const phoneResult = await getPhoneNumber();
const phoneNumber = phoneResult.number;
if (!phoneNumber) {
openSnackbar({
type: "warning",
text: t("auth.zalo.phoneDenied"),
});
return;
}
// 3. Gửi lên backend để xác thực + đăng nhập/tạo tài khoản
const response = await authService.loginWithZalo({ accessToken, phoneNumber });
if (response.success && response.data) {
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.zalo.loginSuccess"),
});
navigate("/", { replace: true });
} else {
throw new Error(t("auth.zalo.loginFailed"));
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("auth.zalo.loginFailed")),
});
} finally {
setIsLoading(false);
}
};
return { handleZaloLogin, isLoading };
}
......@@ -160,7 +160,21 @@
"registerNow": "Sign up now",
"success": "Signed in successfully!",
"failed": "Sign-in failed",
"failedDetail": "Sign-in failed. Please check your details and try again."
"failedDetail": "Sign-in failed. Please check your details and try again.",
"feature": {
"wallet": "Manage Wallets",
"report": "Reports",
"ai": "AI Assistant"
}
},
"zalo": {
"loginButton": "Sign in with Zalo",
"loggingIn": "Signing in...",
"loginSuccess": "Signed in successfully!",
"loginFailed": "Sign-in failed, please try again.",
"tokenFailed": "Could not retrieve Zalo info, please try again.",
"phoneDenied": "Phone number permission is required to sign in.",
"termsNote": "By signing in, you agree to FinWise's terms of service."
},
"register": {
"header": "Sign Up",
......
......@@ -160,7 +160,21 @@
"registerNow": "Đăng ký ngay",
"success": "Đăng nhập thành công! 🎉",
"failed": "Đăng nhập thất bại",
"failedDetail": "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."
"failedDetail": "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin.",
"feature": {
"wallet": "Quản lý ví",
"report": "Báo cáo",
"ai": "Trợ lý AI"
}
},
"zalo": {
"loginButton": "Đăng nhập bằng Zalo",
"loggingIn": "Đang đăng nhập...",
"loginSuccess": "Đăng nhập thành công!",
"loginFailed": "Đăng nhập thất bại, vui lòng thử lại.",
"tokenFailed": "Không thể lấy thông tin Zalo, vui lòng thử lại.",
"phoneDenied": "Bạn cần cấp quyền số điện thoại để đăng nhập.",
"termsNote": "Bằng cách đăng nhập, bạn đồng ý với điều khoản sử dụng của FinWise."
},
"register": {
"header": "Đăng Ký",
......
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message";
import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const getRememberedEmail = (): string => {
try {
return localStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
} catch {
return "";
}
};
const updateRememberedEmail = (email: string, shouldRemember: boolean): void => {
try {
if (shouldRemember) {
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
} else {
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Storage can be unavailable in restricted webviews; login should still succeed.
}
};
const createLoginSchema = (t: TranslationFunction) => z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.emailInvalid")),
password: z.string().min(8, t("validation.passwordMin")),
rememberMe: z.boolean(),
});
type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
import React from "react";
import { Page } from "zmp-ui";
import {
IconGradients,
WalletIcon,
ReportIcon,
AIAssistantIcon,
} from "@/components/ui/icons";
import { useZaloLogin } from "@/hooks/use-zalo-login";
import { useI18n } from "@/i18n";
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const [isLoading, setIsLoading] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
const { handleZaloLogin, isLoading } = useZaloLogin();
const { t } = useI18n();
const loginSchema = useMemo(() => createLoginSchema(t), [t]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: rememberedEmail,
password: "",
rememberMe: Boolean(rememberedEmail),
const features = [
{
Icon: WalletIcon,
label: t("auth.login.feature.wallet"),
},
});
const onSubmit = async (values: LoginFormValues) => {
setIsLoading(true);
try {
const response = await authService.login({
email: values.email,
password: values.password,
});
if (response.success && response.data) {
updateRememberedEmail(values.email, values.rememberMe);
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.login.success"),
});
navigate("/", { replace: true });
} else {
openSnackbar({
type: "error",
text: t("auth.login.failed"),
});
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("auth.login.failedDetail")),
});
} finally {
setIsLoading(false);
}
};
{
Icon: ReportIcon,
label: t("auth.login.feature.report"),
},
{
Icon: AIAssistantIcon,
label: t("auth.login.feature.ai"),
},
];
return (
<Page className="page flex flex-col justify-center py-12">
<Header title={t("auth.login.header")} showBackIcon={false} />
<Page className="page flex flex-col items-center justify-between py-12 px-6">
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">{t("auth.login.title")}</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p>
{/* Top: Logo + branding */}
<div className="flex-1 flex flex-col items-center justify-center gap-8 w-full max-w-sm">
<div className="flex flex-col items-center gap-4">
{/* App icon with Claymorphism */}
<div className="flex h-20 w-20 items-center justify-center rounded-clay-lg bg-clay-primary text-clay-on-primary shadow-clay-hover border border-clay-highlight/30">
<WalletIcon size={40} />
</div>
<div className="text-center space-y-1">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p>
</div>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label={t("auth.email")}
placeholder="user@gmail.com"
type="email"
autoComplete="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
label={t("auth.password")}
placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
endAdornment={
<button
type="button"
aria-label={t(isPasswordVisible ? "auth.login.hidePassword" : "auth.login.showPassword")}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
<div className="flex items-center justify-between gap-3 px-1">
<label className="flex cursor-pointer items-center gap-2 font-nunito text-xs font-semibold text-clay-text-muted">
<input
type="checkbox"
{...register("rememberMe")}
disabled={isLoading}
className="h-4 w-4 cursor-pointer rounded border-clay-text-muted accent-clay-primary transition-all duration-200 ease-in-out focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>{t("auth.login.rememberEmail")}</span>
</label>
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
>
{t("auth.login.forgotPassword")}
{/* Feature highlights with Claymorphic icons */}
<div className="w-full grid grid-cols-3 gap-3">
{features.map(({ Icon, label }) => (
<div
key={label}
className="flex flex-col items-center gap-2 rounded-clay border border-clay-highlight/40 bg-clay-surface p-3 text-center shadow-clay-raised backdrop-blur-sm transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:-translate-y-0.5"
>
<div className="flex h-10 w-10 items-center justify-center">
<Icon size={28} />
</div>
<span className="font-nunito text-xs font-semibold text-clay-text-muted leading-tight">
{label}
</span>
</div>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>{t("auth.login.submitting")}</span>
</div>
) : (
t("auth.login.submit")
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
{t("auth.login.registerNow")}
</span>
))}
</div>
</div>
{/* Bottom: Login button */}
<div className="w-full max-w-sm flex flex-col gap-3">
<button
id="btn-zalo-login"
type="button"
onClick={handleZaloLogin}
disabled={isLoading}
className="relative flex w-full items-center justify-center gap-3 rounded-clay bg-[#0068FF] px-6 py-4 font-nunito text-base font-bold text-white shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-70"
>
{isLoading ? (
<>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>{t("auth.zalo.loggingIn")}</span>
</>
) : (
<>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="24" height="24" rx="6" fill="white" />
<text x="3" y="18" fontSize="14" fontWeight="bold" fill="#0068FF">
Z
</text>
</svg>
<span>{t("auth.zalo.loginButton")}</span>
</>
)}
</button>
<p className="text-center font-nunito text-xs text-clay-text-muted px-4">
{t("auth.zalo.termsNote")}
</p>
</div>
</Page>
);
};
export default LoginPage;
......@@ -71,4 +71,12 @@ export const authService = {
const response = await apiClient.delete("/auth/sessions");
return response.data;
},
async loginWithZalo(data: {
accessToken: string;
phoneNumber: string;
}): Promise<ApiResponse<{ user: User; accessToken?: string; refreshToken?: string }>> {
const response = await apiClient.post("/auth/zalo-login", data);
return response.data;
},
};
Markdown is supported
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