Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
frontend-data-crawler-be
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
BangNSK
frontend-data-crawler-be
Commits
24bab604
Commit
24bab604
authored
Jul 28, 2026
by
BangNSK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: use cookie auth and stream export downloads
parent
b465708d
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
80 additions
and
115 deletions
+80
-115
AuthContext.tsx
src/context/AuthContext.tsx
+36
-23
auth.ts
src/context/auth.ts
+6
-1
JobDetail.tsx
src/pages/JobDetail.tsx
+7
-32
Profile.tsx
src/pages/Profile.tsx
+9
-2
api.ts
src/services/api.ts
+22
-57
No files found.
src/context/AuthContext.tsx
View file @
24bab604
...
...
@@ -7,41 +7,36 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const
[
loading
,
setLoading
]
=
useState
(
true
);
useEffect
(()
=>
{
const
handleAuthExpired
=
()
=>
setUser
(
null
);
window
.
addEventListener
(
'auth:expired'
,
handleAuthExpired
);
const
initAuth
=
async
()
=>
{
const
accessToken
=
localStorage
.
getItem
(
'accessToken'
);
if
(
accessToken
)
{
try
{
const
response
=
await
api
.
get
(
'/auth/me'
);
if
(
response
.
data
.
success
)
{
setUser
(
response
.
data
.
data
);
}
}
catch
(
error
)
{
console
.
error
(
'Failed to fetch user data'
,
error
);
// Token might be expired, api interceptor should handle refresh.
// If both fail, it will redirect/logout anyway.
try
{
const
response
=
await
api
.
get
(
'/auth/me'
);
if
(
response
.
data
.
success
)
{
setUser
(
response
.
data
.
data
);
}
}
catch
{
setUser
(
null
);
}
finally
{
setLoading
(
false
);
}
setLoading
(
false
);
};
initAuth
();
void
initAuth
();
return
()
=>
window
.
removeEventListener
(
'auth:expired'
,
handleAuthExpired
);
},
[]);
const
login
=
async
(
email
:
string
,
password
:
string
)
=>
{
const
response
=
await
api
.
post
(
'/auth/login'
,
{
email
,
password
});
if
(
response
.
data
.
success
)
{
const
{
accessToken
,
refreshToken
,
user
}
=
response
.
data
.
data
;
localStorage
.
setItem
(
'accessToken'
,
accessToken
);
localStorage
.
setItem
(
'refreshToken'
,
refreshToken
);
setUser
(
user
);
setUser
(
response
.
data
.
data
.
user
);
}
};
const
logout
=
async
()
=>
{
try
{
const
refreshToken
=
localStorage
.
getItem
(
'refreshToken'
);
if
(
refreshToken
)
{
await
api
.
post
(
'/auth/logout'
,
{
refreshToken
});
}
await
api
.
post
(
'/auth/logout'
,
{});
}
catch
(
error
)
{
console
.
error
(
'Logout error'
,
error
);
}
finally
{
...
...
@@ -55,16 +50,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await
api
.
post
(
'/auth/register'
,
{
email
,
password
,
fullName
:
fullName
?.
trim
()
||
undefined
});
};
const
updateProfile
=
async
(
data
:
{
fullName
?:
string
;
oldPassword
?:
string
;
password
?:
string
})
=>
{
const
updateProfile
=
async
(
data
:
{
fullName
?:
string
})
=>
{
const
response
=
await
api
.
put
(
'/auth/me'
,
data
);
if
(
response
.
data
.
success
)
setUser
(
response
.
data
.
data
);
};
const
changePassword
=
async
(
data
:
{
currentPassword
:
string
;
newPassword
:
string
;
confirmPassword
:
string
;
})
=>
{
await
api
.
post
(
'/auth/change-password'
,
data
);
};
const
isAdmin
=
user
?.
role
===
'ADMIN'
;
const
isCrawler
=
user
?.
role
===
'ADMIN'
||
user
?.
role
===
'CRAWLER_USER'
;
return
(
<
AuthContext
.
Provider
value=
{
{
user
,
loading
,
login
,
logout
,
register
,
updateProfile
,
isAdmin
,
isCrawler
}
}
>
<
AuthContext
.
Provider
value=
{
{
user
,
loading
,
login
,
logout
,
register
,
updateProfile
,
changePassword
,
isAdmin
,
isCrawler
,
}
}
>
{
children
}
</
AuthContext
.
Provider
>
);
...
...
src/context/auth.ts
View file @
24bab604
...
...
@@ -17,7 +17,12 @@ export interface AuthContextType {
login
:
(
email
:
string
,
password
:
string
)
=>
Promise
<
void
>
;
logout
:
()
=>
Promise
<
void
>
;
register
:
(
email
:
string
,
password
:
string
,
fullName
?:
string
)
=>
Promise
<
void
>
;
updateProfile
:
(
data
:
{
fullName
?:
string
;
oldPassword
?:
string
;
password
?:
string
})
=>
Promise
<
void
>
;
updateProfile
:
(
data
:
{
fullName
?:
string
})
=>
Promise
<
void
>
;
changePassword
:
(
data
:
{
currentPassword
:
string
;
newPassword
:
string
;
confirmPassword
:
string
;
})
=>
Promise
<
void
>
;
isAdmin
:
boolean
;
isCrawler
:
boolean
;
}
...
...
src/pages/JobDetail.tsx
View file @
24bab604
import
React
,
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
useParams
,
Link
}
from
'react-router-dom'
;
import
{
api
}
from
'../services/api'
;
import
{
api
,
getApiUrl
}
from
'../services/api'
;
import
{
useAuth
}
from
'../context/auth'
;
import
{
ArrowLeft
,
...
...
@@ -287,34 +287,6 @@ export const JobDetail: React.FC = () => {
}
};
const
handleDownload
=
async
(
exportId
:
string
,
fileName
:
string
,
_mimeType
:
string
)
=>
{
try
{
const
res
=
await
api
.
get
(
`/exports/
${
exportId
}
/download`
,
{
responseType
:
'blob'
});
const
blob
=
res
.
data
;
const
url
=
window
.
URL
.
createObjectURL
(
blob
);
const
link
=
document
.
createElement
(
'a'
);
link
.
href
=
url
;
link
.
setAttribute
(
'download'
,
fileName
);
document
.
body
.
appendChild
(
link
);
link
.
click
();
link
.
remove
();
window
.
URL
.
revokeObjectURL
(
url
);
}
catch
(
e
:
any
)
{
console
.
error
(
'Failed to download export file:'
,
e
);
if
(
e
.
response
?.
data
instanceof
Blob
)
{
const
text
=
await
e
.
response
.
data
.
text
();
try
{
const
json
=
JSON
.
parse
(
text
);
alert
(
json
.
message
||
'Tải file thất bại.'
);
}
catch
{
alert
(
'Tải file thất bại.'
);
}
}
else
{
alert
(
e
.
response
?.
data
?.
message
||
'Tải file thất bại.'
);
}
}
};
const
copyToClipboard
=
(
text
:
string
)
=>
{
void
navigator
.
clipboard
.
writeText
(
text
);
setCopied
(
true
);
...
...
@@ -547,13 +519,16 @@ export const JobDetail: React.FC = () => {
</
div
>
</
div
>
{
exp
.
status
===
'COMPLETED'
?
(
<
button
onClick=
{
()
=>
handleDownload
(
exp
.
id
,
exp
.
fileName
,
exp
.
mimeType
||
'application/octet-stream'
)
}
<
a
href=
{
getApiUrl
(
`/exports/${encodeURIComponent(exp.id)}/download`
)
}
download=
{
exp
.
fileName
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 hover:bg-emerald-100 text-xs font-semibold transition-colors border border-emerald-100"
>
<
Download
className=
"h-3.5 w-3.5"
/>
Tải xuống
</
button
>
</
a
>
)
:
(
<
span
className=
"text-xs text-slate-400 capitalize"
>
{
exp
.
status
.
toLowerCase
()
}
</
span
>
)
}
...
...
src/pages/Profile.tsx
View file @
24bab604
...
...
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import
{
Shield
,
Mail
,
UserCheck
,
Loader2
,
Save
,
KeyRound
}
from
'lucide-react'
;
export
const
Profile
:
React
.
FC
=
()
=>
{
const
{
user
,
updateProfile
}
=
useAuth
();
const
{
user
,
updateProfile
,
changePassword
}
=
useAuth
();
const
[
fullName
,
setFullName
]
=
useState
(
user
?.
fullName
||
''
);
const
[
oldPassword
,
setOldPassword
]
=
useState
(
''
);
const
[
password
,
setPassword
]
=
useState
(
''
);
...
...
@@ -20,7 +20,14 @@ export const Profile: React.FC = () => {
if
(
password
&&
password
!==
confirmPassword
)
return
setError
(
'Mật khẩu xác nhận không khớp.'
);
setSubmitting
(
true
);
try
{
await
updateProfile
({
fullName
:
fullName
.
trim
(),
...(
password
?
{
oldPassword
,
password
}
:
{})
});
if
(
password
)
{
await
changePassword
({
currentPassword
:
oldPassword
,
newPassword
:
password
,
confirmPassword
,
});
}
await
updateProfile
({
fullName
:
fullName
.
trim
()
});
setOldPassword
(
''
);
setPassword
(
''
);
setConfirmPassword
(
''
);
setMessage
(
'Cập nhật hồ sơ thành công.'
);
}
catch
(
err
:
any
)
{
...
...
src/services/api.ts
View file @
24bab604
import
axios
from
'axios'
;
const
API_BASE_URL
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
||
'http://171.247.68.96:4011/api/v1'
)
export
const
API_BASE_URL
=
(
import
.
meta
.
env
.
VITE_API_BASE_URL
||
'http://171.247.68.96:4011/api/v1'
)
.
replace
(
/
\/
$/
,
''
);
export
const
api
=
axios
.
create
({
baseURL
:
API_BASE_URL
,
withCredentials
:
true
,
headers
:
{
'Content-Type'
:
'application/json'
,
},
});
// Flag to prevent multiple concurrent token refresh requests
let
isRefreshing
=
false
;
let
failedQueue
:
Array
<
{
resolve
:
(
value
:
unknown
)
=>
void
;
resolve
:
(
value
?
:
unknown
)
=>
void
;
reject
:
(
error
:
unknown
)
=>
void
;
}
>
=
[];
const
processQueue
=
(
error
:
any
,
token
:
string
|
null
=
null
)
=>
{
const
processQueue
=
(
error
?:
unknown
)
=>
{
failedQueue
.
forEach
((
prom
)
=>
{
if
(
error
)
{
prom
.
reject
(
error
);
}
else
{
prom
.
resolve
(
token
);
prom
.
resolve
();
}
});
failedQueue
=
[];
};
// Request Interceptor: Attach access token
api
.
interceptors
.
request
.
use
(
(
config
)
=>
{
const
token
=
localStorage
.
getItem
(
'accessToken'
);
if
(
token
&&
config
.
headers
)
{
config
.
headers
.
Authorization
=
`Bearer
${
token
}
`
;
}
return
config
;
},
(
error
)
=>
Promise
.
reject
(
error
)
);
// Response Interceptor: Handle auto token refresh on 401
api
.
interceptors
.
response
.
use
(
(
response
)
=>
response
,
async
(
error
)
=>
{
const
originalRequest
=
error
.
config
;
// Avoid infinite loop if auth requests fail (like /auth/login or /auth/refresh itself)
if
(
originalRequest
.
url
?.
includes
(
'/auth/login'
)
||
originalRequest
.
url
?.
includes
(
'/auth/refresh'
))
{
if
(
originalRequest
.
url
?.
includes
(
'/auth/login'
)
||
originalRequest
.
url
?.
includes
(
'/auth/refresh'
)
||
originalRequest
.
url
?.
includes
(
'/auth/logout'
)
)
{
return
Promise
.
reject
(
error
);
}
if
(
error
.
response
?.
status
===
401
&&
!
originalRequest
.
_retry
)
{
if
(
isRefreshing
)
{
// Queue the request until token is refreshed
return
new
Promise
((
resolve
,
reject
)
=>
{
failedQueue
.
push
({
resolve
,
reject
});
})
.
then
((
token
)
=>
{
originalRequest
.
headers
.
Authorization
=
`Bearer
${
token
}
`
;
return
api
(
originalRequest
);
})
.
then
(()
=>
api
(
originalRequest
))
.
catch
((
err
)
=>
Promise
.
reject
(
err
));
}
originalRequest
.
_retry
=
true
;
isRefreshing
=
true
;
const
refreshToken
=
localStorage
.
getItem
(
'refreshToken'
);
if
(
!
refreshToken
)
{
processQueue
(
error
,
null
);
isRefreshing
=
false
;
handleLogout
();
return
Promise
.
reject
(
error
);
}
try
{
const
response
=
await
axios
.
post
(
`
${
API_BASE_URL
}
/auth/refresh`
,
{
refreshToken
,
});
const
{
accessToken
:
newAccessToken
,
refreshToken
:
newRefreshToken
}
=
response
.
data
.
data
||
response
.
data
;
localStorage
.
setItem
(
'accessToken'
,
newAccessToken
);
if
(
newRefreshToken
)
{
localStorage
.
setItem
(
'refreshToken'
,
newRefreshToken
);
}
api
.
defaults
.
headers
.
common
[
'Authorization'
]
=
`Bearer
${
newAccessToken
}
`
;
originalRequest
.
headers
.
Authorization
=
`Bearer
${
newAccessToken
}
`
;
processQueue
(
null
,
newAccessToken
);
isRefreshing
=
false
;
await
api
.
post
(
'/auth/refresh'
,
{});
processQueue
();
return
api
(
originalRequest
);
}
catch
(
refreshError
)
{
processQueue
(
refreshError
,
null
);
isRefreshing
=
false
;
handleLogout
();
processQueue
(
refreshError
);
notifyAuthExpired
();
return
Promise
.
reject
(
refreshError
);
}
finally
{
isRefreshing
=
false
;
}
}
...
...
@@ -106,11 +70,12 @@ api.interceptors.response.use(
}
);
function
handleLogout
()
{
export
const
getApiUrl
=
(
path
:
string
)
=>
`
${
API_BASE_URL
}
/
${
path
.
replace
(
/^
\/
+/
,
''
)}
`
;
function
notifyAuthExpired
()
{
localStorage
.
removeItem
(
'accessToken'
);
localStorage
.
removeItem
(
'refreshToken'
);
localStorage
.
removeItem
(
'user'
);
if
(
window
.
location
.
pathname
!==
'/login'
)
{
window
.
location
.
href
=
'/login'
;
}
window
.
dispatchEvent
(
new
Event
(
'auth:expired'
));
}
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