Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
V
VCCI-News
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
Văn Hoàng
VCCI-News
Commits
4b4dbccb
Commit
4b4dbccb
authored
May 18, 2026
by
Lê Bảo Hồng Đức
☄
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'fix/header' into 'develop-news'
fix See merge request
!59
parents
4a2b2394
a877b692
Changes
12
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
632 additions
and
146 deletions
+632
-146
index.tsx
...(main)/(home)/components/business-opportunities/index.tsx
+5
-5
index.tsx
src/app/(main)/(home)/components/events-calendar/index.tsx
+47
-21
index.tsx
src/app/(main)/(home)/components/policies-and-laws/index.tsx
+5
-6
index.tsx
src/app/(main)/(home)/components/quick-links/index.tsx
+25
-22
use-home-posts.ts
src/app/(main)/(home)/lib/use-home-posts.ts
+214
-13
index.tsx
src/app/_providers/progress-bar/index.tsx
+24
-22
layout.tsx
src/app/admin/layout.tsx
+37
-22
page.tsx
src/app/admin/login/page.tsx
+4
-4
page.tsx
src/app/admin/page.tsx
+1
-1
admin-auth-guard.tsx
src/components/shared/admin-auth-guard.tsx
+113
-2
admin-auth.ts
src/lib/auth/admin-auth.ts
+36
-3
useAuthStore.ts
src/store/useAuthStore.ts
+121
-25
No files found.
src/app/(main)/(home)/components/business-opportunities/index.tsx
View file @
4b4dbccb
...
...
@@ -15,7 +15,7 @@ function BusinessOpportunities() {
"/xuc-tien-thuong-mai/co-hoi-kinh-doanh"
;
return
(
<
section
className=
"flex
-1
"
>
<
section
className=
"flex
flex-1 flex-col
"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
...
...
@@ -32,7 +32,7 @@ function BusinessOpportunities() {
</
Link
>
</
div
>
<
div
className=
"
space-y
-3"
>
<
div
className=
"
flex min-h-[270px] flex-1 flex-col justify-between gap
-3"
>
{
featuredItem
?
(
<
Link
href=
{
featuredItem
.
externalLink
}
...
...
@@ -52,13 +52,13 @@ function BusinessOpportunities() {
</
div
>
)
}
<
div
className=
"
space-y
-2.5"
>
<
div
className=
"
flex flex-col gap
-2.5"
>
{
listSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
externalLink
}
className=
"flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
className=
"flex
min-h-[58px]
gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe]"
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]"
/>
<
div
className=
"min-w-0"
>
...
...
@@ -73,7 +73,7 @@ function BusinessOpportunities() {
)
:
(
<
div
key=
{
`business-placeholder-${index}`
}
className=
"flex gap-3 rounded-[14px] px-0.5 py-1"
className=
"flex
min-h-[58px]
gap-3 rounded-[14px] px-0.5 py-1"
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40"
/>
<
div
className=
"min-w-0 flex-1"
>
...
...
src/app/(main)/(home)/components/events-calendar/index.tsx
View file @
4b4dbccb
...
...
@@ -8,32 +8,37 @@ import { useMemo, useState } from "react";
const
weekDays
=
[
"CN"
,
"T2"
,
"T3"
,
"T4"
,
"T5"
,
"T6"
,
"T7"
];
const
formatDateTime
=
(
value
:
string
)
=>
value
?
dayjs
(
value
).
format
(
"DD/MM/YYYY HH:mm"
)
:
"Đang cập nhật"
;
const
isTrainingEvent
=
(
item
:
HomePostItem
)
=>
item
.
categories
.
some
((
category
)
=>
{
const
key
=
`
${
category
.
name
}
${
category
.
slug
}
${
category
.
url
}
`
.
toLowerCase
();
return
key
.
includes
(
"đào tạo"
)
||
key
.
includes
(
"dao-tao"
);
});
function
EventsCalendar
()
{
const
{
eventPosts
}
=
useHomePosts
();
const
{
event
Calendar
Posts
}
=
useHomePosts
();
const
firstEventDate
=
event
Posts
[
0
]?.
startedAt
?
new
Date
(
event
Posts
[
0
].
startedAt
)
:
new
Date
(
"2026-11-01T00:00:00"
);
const
firstEventDate
=
event
CalendarPosts
[
0
]?.
registrationDeadline
?
new
Date
(
event
CalendarPosts
[
0
].
registrationDeadline
)
:
new
Date
();
const
[
currentMonth
,
setCurrentMonth
]
=
useState
(
new
Date
(
firstEventDate
.
getFullYear
(),
firstEventDate
.
getMonth
(),
1
),
);
const
isTrainingEvent
=
(
item
:
HomePostItem
)
=>
item
.
categories
.
some
((
category
)
=>
category
.
name
.
toLowerCase
().
includes
(
"đào tạo"
),
);
const
[
selectedDateKey
,
setSelectedDateKey
]
=
useState
<
string
|
null
>
(
null
);
const
monthEvents
=
useMemo
(
()
=>
eventPosts
.
filter
((
item
)
=>
{
const
date
=
new
Date
(
item
.
startedAt
);
event
Calendar
Posts
.
filter
((
item
)
=>
{
const
date
=
new
Date
(
item
.
registrationDeadline
);
return
(
date
.
getMonth
()
===
currentMonth
.
getMonth
()
&&
date
.
getFullYear
()
===
currentMonth
.
getFullYear
()
);
}),
[
currentMonth
,
eventPosts
],
[
currentMonth
,
event
Calendar
Posts
],
);
const
days
=
useMemo
(()
=>
{
...
...
@@ -53,7 +58,7 @@ function EventsCalendar() {
const
map
=
new
Map
<
string
,
HomePostItem
[]
>
();
monthEvents
.
forEach
((
item
)
=>
{
const
key
=
dayjs
(
item
.
startedAt
).
format
(
"YYYY-MM-DD"
);
const
key
=
dayjs
(
item
.
registrationDeadline
).
format
(
"YYYY-MM-DD"
);
const
existing
=
map
.
get
(
key
)
??
[];
existing
.
push
(
item
);
map
.
set
(
key
,
existing
);
...
...
@@ -62,7 +67,8 @@ function EventsCalendar() {
return
map
;
},
[
monthEvents
]);
const
highlightedEvent
=
monthEvents
[
0
];
const
selectedEvents
=
selectedDateKey
?
eventMap
.
get
(
selectedDateKey
)
??
[]
:
[];
const
highlightedEvent
=
selectedEvents
[
0
]
??
monthEvents
[
0
];
return
(
<
aside
className=
"w-full rounded-[28px] bg-white p-4 text-[#24469c] shadow-[0_18px_38px_rgba(16,61,130,0.16)] md:p-5 xl:w-[28%] xl:min-w-[320px]"
>
...
...
@@ -109,11 +115,21 @@ function EventsCalendar() {
const
inMonth
=
day
.
getMonth
()
===
currentMonth
.
getMonth
();
const
hasTraining
=
items
.
some
((
item
)
=>
isTrainingEvent
(
item
));
const
hasEvent
=
items
.
length
>
0
&&
!
hasTraining
;
const
tooltip
=
items
.
map
((
item
)
=>
item
.
title
).
join
(
"
\n
"
);
const
selectable
=
inMonth
&&
items
.
length
>
0
;
const
selected
=
selectable
&&
selectedDateKey
===
key
;
return
(
<
div
key=
{
key
}
className=
"relative flex items-center justify-center"
>
<
span
className=
{
`relative flex h-7 w-7 items-center justify-center rounded-full ${
<
div
key=
{
key
}
className=
"relative flex items-center justify-center"
>
<
button
type=
"button"
title=
{
tooltip
||
undefined
}
disabled=
{
!
selectable
}
onClick=
{
()
=>
setSelectedDateKey
(
key
)
}
className=
{
`relative flex h-7 w-7 items-center justify-center rounded-full transition-all ${
!inMonth
? "text-[#c9d2e2]"
: hasTraining
...
...
@@ -121,10 +137,14 @@ function EventsCalendar() {
: hasEvent
? "bg-[#1e3f9a] font-semibold text-white"
: ""
}`
}
} ${
selectable
? "cursor-pointer hover:ring-2 hover:ring-[#f7b500]/60"
: "cursor-default"
} ${selected ? "ring-2 ring-[#f7b500] ring-offset-2" : ""}`
}
>
{
format
(
day
,
"d"
)
}
</
spa
n
>
</
butto
n
>
{
items
.
length
>
0
&&
!
hasTraining
&&
inMonth
?
(
<
span
className=
"absolute bottom-[-5px] h-1.5 w-1.5 rounded-full bg-[#1e3f9a]"
/>
...
...
@@ -154,11 +174,17 @@ function EventsCalendar() {
<
div
className=
"mt-4 rounded-[16px] bg-[#f7f9fd] p-3.5 text-[12px] leading-5 text-[#3d547f]"
>
<
div
className=
"flex items-start gap-3"
>
<
span
className=
{
`mt-1 h-2.5 w-2.5 rounded-full ${
className=
{
`mt-1 h-2.5 w-2.5
shrink-0
rounded-full ${
isTrainingEvent(highlightedEvent) ? "bg-[#ffbc11]" : "bg-[#1e3f9a]"
}`
}
/>
<
p
className=
"line-clamp-3"
>
{
highlightedEvent
.
title
}
</
p
>
<
div
className=
"min-w-0 space-y-1"
>
<
p
>
Hạn đăng ký:
{
formatDateTime
(
highlightedEvent
.
registrationDeadline
)
}
· Chi phí:
{
" "
}
{
highlightedEvent
.
participationFee
||
"Đang cập nhật"
}
</
p
>
<
p
>
Địa điểm:
{
highlightedEvent
.
location
||
"Đang cập nhật"
}
</
p
>
</
div
>
</
div
>
</
div
>
)
:
null
}
...
...
src/app/(main)/(home)/components/policies-and-laws/index.tsx
View file @
4b4dbccb
...
...
@@ -8,14 +8,13 @@ import Link from "next/link";
function
PolicyAndLaws
()
{
const
{
policyPosts
,
categoryLinks
,
categoryNames
}
=
useHomePosts
();
const
policyItems
=
policyPosts
;
const
[
featuredItem
,
...
listItems
]
=
policyItems
;
const
listSlots
=
[
featuredItem
,
...
listItems
.
slice
(
0
,
2
)];
const
listSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
policyItems
[
index
]
??
null
);
const
sectionLink
=
categoryLinks
.
get
(
categoryNames
.
chinhSachPhapLuat
.
toLowerCase
())
??
"/thong-tin-truyen-thong/thong-tin-chinh-sach-va-phap-luat"
;
return
(
<
section
className=
"flex
-1
"
>
<
section
className=
"flex
flex-1 flex-col
"
>
<
div
className=
"mb-4 flex items-center justify-between gap-3"
>
<
div
>
<
h2
className=
"client-section-title uppercase text-[#24469c]"
>
...
...
@@ -32,13 +31,13 @@ function PolicyAndLaws() {
</
Link
>
</
div
>
<
div
className=
"
space-y
-2.5"
>
<
div
className=
"
flex min-h-[270px] flex-1 flex-col justify-between gap
-2.5"
>
{
listSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
externalLink
}
className=
{
`flex gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
className=
{
`flex
min-h-[58px]
gap-3 rounded-[14px] px-0.5 py-1 transition-colors hover:bg-[#f8fafe] ${
index === 0 ? "pt-0.5" : ""
}`
}
>
...
...
@@ -55,7 +54,7 @@ function PolicyAndLaws() {
)
:
(
<
div
key=
{
`policy-placeholder-${index}`
}
className=
{
`flex gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`
}
className=
{
`flex
min-h-[58px]
gap-3 rounded-[14px] px-0.5 py-1 ${index === 0 ? "pt-0.5" : ""}`
}
>
<
span
className=
"mt-1 h-[40px] w-[2px] shrink-0 rounded-full bg-[#f7b500]/40"
/>
<
div
className=
"min-w-0 flex-1"
>
...
...
src/app/(main)/(home)/components/quick-links/index.tsx
View file @
4b4dbccb
'use client'
;
import
{
useHomePosts
}
from
"@/app/(main)/(home)/lib/use-home-posts"
;
import
ImageNext
from
"@/components/shared/image-next"
;
import
Link
from
"next/link"
;
const
quickLinks
=
[
{
href
:
"https://vcci-hcm.org.vn/lien-ket-nhanh/doanh-nghiep-kien-nghi-ve-chinh-sach-va-phap-luat/"
,
label
:
"Doanh nghiệp kiến nghị về chính sách và pháp luật"
,
},
{
href
:
"https://vcci-hcm.org.vn/lien-ket-nhanh/cam-nang-huong-dan-dau-tu-kinh-doanh-tai-viet-nam-2023/"
,
label
:
"Cẩm nang hướng dẫn đầu tư kinh doanh tại Việt Nam"
,
},
];
function
QuickLinks
()
{
const
{
quickLinkPosts
}
=
useHomePosts
();
const
linkSlots
=
Array
.
from
({
length
:
4
},
(
_
,
index
)
=>
quickLinkPosts
[
index
]
??
null
);
return
(
<
aside
className=
"w-full xl:grid xl:w-[32%] xl:grid-rows-[0.74fr_0.88fr] xl:gap-4"
>
<
div
className=
"rounded-[22px] border border-[#dbe4f2] bg-white p-4 shadow-[0_8px_24px_rgba(31,59,124,0.08)] xl:h-full"
>
...
...
@@ -23,17 +16,27 @@ function QuickLinks() {
</
h2
>
<
div
className=
"mt-3 h-[5px] w-[68px] rounded-full bg-[#f7b500]"
/>
<
div
className=
"mt-4 space-y-2.5"
>
{
quickLinks
.
map
((
item
)
=>
(
<
Link
key=
{
item
.
href
}
href=
{
item
.
href
}
className=
"flex items-start gap-3 text-[15px] leading-[1.32] text-[#556684] transition-colors hover:text-[#21408f]"
>
<
span
className=
"mt-1 text-[#e2a500]"
>
›
</
span
>
<
span
>
{
item
.
label
}
</
span
>
</
Link
>
))
}
<
div
className=
"mt-4 space-y-3"
>
{
linkSlots
.
map
((
item
,
index
)
=>
item
?
(
<
Link
key=
{
item
.
id
}
href=
{
item
.
externalLink
}
className=
"flex items-start gap-3 text-[15px] leading-[1.32] text-[#556684] transition-colors hover:text-[#21408f]"
>
<
span
className=
"mt-1 text-[#e2a500]"
>
›
</
span
>
<
span
className=
"line-clamp-1"
>
{
item
.
title
}
</
span
>
</
Link
>
)
:
(
<
div
key=
{
`quick-link-placeholder-${index}`
}
className=
"flex items-start gap-3"
>
<
span
className=
"mt-1 h-4 w-2 shrink-0 rounded bg-[#f5d774]"
/>
<
span
className=
"h-5 w-5/6 rounded bg-[#eef3fb]"
/>
</
div
>
),
)
}
</
div
>
</
div
>
...
...
src/app/(main)/(home)/lib/use-home-posts.ts
View file @
4b4dbccb
This diff is collapsed.
Click to expand it.
src/app/_providers/progress-bar/index.tsx
View file @
4b4dbccb
'use client'
import
{
AppProgressBar
as
ProgressBar
}
from
'next-nprogress-bar'
import
{
Fragment
,
startTransition
,
useEffect
,
useState
}
from
'react'
import
{
cssVar
}
from
'@/lib/utils/css-var'
export
const
ProgressBarProvider
=
({
children
}:
{
children
:
React
.
ReactNode
})
=>
{
const
[
isClient
,
setIsClient
]
=
useState
(
false
)
useEffect
(()
=>
startTransition
(()
=>
setIsClient
(
true
)),
[])
if
(
!
isClient
)
return
children
return
(
<
Fragment
>
{
children
}
<
ProgressBar
height=
'4px'
color=
{
`hsl(${cssVar('--secondary')})`
}
options=
{
{
showSpinner
:
false
}
}
shallowRouting
/>
</
Fragment
>
)
}
'use client'
import
{
AppProgressBar
as
ProgressBar
}
from
'next-nprogress-bar'
import
{
Fragment
,
startTransition
,
useEffect
,
useState
}
from
'react'
import
{
cssVar
}
from
'@/lib/utils/css-var'
export
const
ProgressBarProvider
=
({
children
}:
{
children
:
React
.
ReactNode
})
=>
{
const
[
isClient
,
setIsClient
]
=
useState
(
false
)
useEffect
(()
=>
startTransition
(()
=>
setIsClient
(
true
)),
[])
return
(
<
Fragment
>
{
children
}
{
isClient
?
(
<
ProgressBar
height=
'4px'
color=
{
`hsl(${cssVar('--secondary')})`
}
options=
{
{
showSpinner
:
false
}
}
shallowRouting
/>
)
:
null
}
</
Fragment
>
)
}
export
default
ProgressBarProvider
src/app/admin/layout.tsx
View file @
4b4dbccb
...
...
@@ -2,35 +2,50 @@
import
React
from
'react'
;
import
{
usePathname
}
from
'next/navigation'
;
import
{
AdminAuthGuard
}
from
'@/components/shared/admin-auth-guard'
;
import
{
AdminAuthLoadingScreen
,
useAdminAuthStatus
,
}
from
'@/components/shared/admin-auth-guard'
;
import
{
AdminSidebar
}
from
'@/components/shared/admin-sidebar'
;
import
{
AdminHeader
}
from
'@/components/shared/admin-header'
;
import
{
useSidebarStore
}
from
'@/hooks/use-admin-sidebar'
;
import
{
cn
}
from
'@/lib/utils'
;
export
default
function
AdminLayout
({
children
}:
{
children
:
React
.
ReactNode
})
{
function
AdminShell
({
children
}:
{
children
:
React
.
ReactNode
})
{
const
{
isOpen
}
=
useSidebarStore
();
const
pathname
=
usePathname
();
const
isLoginPage
=
pathname
===
'/admin/login'
;
return
(
<
AdminAuthGuard
>
{
isLoginPage
?
(
<
div
className=
"min-h-screen bg-slate-50"
>
{
children
}
</
div
>
)
:
(
<
div
className=
"min-h-screen bg-white"
>
<
AdminSidebar
/>
<
div
className=
{
cn
(
'transition-all duration-300'
,
isOpen
?
'pl-72'
:
'pl-24'
,
)
}
>
<
AdminHeader
/>
<
main
className=
"px-4 py-4 lg:px-6 lg:py-6"
>
{
children
}
</
main
>
</
div
>
</
div
>
)
}
</
AdminAuthGuard
>
<
div
className=
"min-h-screen bg-white"
>
<
AdminSidebar
/>
<
div
className=
{
cn
(
'transition-all duration-300'
,
isOpen
?
'pl-72'
:
'pl-24'
,
)
}
>
<
AdminHeader
/>
<
main
className=
"px-4 py-4 lg:px-6 lg:py-6"
>
{
children
}
</
main
>
</
div
>
</
div
>
);
}
export
default
function
AdminLayout
({
children
}:
{
children
:
React
.
ReactNode
})
{
const
pathname
=
usePathname
();
const
isLoginPage
=
pathname
===
'/admin/login'
;
const
authStatus
=
useAdminAuthStatus
();
if
(
isLoginPage
)
{
return
<
div
className=
"min-h-screen bg-slate-50"
>
{
children
}
</
div
>;
}
if
(
authStatus
===
'loading'
)
{
return
<
AdminAuthLoadingScreen
/>;
}
if
(
authStatus
===
'blocked'
)
{
return
null
;
}
return
<
AdminShell
>
{
children
}
</
AdminShell
>;
}
src/app/admin/login/page.tsx
View file @
4b4dbccb
...
...
@@ -125,8 +125,8 @@ function AuthShell({
mode
===
"login"
?
"Truy cập khu vực quản trị nội dung VCCI News."
:
mode
===
"forgot"
?
"X
?c th?c email qu?n tr? d? nh?n m?
OTP."
:
"Nh
?p m? OTP v? t?o m?t kh?u m?i cho t?i kho?
n."
;
?
"X
ác thực email quản trị để nhận mã
OTP."
:
"Nh
ập mã OTP để tạo mật khẩu mới cho tài khoả
n."
;
return
(
<
div
className=
"min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700"
>
...
...
@@ -312,7 +312,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading
(
true
);
try
{
await
loginAdmin
(
email
.
trim
(),
password
);
await
loginAdmin
(
email
.
trim
(),
password
,
{
persistSession
:
remember
}
);
setAppUserRemember
(
remember
?
email
.
trim
()
:
""
,
remember
?
password
:
""
,
remember
);
toast
.
success
(
"Đăng nhập quản trị thành công"
);
...
...
@@ -543,7 +543,7 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
Đang gửi OTP...
</>
)
:
(
"G
?i m?
OTP"
"G
ửi mã
OTP"
)
}
</
Button
>
...
...
src/app/admin/page.tsx
View file @
4b4dbccb
import
{
redirect
}
from
'next/navigation'
;
export
default
function
AdminPage
()
{
redirect
(
'/admin/
login
'
);
redirect
(
'/admin/
base-config
'
);
}
src/components/shared/admin-auth-guard.tsx
View file @
4b4dbccb
...
...
@@ -7,7 +7,7 @@ import useAuthStore from "@/store/useAuthStore";
const
LOGIN_PATH
=
"/admin/login"
;
function
AdminAuthLoadingScreen
()
{
export
function
AdminAuthLoadingScreen
()
{
return
(
<
div
className=
"min-h-screen bg-white"
>
<
div
className=
"fixed inset-y-0 left-0 hidden w-24 border-r border-[#063e8e]/10 bg-white lg:block"
>
...
...
@@ -76,7 +76,10 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const
refreshToken
=
useAuthStore
((
state
)
=>
state
.
appRefreshToken
);
const
isRefreshing
=
useAuthStore
((
state
)
=>
state
.
appIsRefreshing
);
const
[
authCheckState
,
setAuthCheckState
]
=
useState
<
"idle"
|
"checking"
|
"ready"
>
(
"idle"
);
const
redirectParam
=
encodeURIComponent
(
pathname
||
"/admin"
);
const
redirectParam
=
typeof
window
===
"undefined"
?
encodeURIComponent
(
pathname
||
"/admin"
)
:
encodeURIComponent
(
`
${
window
.
location
.
pathname
}${
window
.
location
.
search
}
`
);
useEffect
(()
=>
{
if
(
pathname
===
LOGIN_PATH
)
{
...
...
@@ -156,9 +159,117 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
return
<
AdminAuthLoadingScreen
/>;
}
if
(
!
isLoggedIn
||
(
!
accessToken
&&
refreshToken
))
{
return
<
AdminAuthLoadingScreen
/>;
}
if
(
!
isLoggedIn
||
!
accessToken
)
{
return
null
;
}
return
<>
{
children
}
</>;
}
export
function
useAdminAuthStatus
()
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
hasHydrated
=
useAuthStore
((
state
)
=>
state
.
_hasHydrated
);
const
isLoggedIn
=
useAuthStore
((
state
)
=>
state
.
appIsLoggedIn
);
const
accessToken
=
useAuthStore
((
state
)
=>
state
.
appAccessToken
);
const
accessTokenExpired
=
useAuthStore
((
state
)
=>
state
.
appAccessTokenExpired
);
const
refreshToken
=
useAuthStore
((
state
)
=>
state
.
appRefreshToken
);
const
isRefreshing
=
useAuthStore
((
state
)
=>
state
.
appIsRefreshing
);
const
[
authCheckState
,
setAuthCheckState
]
=
useState
<
"idle"
|
"checking"
|
"ready"
>
(
"idle"
);
const
redirectParam
=
typeof
window
===
"undefined"
?
encodeURIComponent
(
pathname
||
"/admin"
)
:
encodeURIComponent
(
`
${
window
.
location
.
pathname
}${
window
.
location
.
search
}
`
);
useEffect
(()
=>
{
if
(
pathname
===
LOGIN_PATH
)
{
setAuthCheckState
(
"ready"
);
return
;
}
if
(
!
hasHydrated
)
{
setAuthCheckState
(
"idle"
);
return
;
}
let
cancelled
=
false
;
const
restoreSession
=
async
()
=>
{
setAuthCheckState
(
"checking"
);
const
hasValidAccessToken
=
Boolean
(
accessToken
&&
isLoggedIn
&&
(
!
accessTokenExpired
||
accessTokenExpired
>
Date
.
now
()),
);
if
(
hasValidAccessToken
)
{
if
(
!
cancelled
)
{
setAuthCheckState
(
"ready"
);
}
return
;
}
if
(
!
refreshToken
)
{
if
(
!
cancelled
)
{
setAuthCheckState
(
"ready"
);
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
redirectParam
}
`
);
}
return
;
}
try
{
const
nextToken
=
await
ensureValidAdminAccessToken
();
if
(
!
nextToken
&&
!
cancelled
)
{
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
redirectParam
}
`
);
}
}
catch
{
if
(
!
cancelled
)
{
router
.
replace
(
`
${
LOGIN_PATH
}
?redirect=
${
redirectParam
}
`
);
}
}
finally
{
if
(
!
cancelled
)
{
setAuthCheckState
(
"ready"
);
}
}
};
void
restoreSession
();
return
()
=>
{
cancelled
=
true
;
};
},
[
accessToken
,
accessTokenExpired
,
hasHydrated
,
isLoggedIn
,
pathname
,
redirectParam
,
refreshToken
,
router
,
]);
if
(
pathname
===
LOGIN_PATH
)
{
return
"ready"
as
const
;
}
if
(
!
hasHydrated
||
isRefreshing
||
authCheckState
!==
"ready"
)
{
return
"loading"
as
const
;
}
if
(
!
isLoggedIn
||
(
!
accessToken
&&
refreshToken
))
{
return
"loading"
as
const
;
}
if
(
!
isLoggedIn
||
!
accessToken
)
{
return
"blocked"
as
const
;
}
return
"ready"
as
const
;
}
src/lib/auth/admin-auth.ts
View file @
4b4dbccb
...
...
@@ -38,7 +38,7 @@ interface LoginResponseData {
token_type
?:
string
|
null
;
}
interface
MeResponseData
extends
Partial
<
AuthenticatedAdminUser
>
{}
type
MeResponseData
=
Partial
<
AuthenticatedAdminUser
>
;
interface
RefreshResponseData
{
session
?:
Partial
<
AuthenticatedAdminSession
>
|
null
;
...
...
@@ -57,6 +57,7 @@ type AuthFailureReason = "missing_refresh_token" | "refresh_failed";
let
refreshPromise
:
Promise
<
string
|
null
>
|
null
=
null
;
let
forcedLogoutPromise
:
Promise
<
void
>
|
null
=
null
;
const
ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
=
300
;
const
isObject
=
(
value
:
unknown
):
value
is
Record
<
string
,
unknown
>
=>
typeof
value
===
"object"
&&
value
!==
null
&&
!
Array
.
isArray
(
value
);
...
...
@@ -107,6 +108,27 @@ const normalizeSession = (
};
};
const
getJwtExpiresAt
=
(
token
?:
string
|
null
)
=>
{
if
(
!
token
)
return
null
;
const
[,
payload
]
=
token
.
split
(
"."
);
if
(
!
payload
)
return
null
;
try
{
const
normalizedPayload
=
payload
.
replace
(
/-/g
,
"+"
).
replace
(
/_/g
,
"/"
);
const
decoded
=
typeof
window
===
"undefined"
?
Buffer
.
from
(
normalizedPayload
,
"base64"
).
toString
(
"utf8"
)
:
window
.
atob
(
normalizedPayload
.
padEnd
(
Math
.
ceil
(
normalizedPayload
.
length
/
4
)
*
4
,
"="
));
const
parsed
=
JSON
.
parse
(
decoded
)
as
{
exp
?:
unknown
};
const
exp
=
typeof
parsed
.
exp
===
"number"
?
parsed
.
exp
:
null
;
return
exp
?
(
exp
-
ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
)
*
1000
:
null
;
}
catch
{
return
null
;
}
};
async
function
requestAuth
<
T
>
(
path
:
string
,
init
?:
AuthRequestOptions
,
...
...
@@ -163,7 +185,11 @@ const markSessionExpiredAndNotify = () => {
}
};
export
async
function
loginAdmin
(
email
:
string
,
password
:
string
)
{
export
async
function
loginAdmin
(
email
:
string
,
password
:
string
,
options
?:
{
persistSession
?:
boolean
},
)
{
const
payload
=
await
requestAuth
<
LoginResponseData
>
(
"/login"
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
...
...
@@ -188,8 +214,10 @@ export async function loginAdmin(email: string, password: string) {
accessToken
:
payload
.
access_token
,
refreshToken
:
payload
.
refresh_token
,
expiresIn
:
payload
.
expires_in
,
accessTokenExpired
:
getJwtExpiresAt
(
payload
.
access_token
),
user
:
normalizedUser
,
session
:
normalizeSession
(
payload
.
session
),
persistSession
:
options
?.
persistSession
===
true
,
});
useAuthStore
.
getState
().
setAppUser
(
normalizedUser
);
...
...
@@ -276,6 +304,7 @@ export async function refreshAdminAccessToken() {
useAuthStore
.
getState
().
updateAccessToken
({
accessToken
:
payload
.
access_token
,
expiresIn
:
payload
.
expires_in
,
accessTokenExpired
:
getJwtExpiresAt
(
payload
.
access_token
),
refreshToken
:
payload
.
refresh_token
??
refreshToken
,
session
:
normalizeSession
(
payload
.
session
),
});
...
...
@@ -305,7 +334,11 @@ export async function ensureValidAdminAccessToken() {
}
if
(
!
store
.
appAccessTokenExpired
||
store
.
appAccessTokenExpired
>
Date
.
now
())
{
return
store
.
appAccessToken
;
const
jwtExpiresAt
=
getJwtExpiresAt
(
store
.
appAccessToken
);
if
(
!
jwtExpiresAt
||
jwtExpiresAt
>
Date
.
now
())
{
return
store
.
appAccessToken
;
}
}
return
refreshAdminAccessToken
();
...
...
src/store/useAuthStore.ts
View file @
4b4dbccb
import
{
create
}
from
"zustand"
;
import
{
devtools
,
persist
}
from
"zustand/middleware"
;
import
{
createJSONStorage
,
devtools
,
persist
}
from
"zustand/middleware"
;
export
interface
AuthenticatedAdminUser
{
id
:
string
;
...
...
@@ -23,13 +23,16 @@ export interface AuthSessionPayload {
accessToken
:
string
;
refreshToken
:
string
;
expiresIn
:
number
;
accessTokenExpired
?:
number
|
null
;
user
:
AuthenticatedAdminUser
|
null
;
session
:
AuthenticatedAdminSession
|
null
;
persistSession
?:
boolean
;
}
export
interface
AuthRefreshPayload
{
accessToken
:
string
;
expiresIn
:
number
;
accessTokenExpired
?:
number
|
null
;
refreshToken
?:
string
|
null
;
session
?:
AuthenticatedAdminSession
|
null
;
}
...
...
@@ -41,6 +44,7 @@ export interface AuthStoreStateType {
appRefreshToken
:
string
|
null
;
appSession
:
AuthenticatedAdminSession
|
null
;
appUser
:
AuthenticatedAdminUser
|
null
;
appPersistSession
:
boolean
;
appIsRefreshing
:
boolean
;
appSessionExpiredNotified
:
boolean
;
appUserRemember
:
{
...
...
@@ -62,8 +66,17 @@ export interface AuthStoreStateType {
resetStore
:
()
=>
void
;
}
const
ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
=
300
;
const
getAccessTokenExpiredAt
=
(
expiresIn
:
number
)
=>
Date
.
now
()
+
Math
.
max
(
expiresIn
-
300
,
0
)
*
1000
;
Date
.
now
()
+
Math
.
max
(
expiresIn
-
ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
,
0
)
*
1000
;
const
getRefreshTokenExpiredAt
=
(
session
?:
AuthenticatedAdminSession
|
null
)
=>
{
if
(
!
session
?.
refresh_expires_at
)
return
null
;
const
time
=
new
Date
(
session
.
refresh_expires_at
).
getTime
();
return
Number
.
isFinite
(
time
)
?
time
:
null
;
};
const
baseState
=
{
appIsLoggedIn
:
false
,
...
...
@@ -72,6 +85,7 @@ const baseState = {
appRefreshToken
:
null
,
appSession
:
null
,
appUser
:
null
,
appPersistSession
:
false
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
appUserRemember
:
null
,
...
...
@@ -85,10 +99,67 @@ const clearSessionState = {
appRefreshToken
:
null
,
appSession
:
null
,
appUser
:
null
,
appPersistSession
:
false
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
};
const
normalizePersistedAuthState
=
(
persistedState
:
unknown
,
currentState
:
AuthStoreStateType
,
)
=>
{
const
storageState
=
typeof
persistedState
===
"object"
&&
persistedState
!==
null
&&
"state"
in
persistedState
&&
typeof
(
persistedState
as
{
state
?:
unknown
}).
state
===
"object"
&&
(
persistedState
as
{
state
?:
unknown
}).
state
!==
null
?
(
persistedState
as
{
state
:
unknown
}).
state
:
persistedState
;
const
persisted
=
typeof
storageState
===
"object"
&&
storageState
!==
null
?
(
storageState
as
Partial
<
AuthStoreStateType
>
)
:
{};
const
rememberState
=
persisted
.
appUserRemember
??
currentState
.
appUserRemember
;
const
persistSession
=
persisted
.
appPersistSession
===
true
;
const
refreshTokenExpiredAt
=
getRefreshTokenExpiredAt
(
persisted
.
appSession
??
null
);
const
hasUsableSession
=
persistSession
&&
Boolean
(
persisted
.
appRefreshToken
)
&&
(
!
refreshTokenExpiredAt
||
refreshTokenExpiredAt
>
Date
.
now
())
&&
(
!
persisted
.
appAccessTokenExpired
||
typeof
persisted
.
appAccessTokenExpired
===
"number"
);
if
(
!
hasUsableSession
)
{
return
{
...
currentState
,
...
clearSessionState
,
appUserRemember
:
rememberState
,
};
}
return
{
...
currentState
,
...
persisted
,
appPersistSession
:
true
,
appUserRemember
:
rememberState
,
appIsRefreshing
:
false
,
};
};
const
shouldPersistAuthStorage
=
(
value
:
string
)
=>
{
try
{
const
parsed
=
JSON
.
parse
(
value
)
as
{
state
?:
Partial
<
AuthStoreStateType
>
;
};
const
state
=
parsed
.
state
??
{};
return
state
.
appPersistSession
===
true
||
state
.
appUserRemember
?.
remember
===
true
;
}
catch
{
return
true
;
}
};
const
useAuthStore
=
create
<
AuthStoreStateType
>
()(
devtools
(
persist
(
...
...
@@ -102,22 +173,31 @@ const useAuthStore = create<AuthStoreStateType>()(
set
(()
=>
({
appIsLoggedIn
:
isLoggedIn
,
})),
setAuthSession
:
({
accessToken
,
refreshToken
,
expiresIn
,
user
,
session
})
=>
setAuthSession
:
({
accessToken
,
refreshToken
,
expiresIn
,
accessTokenExpired
,
user
,
session
,
persistSession
=
false
,
})
=>
set
(()
=>
({
appIsLoggedIn
:
true
,
appAccessToken
:
accessToken
,
appAccessTokenExpired
:
getAccessTokenExpiredAt
(
expiresIn
),
appAccessTokenExpired
:
accessTokenExpired
??
getAccessTokenExpiredAt
(
expiresIn
),
appRefreshToken
:
refreshToken
,
appSession
:
session
,
appUser
:
user
,
appPersistSession
:
persistSession
,
appIsRefreshing
:
false
,
appSessionExpiredNotified
:
false
,
})),
updateAccessToken
:
({
accessToken
,
expiresIn
,
refreshToken
,
session
})
=>
updateAccessToken
:
({
accessToken
,
expiresIn
,
accessTokenExpired
,
refreshToken
,
session
})
=>
set
(()
=>
({
appIsLoggedIn
:
true
,
appAccessToken
:
accessToken
,
appAccessTokenExpired
:
getAccessTokenExpiredAt
(
expiresIn
),
appAccessTokenExpired
:
accessTokenExpired
??
getAccessTokenExpiredAt
(
expiresIn
),
appRefreshToken
:
refreshToken
??
get
().
appRefreshToken
,
appSession
:
session
??
get
().
appSession
,
appIsRefreshing
:
false
,
...
...
@@ -151,11 +231,14 @@ const useAuthStore = create<AuthStoreStateType>()(
},
setAppUserRemember
:
(
username
,
password
,
remember
)
=>
set
(()
=>
({
appUserRemember
:
{
username
,
password
,
remember
,
},
appPersistSession
:
remember
,
appUserRemember
:
remember
?
{
username
,
password
,
remember
,
}
:
null
,
})),
resetStore
:
()
=>
{
const
rememberedUser
=
get
().
appUserRemember
;
...
...
@@ -164,27 +247,40 @@ const useAuthStore = create<AuthStoreStateType>()(
appUserRemember
:
rememberedUser
,
_hasHydrated
:
true
,
}));
try
{
localStorage
.
removeItem
(
"app-auth-storage"
);
}
catch
{
// ignore
}
},
}),
{
name
:
"app-auth-storage"
,
storage
:
createJSONStorage
(()
=>
({
getItem
:
(
name
)
=>
localStorage
.
getItem
(
name
),
setItem
:
(
name
,
value
)
=>
{
if
(
shouldPersistAuthStorage
(
value
))
{
localStorage
.
setItem
(
name
,
value
);
return
;
}
localStorage
.
removeItem
(
name
);
},
removeItem
:
(
name
)
=>
localStorage
.
removeItem
(
name
),
})),
partialize
:
(
state
)
=>
({
appIsLoggedIn
:
state
.
appIsLoggedIn
,
appAccessToken
:
state
.
appAccessToken
,
appAccessTokenExpired
:
state
.
appAccessTokenExpired
,
appRefreshToken
:
state
.
appRefreshToken
,
appSession
:
state
.
appSession
,
appUser
:
state
.
appUser
,
appSessionExpiredNotified
:
state
.
appSessionExpiredNotified
,
appPersistSession
:
state
.
appPersistSession
,
...(
state
.
appPersistSession
?
{
appIsLoggedIn
:
state
.
appIsLoggedIn
,
appAccessToken
:
state
.
appAccessToken
,
appAccessTokenExpired
:
state
.
appAccessTokenExpired
,
appRefreshToken
:
state
.
appRefreshToken
,
appSession
:
state
.
appSession
,
appUser
:
state
.
appUser
,
appSessionExpiredNotified
:
state
.
appSessionExpiredNotified
,
}
:
{}),
appUserRemember
:
state
.
appUserRemember
,
}),
onRehydrateStorage
:
(
state
)
=>
{
merge
:
(
persistedState
,
currentState
)
=>
normalizePersistedAuthState
(
persistedState
,
currentState
),
onRehydrateStorage
:
()
=>
{
return
(
state
:
AuthStoreStateType
|
undefined
,
error
:
unknown
)
=>
{
if
(
error
)
{
useAuthStore
.
persist
.
clearStorage
();
...
...
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