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
6b3dbdaf
Commit
6b3dbdaf
authored
Aug 11, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(auth): add password visibility and remember email
parent
24913d00
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
117 additions
and
25 deletions
+117
-25
Input.tsx
src/components/ui/Input.tsx
+30
-17
icons.tsx
src/components/ui/icons.tsx
+16
-0
login.tsx
src/pages/auth/login.tsx
+64
-6
auth.service.ts
src/services/auth.service.ts
+2
-2
auth.ts
src/types/auth.ts
+5
-0
No files found.
src/components/ui/Input.tsx
View file @
6b3dbdaf
...
@@ -3,11 +3,14 @@ import React from "react";
...
@@ -3,11 +3,14 @@ import React from "react";
export
interface
InputProps
extends
React
.
InputHTMLAttributes
<
HTMLInputElement
>
{
export
interface
InputProps
extends
React
.
InputHTMLAttributes
<
HTMLInputElement
>
{
label
?:
string
;
label
?:
string
;
error
?:
string
;
error
?:
string
;
endAdornment
?:
React
.
ReactNode
;
}
}
export
const
Input
=
React
.
forwardRef
<
HTMLInputElement
,
InputProps
>
(
export
const
Input
=
React
.
forwardRef
<
HTMLInputElement
,
InputProps
>
(
({
label
,
error
,
className
=
""
,
id
,
...
props
},
ref
)
=>
{
({
label
,
error
,
endAdornment
,
className
=
""
,
id
,
...
props
},
ref
)
=>
{
const
inputId
=
id
||
`input-
${
Math
.
random
().
toString
(
36
).
substr
(
2
,
9
)}
`
;
const
generatedId
=
React
.
useId
();
const
inputId
=
id
||
generatedId
;
const
errorId
=
`
${
inputId
}
-error`
;
return
(
return
(
<
div
className=
"flex flex-col gap-2 w-full"
>
<
div
className=
"flex flex-col gap-2 w-full"
>
...
@@ -16,22 +19,32 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
...
@@ -16,22 +19,32 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{
label
}
{
label
}
</
label
>
</
label
>
)
}
)
}
<
input
<
div
className=
"relative w-full"
>
id=
{
inputId
}
<
input
ref=
{
ref
}
id=
{
inputId
}
className=
{
`
ref=
{
ref
}
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
aria
-
invalid=
{
error
?
true
:
undefined
}
rounded-clay-sm shadow-clay-pressed border border-transparent
aria
-
describedby=
{
error
?
errorId
:
undefined
}
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65
className=
{
`
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
disabled:opacity-60 disabled:cursor-not-allowed
rounded-clay-sm shadow-clay-pressed border border-transparent
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
transition-all duration-200 ease-in-out placeholder-clay-text-muted/65
${className}
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
`
}
disabled:opacity-60 disabled:cursor-not-allowed
{
...
props
}
${endAdornment ? "pr-12" : ""}
/>
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`
}
{
...
props
}
/>
{
endAdornment
&&
(
<
div
className=
"absolute inset-y-0 right-3 flex items-center"
>
{
endAdornment
}
</
div
>
)
}
</
div
>
{
error
&&
(
{
error
&&
(
<
span
className=
"font-nunito text-xs text-clay-expense px-1"
>
<
span
id=
{
errorId
}
className=
"font-nunito text-xs text-clay-expense px-1"
>
{
error
}
{
error
}
</
span
>
</
span
>
)
}
)
}
...
...
src/components/ui/icons.tsx
View file @
6b3dbdaf
...
@@ -162,6 +162,22 @@ export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) =
...
@@ -162,6 +162,22 @@ export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) =
</
svg
>
</
svg
>
);
);
// Password visibility
export
const
EyeIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
20
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
{
...
props
}
>
<
path
d=
"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z"
/>
<
circle
cx=
"12"
cy=
"12"
r=
"3"
fill=
"url(#clay-grad-primary)"
/>
</
svg
>
);
export
const
EyeOffIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
20
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
{
...
props
}
>
<
path
d=
"M3 3l18 18"
/>
<
path
d=
"M10.6 6.2A10.8 10.8 0 0 1 12 6c6.5 0 10 6 10 6a16.2 16.2 0 0 1-2.1 2.8M6.2 6.2C3.5 8 2 12 2 12s3.5 6 10 6c1.8 0 3.4-.5 4.7-1.2"
/>
<
path
d=
"M9.9 9.9a3 3 0 0 0 4.2 4.2"
/>
</
svg
>
);
// Income Category Icon Example (Food, Shopping, etc.)
// Income Category Icon Example (Food, Shopping, etc.)
export
const
FoodIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
export
const
FoodIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
...
...
src/pages/auth/login.tsx
View file @
6b3dbdaf
...
@@ -8,11 +8,35 @@ import { Card } from "@/components/ui/Card";
...
@@ -8,11 +8,35 @@ import { Card } from "@/components/ui/Card";
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
authService
}
from
"@/services/auth.service"
;
import
{
authService
}
from
"@/services/auth.service"
;
import
{
useAuthStore
}
from
"@/stores/auth-store"
;
import
{
useAuthStore
}
from
"@/stores/auth-store"
;
import
{
IconGradients
}
from
"@/components/ui/icons"
;
import
{
EyeIcon
,
EyeOffIcon
,
IconGradients
}
from
"@/components/ui/icons"
;
import
{
getErrorMessage
}
from
"@/lib/error-message"
;
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
loginSchema
=
z
.
object
({
const
loginSchema
=
z
.
object
({
email
:
z
.
string
().
min
(
1
,
"Email không được để trống"
).
email
(
"Email không hợp lệ"
),
email
:
z
.
string
().
min
(
1
,
"Email không được để trống"
).
email
(
"Email không hợp lệ"
),
password
:
z
.
string
().
min
(
8
,
"Mật khẩu phải từ 8 ký tự trở lên"
),
password
:
z
.
string
().
min
(
8
,
"Mật khẩu phải từ 8 ký tự trở lên"
),
rememberMe
:
z
.
boolean
(),
});
});
type
LoginFormValues
=
z
.
infer
<
typeof
loginSchema
>
;
type
LoginFormValues
=
z
.
infer
<
typeof
loginSchema
>
;
...
@@ -22,6 +46,8 @@ const LoginPage: React.FC = () => {
...
@@ -22,6 +46,8 @@ const LoginPage: React.FC = () => {
const
{
openSnackbar
}
=
useSnackbar
();
const
{
openSnackbar
}
=
useSnackbar
();
const
setAuth
=
useAuthStore
((
state
)
=>
state
.
setAuth
);
const
setAuth
=
useAuthStore
((
state
)
=>
state
.
setAuth
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
false
);
const
[
isLoading
,
setIsLoading
]
=
useState
(
false
);
const
[
isPasswordVisible
,
setIsPasswordVisible
]
=
useState
(
false
);
const
[
rememberedEmail
]
=
useState
(
getRememberedEmail
);
const
{
const
{
register
,
register
,
...
@@ -29,13 +55,22 @@ const LoginPage: React.FC = () => {
...
@@ -29,13 +55,22 @@ const LoginPage: React.FC = () => {
formState
:
{
errors
},
formState
:
{
errors
},
}
=
useForm
<
LoginFormValues
>
({
}
=
useForm
<
LoginFormValues
>
({
resolver
:
zodResolver
(
loginSchema
),
resolver
:
zodResolver
(
loginSchema
),
defaultValues
:
{
email
:
rememberedEmail
,
password
:
""
,
rememberMe
:
Boolean
(
rememberedEmail
),
},
});
});
const
onSubmit
=
async
(
values
:
LoginFormValues
)
=>
{
const
onSubmit
=
async
(
values
:
LoginFormValues
)
=>
{
setIsLoading
(
true
);
setIsLoading
(
true
);
try
{
try
{
const
response
=
await
authService
.
login
(
values
);
const
response
=
await
authService
.
login
({
email
:
values
.
email
,
password
:
values
.
password
,
});
if
(
response
.
success
&&
response
.
data
)
{
if
(
response
.
success
&&
response
.
data
)
{
updateRememberedEmail
(
values
.
email
,
values
.
rememberMe
);
setAuth
(
response
.
data
.
user
,
""
,
""
);
setAuth
(
response
.
data
.
user
,
""
,
""
);
openSnackbar
({
openSnackbar
({
type
:
"success"
,
type
:
"success"
,
...
@@ -48,10 +83,10 @@ const LoginPage: React.FC = () => {
...
@@ -48,10 +83,10 @@ const LoginPage: React.FC = () => {
text
:
response
.
message
||
"Đăng nhập thất bại"
,
text
:
response
.
message
||
"Đăng nhập thất bại"
,
});
});
}
}
}
catch
(
error
:
any
)
{
}
catch
(
error
:
unknown
)
{
openSnackbar
({
openSnackbar
({
type
:
"error"
,
type
:
"error"
,
text
:
error
.
response
?.
data
?.
message
||
"Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."
,
text
:
getErrorMessage
(
error
,
"Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."
)
,
});
});
}
finally
{
}
finally
{
setIsLoading
(
false
);
setIsLoading
(
false
);
...
@@ -75,6 +110,7 @@ const LoginPage: React.FC = () => {
...
@@ -75,6 +110,7 @@ const LoginPage: React.FC = () => {
label=
"Email"
label=
"Email"
placeholder=
"user@gmail.com"
placeholder=
"user@gmail.com"
type=
"email"
type=
"email"
autoComplete=
"email"
error=
{
errors
.
email
?.
message
}
error=
{
errors
.
email
?.
message
}
{
...
register
("
email
")}
{
...
register
("
email
")}
disabled=
{
isLoading
}
disabled=
{
isLoading
}
...
@@ -83,13 +119,35 @@ const LoginPage: React.FC = () => {
...
@@ -83,13 +119,35 @@ const LoginPage: React.FC = () => {
<
Input
<
Input
label=
"Mật khẩu"
label=
"Mật khẩu"
placeholder=
"••••••••"
placeholder=
"••••••••"
type=
"password"
type=
{
isPasswordVisible
?
"text"
:
"password"
}
autoComplete=
"current-password"
error=
{
errors
.
password
?.
message
}
error=
{
errors
.
password
?.
message
}
{
...
register
("
password
")}
{
...
register
("
password
")}
disabled=
{
isLoading
}
disabled=
{
isLoading
}
endAdornment=
{
<
button
type=
"button"
aria
-
label=
{
isPasswordVisible
?
"Ẩn mật khẩu"
:
"Hiện mật khẩu"
}
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=
"text-right"
>
<
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
>
Ghi nhớ email
</
span
>
</
label
>
<
span
<
span
onClick=
{
()
=>
navigate
(
"/forgot-password"
)
}
onClick=
{
()
=>
navigate
(
"/forgot-password"
)
}
className=
"font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
className=
"font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
...
...
src/services/auth.service.ts
View file @
6b3dbdaf
import
{
apiClient
}
from
"@/lib/api-client"
;
import
{
apiClient
}
from
"@/lib/api-client"
;
import
{
ApiResponse
,
User
,
Session
}
from
"@/types/auth"
;
import
{
ApiResponse
,
LoginRequest
,
User
,
Session
}
from
"@/types/auth"
;
export
const
authService
=
{
export
const
authService
=
{
async
register
(
data
:
any
):
Promise
<
ApiResponse
>
{
async
register
(
data
:
any
):
Promise
<
ApiResponse
>
{
...
@@ -12,7 +12,7 @@ export const authService = {
...
@@ -12,7 +12,7 @@ export const authService = {
return
response
.
data
;
return
response
.
data
;
},
},
async
login
(
data
:
any
):
Promise
<
ApiResponse
<
{
user
:
User
}
>>
{
async
login
(
data
:
LoginRequest
):
Promise
<
ApiResponse
<
{
user
:
User
}
>>
{
const
response
=
await
apiClient
.
post
(
"/auth/login"
,
data
);
const
response
=
await
apiClient
.
post
(
"/auth/login"
,
data
);
return
response
.
data
;
return
response
.
data
;
},
},
...
...
src/types/auth.ts
View file @
6b3dbdaf
...
@@ -21,6 +21,11 @@ export interface TokenPair {
...
@@ -21,6 +21,11 @@ export interface TokenPair {
refreshToken
:
string
;
refreshToken
:
string
;
}
}
export
interface
LoginRequest
{
email
:
string
;
password
:
string
;
}
export
interface
Session
{
export
interface
Session
{
id
:
string
;
id
:
string
;
deviceName
:
string
;
deviceName
:
string
;
...
...
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