Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
e693bcf4
Commit
e693bcf4
authored
Aug 29, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(auth): streamline login with Zalo SDK and Claymorphism UI
parent
29c7ecf9
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
199 additions
and
175 deletions
+199
-175
use-zalo-login.ts
src/hooks/use-zalo-login.ts
+72
-0
en.json
src/i18n/locales/en.json
+15
-1
vi.json
src/i18n/locales/vi.json
+15
-1
login.tsx
src/pages/auth/login.tsx
+89
-173
auth.service.ts
src/services/auth.service.ts
+8
-0
No files found.
src/hooks/use-zalo-login.ts
0 → 100644
View file @
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
};
}
src/i18n/locales/en.json
View file @
e693bcf4
...
...
@@ -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"
,
...
...
src/i18n/locales/vi.json
View file @
e693bcf4
...
...
@@ -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ý"
,
...
...
src/pages/auth/login.tsx
View file @
e693bcf4
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
>
{
/* 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
}
/>
{
/* 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
>
))
}
</
div
>
</
div
>
<
Input
label=
{
t
(
"auth.password"
)
}
placeholder=
"••••••••"
type=
{
isPasswordVisible
?
"text"
:
"password"
}
autoComplete=
"current-password"
error=
{
errors
.
password
?.
message
}
{
...
register
("
password
")}
disabled=
{
isLoading
}
endAdornment=
{
{
/* Bottom: Login button */
}
<
div
className=
"w-full max-w-sm flex flex-col gap-3"
>
<
button
id=
"btn-zalo-login"
type=
"button"
aria
-
label=
{
t
(
isPasswordVisible
?
"auth.login.hidePassword"
:
"auth.login.showPassword"
)
}
aria
-
pressed=
{
isPasswordVisible
}
onClick=
{
()
=>
setIsPasswordVisible
((
visible
)
=>
!
visible
)
}
onClick=
{
handleZaloLogin
}
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-6
0"
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-7
0"
>
{
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"
)
}
</
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
>
<
>
<
div
className=
"h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"
/
>
<
span
>
{
t
(
"auth.zalo.loggingIn
"
)
}
</
span
>
</
>
)
:
(
t
(
"auth.login.submit"
)
<>
<
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
>
</
form
>
</
Card
>
</
button
>
<
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
>
<
p
className=
"text-center font-nunito text-xs text-clay-text-muted px-4"
>
{
t
(
"auth.zalo.termsNote"
)
}
</
p
>
</
div
>
</
Page
>
);
};
export
default
LoginPage
;
src/services/auth.service.ts
View file @
e693bcf4
...
...
@@ -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
;
},
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment