Commit 4b4dbccb authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

Merge branch 'fix/header' into 'develop-news'

fix

See merge request !59
parents 4a2b2394 a877b692
......@@ -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">
......
......@@ -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 { eventCalendarPosts } = useHomePosts();
const firstEventDate = eventPosts[0]?.startedAt
? new Date(eventPosts[0].startedAt)
: new Date("2026-11-01T00:00:00");
const firstEventDate = eventCalendarPosts[0]?.registrationDeadline
? new Date(eventCalendarPosts[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);
eventCalendarPosts.filter((item) => {
const date = new Date(item.registrationDeadline);
return (
date.getMonth() === currentMonth.getMonth() &&
date.getFullYear() === currentMonth.getFullYear()
);
}),
[currentMonth, eventPosts],
[currentMonth, eventCalendarPosts],
);
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")}
</span>
</button>
{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}
......
......@@ -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">
......
'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) => (
<div className="mt-4 space-y-3">
{linkSlots.map((item, index) =>
item ? (
<Link
key={item.href}
href={item.href}
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>{item.label}</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>
......
This diff is collapsed.
......@@ -5,18 +5,20 @@ 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}
{isClient ? (
<ProgressBar
height='4px'
color={`hsl(${cssVar('--secondary')})`}
options={{ showSpinner: false }}
shallowRouting
/>
) : null}
</Fragment>
)
}
......
......@@ -2,22 +2,19 @@
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
......@@ -30,7 +27,25 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<main className="px-4 py-4 lg:px-6 lg:py-6">{children}</main>
</div>
</div>
)}
</AdminAuthGuard>
);
}
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>;
}
......@@ -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>
......
import { redirect } from 'next/navigation';
export default function AdminPage() {
redirect('/admin/login');
redirect('/admin/base-config');
}
......@@ -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;
}
......@@ -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,8 +334,12 @@ export async function ensureValidAdminAccessToken() {
}
if (!store.appAccessTokenExpired || store.appAccessTokenExpired > Date.now()) {
const jwtExpiresAt = getJwtExpiresAt(store.appAccessToken);
if (!jwtExpiresAt || jwtExpiresAt > Date.now()) {
return store.appAccessToken;
}
}
return refreshAdminAccessToken();
}
......
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: {
appPersistSession: remember,
appUserRemember: remember
? {
username,
password,
remember,
},
}
: null,
})),
resetStore: () => {
const rememberedUser = get().appUserRemember;
......@@ -164,17 +247,26 @@ 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) => ({
appPersistSession: state.appPersistSession,
...(state.appPersistSession
? {
appIsLoggedIn: state.appIsLoggedIn,
appAccessToken: state.appAccessToken,
appAccessTokenExpired: state.appAccessTokenExpired,
......@@ -182,9 +274,13 @@ const useAuthStore = create<AuthStoreStateType>()(
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();
......
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