feat: enhance security by implementing HttpOnly refresh tokens and in-memory access token management
- Added isomorphic-dompurify for improved XSS protection - Refactored token storage to use in-memory management for access tokens - Implemented server-side route handlers for OAuth token management - Introduced security headers in next.config.js - Removed client-side exposure of client_secret and sensitive tokens - Updated API interceptors to handle token refresh logic - Cleaned up cookie management for refresh tokens
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
# سختسازی امنیتی سایت عمومی: ذخیرهی توکن، XSS، هدرها، client_secret
|
||||
|
||||
## پروژه
|
||||
|
||||
`nobat724_front` (سایت عمومی). این پرامپت **cross-repo** است — پرامپت همتای backend: `clinicpro/.claude/prompt/fix-auth-token-hardening.md` که **اول** باید اجرا شود (قرارداد توکن را عوض میکند: `oauth/token` بهجای `uuid` فیلد `grant` میگیرد و `verify-code` فیلد `grant` برمیگرداند).
|
||||
|
||||
مرجع: گزارش امنیتی این session (OWASP Top 10). یافتههای frontend: **C-2 (Critical)**، **C-3 (Critical)**، **H-2 (High)**، **H-3 (High)**.
|
||||
|
||||
## زمینه
|
||||
|
||||
ممیزی امنیتی این مشکلات را در سایت عمومی پیدا کرد:
|
||||
|
||||
- **C-2:** `access_token` و `refresh_token` با `js-cookie` ست میشوند (`Cookies.set(...)`) — یعنی **غیر HttpOnly، بدون Secure، بدون SameSite**؛ هر اسکریپتی میتواند بخواندشان. با XSS → سرقت کامل توکن.
|
||||
- **C-3:** بدنهی بلاگ/کلینیک/پزشک با `dangerouslySetInnerHTML={{ __html: sanitizeHtml(...) }}` رندر میشود، اما `lib/sanitize.js` یک sanitizer **regex دستی و قابل دور زدن** است (مثلاً `<img src=x onerror=...>` چون `<img>` در لیست خطرناک نیست عبور میکند). → Stored XSS.
|
||||
- **H-2:** `next.config.js` هیچ تابع `headers()` ندارد → بدون CSP/HSTS/X-Frame-Options/nosniff/Referrer-Policy/Permissions-Policy.
|
||||
- **H-3:** توکن با `NEXT_PUBLIC_CLIENT_SECRET` گرفته میشود؛ هر مقدار `NEXT_PUBLIC_*` داخل bundle مرورگر inline و عمومی میشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
refresh token را به کوکی **HttpOnly سمت سرور** ببر و توکنگیری/OAuth را در یک Route Handler سرور-ساید انجام بده (تا `client_secret` و `refresh_token` هرگز به مرورگر نروند)؛ sanitizer را با **DOMPurify** عوض کن؛ security headers را در `next.config.js` اضافه کن.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `services/api.js` | interceptor — خواندن `access_token` از کوکی |
|
||||
| `services/response.js` | `getToken` که `client_secret` میفرستد |
|
||||
| `components/register/verificationPage/SendReq.js` | ستکردن کوکیها بعد از لاگین (`Cookies.set`) |
|
||||
| `components/appointment/detail/SubmitData.js` | `Cookies.set("access_token"/"refresh_token")` |
|
||||
| `components/dashboard/userAccount/.../ButtonSendData.js` | `Cookies.set("access_token")` |
|
||||
| `lib/sanitize.js` | sanitizer regex فعلی |
|
||||
| `lib/req.js` / `services/clinicApi.js` | فراخوانیهای مصرفکنندهی توکن جدید |
|
||||
| `app/api/auth/token/route.js` (جدید) | Route Handler سرور-ساید برای OAuth + ست کوکی HttpOnly |
|
||||
| `next.config.js` | افزودن `headers()` |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
`services/response.js` — `client_secret` در درخواست:
|
||||
```js
|
||||
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
|
||||
// ...
|
||||
formData.append("client_secret", client_secret);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`components/register/verificationPage/SendReq.js` (و مشابه در SubmitData.js):
|
||||
```js
|
||||
Cookies.set("access_token", response.access_token, cookieOptions);
|
||||
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
|
||||
```
|
||||
|
||||
`services/api.js` interceptor:
|
||||
```js
|
||||
if (config.requireAuth) {
|
||||
const token = Cookies.get("access_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
```
|
||||
|
||||
`lib/sanitize.js` — regex قابل دور زدن:
|
||||
```js
|
||||
const DANGEROUS_TAGS = ['script','iframe','object','embed','link','meta','base','form'];
|
||||
export function sanitizeHtml(html) {
|
||||
let sanitized = html;
|
||||
DANGEROUS_TAGS.forEach((tag) => { /* regex replace */ });
|
||||
sanitized = sanitized.replace(/\s+on\w+\s*=\s*.../gi, '');
|
||||
return sanitized;
|
||||
}
|
||||
```
|
||||
|
||||
`next.config.js` — بدون `headers()`.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. OAuth + ست کوکی در یک Route Handler سرور-ساید (C-2, H-3)
|
||||
|
||||
یک Route Handler بساز: `app/api/auth/token/route.js`. این هندلر:
|
||||
- ورودی `{ grant }` (مطابق قرارداد جدید backend) را از body میگیرد.
|
||||
- توکن را با `process.env.CLIENT_SECRET` (**بدون** `NEXT_PUBLIC`) و `process.env.CLIENT_ID` از backend (`oauth/token`) میگیرد.
|
||||
- `refresh_token` را در کوکی **HttpOnly; Secure; SameSite=Lax; path=/** ست میکند.
|
||||
- `access_token` را در پاسخ JSON برمیگرداند تا کلاینت در **memory** نگهش دارد (نه کوکی، نه localStorage).
|
||||
|
||||
```js
|
||||
import { cookies } from 'next/headers';
|
||||
import axios from 'axios';
|
||||
|
||||
export async function POST(req) {
|
||||
const { grant } = await req.json();
|
||||
const res = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/oauth/token`, {
|
||||
grant_type: 'mobile',
|
||||
grant,
|
||||
client_id: process.env.CLIENT_ID,
|
||||
client_secret: process.env.CLIENT_SECRET,
|
||||
});
|
||||
const { access_token, refresh_token, expires_in } = res.data;
|
||||
const jar = cookies();
|
||||
jar.set('refresh_token', refresh_token, {
|
||||
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
return Response.json({ access_token, expires_in });
|
||||
}
|
||||
```
|
||||
|
||||
یک Route Handler refresh هم بساز (`app/api/auth/refresh/route.js`) که `refresh_token` را از کوکی HttpOnly میخواند، `oauth/token/refresh` را صدا میزند، کوکی جدید ست میکند و access token تازه برمیگرداند. و `app/api/auth/logout/route.js` که کوکی را پاک و `oauth/logout` را صدا میزند.
|
||||
|
||||
> `services/response.js::getToken` که `client_secret` سمت کلاینت میفرستد را **حذف/جایگزین** کن با فراخوانی این Route Handler. هیچ `NEXT_PUBLIC_CLIENT_SECRET` در کد کلاینت نماند.
|
||||
|
||||
### ۲. مدیریت access token در memory بهجای کوکی JS (C-2)
|
||||
|
||||
- همهی `Cookies.set("access_token", ...)` و `Cookies.set("refresh_token", ...)` را حذف کن (SendReq.js, SubmitData.js, ButtonSendData.js و هرجای دیگر).
|
||||
- access token را در یک ماژول in-memory نگهدار (مثلاً `lib/tokenStore.js` با یک متغیر و getter/setter، یا context). در `services/api.js` interceptor بهجای `Cookies.get("access_token")` از این store بخوان.
|
||||
- روی 401، interceptor اول `app/api/auth/refresh` را امتحان کند؛ اگر شکست خورد، logout و redirect.
|
||||
- چون access token در memory با refresh صفحه پاک میشود، در bootstrap اپ (مثلاً یک Provider بالای درخت) یکبار `app/api/auth/refresh` صدا بزن تا از روی کوکی HttpOnly، access token تازه بگیری.
|
||||
|
||||
> کوکیهای `uuid`/`userInfo` که حساس نیستند میتوانند بمانند ولی `userInfo` را به فیلدهای غیرحساس محدود کن.
|
||||
|
||||
### ۳. جایگزینی sanitizer با DOMPurify (C-3)
|
||||
|
||||
`isomorphic-dompurify` را نصب و `lib/sanitize.js` را بازنویسی کن (امضای `sanitizeHtml` حفظ شود تا `Caption.js`/`TextDetail.js`/`blog`/`clinic`/`doctor` تغییر نکنند):
|
||||
```js
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
if (!html || typeof html !== 'string') return '';
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','ul','ol','li','a','h2','h3','h4','blockquote','img','span','table','thead','tbody','tr','td','th'],
|
||||
ALLOWED_ATTR: ['href','target','rel','src','alt','title'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
}
|
||||
// safeJsonParse را همانطور که هست نگه دار
|
||||
```
|
||||
> `isomorphic-dompurify` در SSR (Server Component) و کلاینت هر دو کار میکند — مهم، چون این صفحات SSR هستند.
|
||||
|
||||
### ۴. Security headers در next.config.js (H-2)
|
||||
|
||||
تابع `headers()` به `nextConfig` اضافه کن:
|
||||
```js
|
||||
async headers() {
|
||||
return [{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{ key: 'Content-Security-Policy', value: [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' https: data:",
|
||||
"script-src 'self' 'unsafe-inline'", // اگر JSON-LD/Next نیاز داشت؛ در صورت امکان nonce
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://api.clinic-pro.ir",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
].join('; ') },
|
||||
],
|
||||
}];
|
||||
},
|
||||
```
|
||||
> CSP را با build و مرور صفحات اصلی (home/doctor/clinic/blog/panel) تست کن؛ اگر چیزی بلاک شد (MUI inline style, JSON-LD)، با `'unsafe-inline'` فقط برای style یا nonce برای script حلش کن — نه باز کردن کامل.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **ترتیب:** اول پرامپت backend اجرا شود (قرارداد `grant`)، بعد این. تا قبل از آن، `oauth/token` همچنان `uuid` میخواهد؛ این پرامپت بر اساس قرارداد جدید (`grant`) نوشته شده.
|
||||
- App Router؛ Route Handlerها سرور-ساید هستند و به `process.env.CLIENT_SECRET` دسترسی دارند بدون افشا به مرورگر.
|
||||
- multi-domain را نشکن: `domain` کوکی را مثل کد فعلی بر اساس hostname ست کن (برای کوکی HttpOnly در Route Handler هم همان منطق domain را اعمال کن تا روی سابدامینهای شهرها کار کند).
|
||||
- بعد از تغییر env، مطمئن شو `CLIENT_SECRET` و `CLIENT_ID` (بدون `NEXT_PUBLIC`) در محیط deploy ست شدهاند (در `docker-compose.yml` از قبل `CLIENT_SECRET`/`CLIENT_ID` تعریف شده).
|
||||
- تست:
|
||||
- `npm run build` بدون خطا.
|
||||
- جریان لاگین کامل: OTP → access token در memory، refresh در کوکی HttpOnly (در DevTools → Application → Cookies باید `HttpOnly` ✓ و `Secure` ✓ باشد).
|
||||
- refresh صفحه → اپ از کوکی HttpOnly دوباره access token میگیرد و کاربر لاگین میماند.
|
||||
- در DevTools Console: `document.cookie` نباید `access_token`/`refresh_token` نشان دهد.
|
||||
- بدنهی بلاگ با payload تست `<img src=x onerror=alert(1)>` رندر شود ولی اجرا نشود (DOMPurify پاکش کند).
|
||||
- هدرهای امنیتی در Network tab روی پاسخ صفحات دیده شوند.
|
||||
- در bundle مرورگر (`.next/static`) رشتهی `client_secret` یا مقدار آن نباشد.
|
||||
@@ -2,10 +2,23 @@
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
export function Providers({ children }) {
|
||||
const router = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!Cookies.get("userInfo")) return;
|
||||
fetch("/api/auth/refresh", { method: "POST" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.access_token) setAccessToken(data.access_token);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute={router.includes("/panel") ? "class" : "data-"}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { axiosInstance } from "@/lib/req";
|
||||
import { clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
|
||||
|
||||
export async function POST(request) {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const host = request.headers.get("host");
|
||||
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
await axiosInstance.post(
|
||||
`${API_URL}/oauth/logout`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||
);
|
||||
} catch {
|
||||
// revoke best-effort; clearing the cookie below is what matters
|
||||
}
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||
clearRefreshCookie(response, host);
|
||||
return response;
|
||||
}
|
||||
@@ -1,28 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { axiosInstance } from "@/lib/req";
|
||||
import { setRefreshCookie, clearRefreshCookie, COOKIE_NAME } from "@/lib/refreshCookie";
|
||||
|
||||
export async function POST(request) {
|
||||
const { refresh_token } = await request.json();
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const host = request.headers.get("host");
|
||||
const refreshToken = request.cookies.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!refresh_token) {
|
||||
return NextResponse.json({ error: "refresh_token is required" }, { status: 400 });
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: "no refresh token" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.post(
|
||||
`${API_URL}/oauth/token/refresh`,
|
||||
{ refresh_token },
|
||||
{
|
||||
headers: { "Content-Type": "application/json", Authorization: "" },
|
||||
}
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||
);
|
||||
return NextResponse.json(res.data, { status: res.status });
|
||||
|
||||
const { access_token, refresh_token, expires_in } = res.data;
|
||||
|
||||
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
|
||||
setRefreshCookie(response, refresh_token, host);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
return NextResponse.json(error.response.data, { status: error.response.status });
|
||||
}
|
||||
return NextResponse.json({ error: "Refresh request failed" }, { status: 500 });
|
||||
const response = NextResponse.json(
|
||||
error.response?.data ?? { error: "Refresh request failed" },
|
||||
{ status: 401 }
|
||||
);
|
||||
clearRefreshCookie(response, host);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { axiosInstance } from "@/lib/req";
|
||||
import { setRefreshCookie } from "@/lib/refreshCookie";
|
||||
|
||||
export async function POST(request) {
|
||||
const { uuid, code } = await request.json();
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
if (!uuid || !code) {
|
||||
@@ -13,20 +13,28 @@ export async function POST(request) {
|
||||
const jsonHeaders = { "Content-Type": "application/json", Authorization: "" };
|
||||
|
||||
try {
|
||||
// مرحله ۱: تأیید OTP — بدون این، oauth/token کد را verified نمیبیند
|
||||
await axiosInstance.post(
|
||||
const verify = await axiosInstance.post(
|
||||
`${API_URL}/api/v1/user/verify-code`,
|
||||
{ uuid, code },
|
||||
{ headers: jsonHeaders }
|
||||
);
|
||||
|
||||
// مرحله ۲: تبدیل uuid تأییدشده به توکن
|
||||
const res = await axiosInstance.post(
|
||||
const grant = verify.data?.data?.grant;
|
||||
if (!grant) {
|
||||
return NextResponse.json({ error: "grant not issued" }, { status: 400 });
|
||||
}
|
||||
|
||||
const token = await axiosInstance.post(
|
||||
`${API_URL}/oauth/token`,
|
||||
{ grant_type: "mobile", uuid },
|
||||
{ grant_type: "mobile", grant },
|
||||
{ headers: jsonHeaders }
|
||||
);
|
||||
return NextResponse.json(res.data, { status: res.status });
|
||||
|
||||
const { access_token, refresh_token, expires_in } = token.data;
|
||||
|
||||
const response = NextResponse.json({ access_token, expires_in }, { status: 200 });
|
||||
setRefreshCookie(response, refresh_token, request.headers.get("host"));
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
return NextResponse.json(error.response.data, { status: error.response.status });
|
||||
|
||||
@@ -8,8 +8,13 @@ import { useState } from "react";
|
||||
function ModalLogout({ open, handleClose }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// clearing client state below is what matters
|
||||
}
|
||||
removeToken();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import Cookies from "js-cookie";
|
||||
|
||||
function SendAppo({ hour, setStep, setSelectedSlot, date, setSelectedDate }) {
|
||||
const sendReq = () => {
|
||||
const isLogged = Cookies.get("access_token");
|
||||
const isLogged = Cookies.get("userInfo");
|
||||
|
||||
// ذخیره کردن اطلاعات اسلات انتخاب شده
|
||||
if (hour && date) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { safeJsonParse } from "@/lib/sanitize";
|
||||
import { buildPatientUser } from "@/lib/representationAdapters";
|
||||
import { getServerAccessToken } from "@/lib/serverToken";
|
||||
|
||||
export default async function Dashboard({ searchParams }) {
|
||||
const awaitedSearchParams = await searchParams;
|
||||
@@ -16,9 +17,9 @@ export default async function Dashboard({ searchParams }) {
|
||||
|
||||
const ability = defineAbilitiesFor(user);
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("access_token");
|
||||
const token = await getServerAccessToken();
|
||||
|
||||
if (!ability.can("access", "Dashboard")) {
|
||||
if (!ability.can("access", "Dashboard") || !token) {
|
||||
removeToken();
|
||||
return redirect("/login");
|
||||
}
|
||||
@@ -27,7 +28,7 @@ export default async function Dashboard({ searchParams }) {
|
||||
// may still carry the OTP uuid, which 404s against user-profile.
|
||||
const userInfo = safeJsonParse(cookieStore.get("userInfo")?.value);
|
||||
const userUuid = userInfo?.uuid || cookieStore.get("uuid")?.value;
|
||||
const authHeader = { headers: { Authorization: `Bearer ${token.value}` } };
|
||||
const authHeader = { headers: { Authorization: `Bearer ${token}` } };
|
||||
const API = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const [profile, appointmentsRes, paymentsRes] = await Promise.all([
|
||||
@@ -54,7 +55,7 @@ export default async function Dashboard({ searchParams }) {
|
||||
|
||||
return (
|
||||
<Content
|
||||
logged={token.value}
|
||||
logged={true}
|
||||
params={awaitedSearchParams}
|
||||
matchedCity={matchedCity}
|
||||
user={buildPatientUser(profile, extras)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { safeJsonParse } from "@/lib/sanitize";
|
||||
import { adaptRepresentationDashboard } from "@/lib/representationAdapters";
|
||||
import { getServerAccessToken } from "@/lib/serverToken";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,21 +14,23 @@ async function Dashboard() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const userInfo = cookieStore.get("userInfo");
|
||||
const accessToken = cookieStore.get("access_token");
|
||||
|
||||
if (userInfo && accessToken) {
|
||||
if (userInfo) {
|
||||
const parsedUserInfo = safeJsonParse(userInfo.value);
|
||||
const representationUuid = parsedUserInfo?.representation_uuid;
|
||||
|
||||
if (representationUuid) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const accessToken = await getServerAccessToken();
|
||||
if (accessToken) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
|
||||
dashboard = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
|
||||
);
|
||||
dashboard = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { safeJsonParse } from "@/lib/sanitize";
|
||||
import { getServerAccessToken } from "@/lib/serverToken";
|
||||
|
||||
async function LayoutPanel({ children }) {
|
||||
const user = await getUser();
|
||||
@@ -20,22 +21,24 @@ async function LayoutPanel({ children }) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const userInfo = cookieStore.get("userInfo");
|
||||
const accessToken = cookieStore.get("access_token");
|
||||
|
||||
if (userInfo && accessToken) {
|
||||
if (userInfo) {
|
||||
const parsedUserInfo = safeJsonParse(userInfo.value);
|
||||
if (!parsedUserInfo) return;
|
||||
const representationUuid = parsedUserInfo?.representation_uuid;
|
||||
|
||||
if (representationUuid) {
|
||||
representationInfo = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken.value}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
const accessToken = await getServerAccessToken();
|
||||
if (accessToken) {
|
||||
representationInfo = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { safeJsonParse } from "@/lib/sanitize";
|
||||
import { adaptRepresentationDashboard } from "@/lib/representationAdapters";
|
||||
import { getServerAccessToken } from "@/lib/serverToken";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,21 +14,23 @@ async function page() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const userInfo = cookieStore.get("userInfo");
|
||||
const accessToken = cookieStore.get("access_token");
|
||||
|
||||
if (userInfo && accessToken) {
|
||||
if (userInfo) {
|
||||
const parsedUserInfo = safeJsonParse(userInfo.value);
|
||||
const representationUuid = parsedUserInfo?.representation_uuid;
|
||||
|
||||
if (representationUuid) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const accessToken = await getServerAccessToken();
|
||||
if (accessToken) {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
|
||||
dashboard = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
|
||||
);
|
||||
dashboard = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}/dashboard/monthly?year=${year}&month=${month}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cookies } from "next/headers";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { safeJsonParse } from "@/lib/sanitize";
|
||||
import { adaptBankAccount } from "@/lib/representationAdapters";
|
||||
import { getServerAccessToken } from "@/lib/serverToken";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,17 +14,19 @@ async function UserAccount() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const userInfo = cookieStore.get("userInfo");
|
||||
const accessToken = cookieStore.get("access_token");
|
||||
|
||||
if (userInfo && accessToken) {
|
||||
if (userInfo) {
|
||||
const parsedUserInfo = safeJsonParse(userInfo.value);
|
||||
const representationUuid = parsedUserInfo?.representation_uuid;
|
||||
|
||||
if (representationUuid) {
|
||||
representation = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken.value}` } }
|
||||
);
|
||||
const accessToken = await getServerAccessToken();
|
||||
if (accessToken) {
|
||||
representation = await fetchReq(
|
||||
`${API_URL}/api/v1/representation/${representationUuid}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@mui/material";
|
||||
import ArrowLeftB from "@/components/icons/ArrowLeftB";
|
||||
import { request } from "@/services/response";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -11,21 +12,12 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
try {
|
||||
const refreshToken = Cookies.get("refresh_token");
|
||||
if (!refreshToken) return false;
|
||||
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
const res = await fetch("/api/auth/refresh", { method: "POST" });
|
||||
if (!res.ok) return false;
|
||||
const response = await res.json();
|
||||
|
||||
if (response?.access_token) {
|
||||
Cookies.set("access_token", response.access_token, { expires: 7 });
|
||||
if (response.refresh_token) {
|
||||
Cookies.set("refresh_token", response.refresh_token, { expires: 7 });
|
||||
}
|
||||
setAccessToken(response.access_token);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -12,7 +12,6 @@ import Surgeries from "./surgeries";
|
||||
import FamilyHistory from "./familyHistory";
|
||||
import Relatives from "./relatives";
|
||||
import { removeAdditionalKeysDashboard } from "@/helper";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import LoadingComponent from "@/app/component/LoadingComponent";
|
||||
|
||||
@@ -37,11 +36,7 @@ function DetailUser({ user, isTurnsDetails, setIsTurnsDetails }) {
|
||||
const usedKays = removeAdditionalKeysDashboard(information);
|
||||
setLoading(true);
|
||||
request
|
||||
.patchUserProfile(
|
||||
usedKays,
|
||||
information?.uuid,
|
||||
Cookies.get("access_token")
|
||||
)
|
||||
.patchUserProfile(usedKays, information?.uuid)
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
toast.success("اطلاعات با موفقیت ثبت شد");
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import ArrowLeftWhiteD from "@/components/icons/ArrowLeftWhiteD";
|
||||
import {
|
||||
changeDateType,
|
||||
handleTimeExpiresToken,
|
||||
isValidIranNationalCode,
|
||||
removeAdditionalKeysDashboard,
|
||||
} from "@/helper";
|
||||
import { request } from "@/services/response";
|
||||
import { Button, CircularProgress } from "@mui/material";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import { setAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
function ButtonSendData({
|
||||
loading,
|
||||
@@ -31,21 +30,11 @@ function ButtonSendData({
|
||||
.postUserProfile(changeDateType(information, true))
|
||||
.then((response) => {
|
||||
if (response.uuid) {
|
||||
fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: Cookies.get("refresh_token") }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
fetch("/api/auth/refresh", { method: "POST" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((res) => {
|
||||
if (res?.access_token) {
|
||||
const expiresTime = handleTimeExpiresToken(res.expires_in);
|
||||
Cookies.set("access_token", res.access_token, {
|
||||
expires: expiresTime.accessTokenExpires,
|
||||
path: "/",
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
setAccessToken(res.access_token);
|
||||
}
|
||||
setInformation({
|
||||
...information,
|
||||
@@ -69,11 +58,7 @@ function ButtonSendData({
|
||||
const usedKays = removeAdditionalKeysDashboard(information);
|
||||
|
||||
request
|
||||
.patchUserProfile(
|
||||
changeDateType(usedKays, true),
|
||||
information?.uuid,
|
||||
Cookies.get("access_token")
|
||||
)
|
||||
.patchUserProfile(changeDateType(usedKays, true), information?.uuid)
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
toast.success("اطلاعات با موفقیت ثبت شد");
|
||||
|
||||
@@ -6,14 +6,14 @@ import { cookies } from "next/headers";
|
||||
async function StLayout({ children, name }) {
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("access_token");
|
||||
const isLogged = Boolean(cookieStore.get("userInfo"));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<main className="w-full min-h-screen">
|
||||
<header className="w-full sticky bg-[#FFF] !z-[70] shadow-[0px_1px_24.8px_0px_rgba(204,_204,_204,_0.18)] mt-[12px] sm:mt-[21px] md:mt-[30px] lg:mt-[40px] top-0 right-1/2">
|
||||
<div className="w-full padding-responsive">
|
||||
<Header matchedCity={matchedCity} name={name} logged={token} />
|
||||
<Header matchedCity={matchedCity} name={name} logged={isLogged} />
|
||||
</div>
|
||||
</header>
|
||||
<section className="opacity-page -mt-[calc(48px_+_12px)] sm:-mt-[calc(56px_+_21px)] md:-mt-[calc(64px_+_30px)] lg:-mt-[calc(80px_+_40px)]">
|
||||
|
||||
@@ -18,7 +18,7 @@ function Content({ matchedCity, name, logged }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setIsLogged(Boolean(Cookies.get("access_token")));
|
||||
setIsLogged(Boolean(Cookies.get("userInfo")));
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { request } from "@/services/response";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import { handleTimeExpiresToken } from "@/helper";
|
||||
import { setAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
function SendReq({
|
||||
loading,
|
||||
@@ -44,14 +45,13 @@ function SendReq({
|
||||
const isHttps = window.location.protocol === "https:";
|
||||
const isLocalhost = hostname.includes("localhost");
|
||||
|
||||
// تنظیمات پایه
|
||||
// فقط دادههای غیرحساس در کوکی JS؛ access_token در memory، refresh_token در کوکی HttpOnly سرور
|
||||
let cookieOptions = {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
expires: expiresTime.accessTokenExpires,
|
||||
expires: expiresTime.refreshTokenExpires,
|
||||
};
|
||||
|
||||
// اگر روی دامنه اصلی و https بود
|
||||
if (!isLocalhost && isHttps) {
|
||||
cookieOptions = {
|
||||
...cookieOptions,
|
||||
@@ -60,17 +60,8 @@ function SendReq({
|
||||
};
|
||||
}
|
||||
|
||||
Cookies.set("access_token", response.access_token, cookieOptions);
|
||||
|
||||
Cookies.set("refresh_token", response.refresh_token, {
|
||||
...cookieOptions,
|
||||
expires: expiresTime.refreshTokenExpires,
|
||||
});
|
||||
|
||||
Cookies.set("uuid", uuid, {
|
||||
...cookieOptions,
|
||||
expires: expiresTime.refreshTokenExpires,
|
||||
});
|
||||
setAccessToken(response.access_token);
|
||||
Cookies.set("uuid", uuid, cookieOptions);
|
||||
|
||||
getInfo(response.access_token, cookieOptions);
|
||||
};
|
||||
|
||||
+4
-7
@@ -2,11 +2,8 @@ import { cookies } from "next/headers";
|
||||
|
||||
export async function getUser() {
|
||||
const cookieStore = await cookies();
|
||||
const raw =
|
||||
cookieStore.get("access_token") &&
|
||||
cookieStore.get("refresh_token") &&
|
||||
cookieStore.get("uuid") &&
|
||||
cookieStore.get("userInfo");
|
||||
if (!raw) return null;
|
||||
return raw;
|
||||
const isAuthenticated =
|
||||
cookieStore.get("refresh_token") && cookieStore.get("userInfo");
|
||||
if (!isAuthenticated) return null;
|
||||
return cookieStore.get("userInfo");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const COOKIE_NAME = "refresh_token";
|
||||
|
||||
function cookieDomain(host) {
|
||||
if (!host) return undefined;
|
||||
const hostname = host.split(":")[0];
|
||||
if (hostname.includes("localhost") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
return "." + hostname.split(".").slice(-2).join(".");
|
||||
}
|
||||
|
||||
export function setRefreshCookie(response, refreshToken, host, maxAge = 60 * 60 * 24 * 30) {
|
||||
response.cookies.set(COOKIE_NAME, refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge,
|
||||
domain: cookieDomain(host),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRefreshCookie(response, host) {
|
||||
response.cookies.set(COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
domain: cookieDomain(host),
|
||||
});
|
||||
}
|
||||
|
||||
export { COOKIE_NAME };
|
||||
+10
-12
@@ -1,19 +1,17 @@
|
||||
const DANGEROUS_TAGS = ['script', 'iframe', 'object', 'embed', 'link', 'meta', 'base', 'form'];
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
if (!html || typeof html !== 'string') return '';
|
||||
if (!html || typeof html !== "string") return "";
|
||||
|
||||
let sanitized = html;
|
||||
|
||||
DANGEROUS_TAGS.forEach((tag) => {
|
||||
const openClose = new RegExp(`<${tag}[\\s\\S]*?(?:<\\/${tag}>|/?>)`, 'gi');
|
||||
sanitized = sanitized.replace(openClose, '');
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
"p", "br", "strong", "em", "b", "i", "u", "ul", "ol", "li", "a",
|
||||
"h2", "h3", "h4", "h5", "blockquote", "img", "span", "div",
|
||||
"table", "thead", "tbody", "tr", "td", "th",
|
||||
],
|
||||
ALLOWED_ATTR: ["href", "target", "rel", "src", "alt", "title"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
||||
sanitized = sanitized.replace(/(?:javascript|vbscript):/gi, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function safeJsonParse(str, fallback = null) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { axiosInstance } from "@/lib/req";
|
||||
|
||||
export async function getServerAccessToken() {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get("refresh_token")?.value;
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/oauth/token/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: "" } }
|
||||
);
|
||||
return res.data?.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
let accessToken = null;
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function setAccessToken(token) {
|
||||
accessToken = token || null;
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
accessToken = null;
|
||||
}
|
||||
@@ -40,6 +40,34 @@ const nextConfig = {
|
||||
poweredByHeader: false,
|
||||
// Enable standalone output for Docker
|
||||
output: 'standalone',
|
||||
async headers() {
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' https: data: blob:",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://api.clinic-pro.ir https://back-dev.clinic-pro.ir",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join('; ');
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
|
||||
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||
{ key: 'Content-Security-Policy', value: csp },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
Generated
+511
-4
@@ -26,6 +26,7 @@
|
||||
"dayjs": "^1.11.19",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i": "^0.3.7",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"jalali-moment": "^3.3.11",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -68,6 +69,53 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/generational-cache": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/nwsapi": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -207,6 +255,18 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-tree": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@casl/ability": {
|
||||
"version": "6.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.3.tgz",
|
||||
@@ -229,6 +289,140 @@
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz",
|
||||
"integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
|
||||
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"peerDependencies": {
|
||||
"css-tree": "^3.2.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"css-tree": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
|
||||
@@ -404,6 +598,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||
@@ -1978,6 +2189,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -2368,6 +2588,19 @@
|
||||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.27.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2510,6 +2743,19 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
@@ -2552,6 +2798,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
|
||||
@@ -2620,11 +2872,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -2649,6 +2900,18 @@
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
@@ -3066,6 +3329,18 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
@@ -3283,6 +3558,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-unicode-supported": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||
@@ -3302,6 +3583,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic-dompurify": {
|
||||
"version": "3.18.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.18.0.tgz",
|
||||
"integrity": "sha512-ajp0D8laIHeoYlhBTevpE2HUhqWaqLXFk6K/wV3Ok8kDraBZpZsifwVWaY8IfJntMRIo1VSksgKV+lXyet9Q7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.11",
|
||||
"jsdom": "^29.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jalaali-js": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
|
||||
@@ -3352,6 +3646,46 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -3479,6 +3813,15 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.6.2.tgz",
|
||||
@@ -3525,6 +3868,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -5886,6 +6235,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -6203,6 +6564,15 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -6428,6 +6798,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
@@ -6584,6 +6963,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -6988,6 +7379,12 @@
|
||||
"node": ">= 4.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -7113,6 +7510,24 @@
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
|
||||
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
|
||||
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -7125,6 +7540,30 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^7.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@@ -7149,6 +7588,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/uninstall": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uninstall/-/uninstall-0.0.0.tgz",
|
||||
@@ -7183,6 +7631,18 @@
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
@@ -7192,6 +7652,38 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@exodus/bytes": "^1.11.0",
|
||||
"tr46": "^6.0.0",
|
||||
"webidl-conversions": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -7228,6 +7720,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
+3
-2
@@ -27,6 +27,7 @@
|
||||
"dayjs": "^1.11.19",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i": "^0.3.7",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"jalali-moment": "^3.3.11",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -35,6 +36,7 @@
|
||||
"next": "^15.5.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"npm": "^11.7.0",
|
||||
"postcss": "^8.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -49,7 +51,6 @@
|
||||
"stylis-plugin-rtl": "^2.1.1",
|
||||
"swiper": "^12.0.3",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"postcss": "^8.5.6",
|
||||
"uninstall": "^0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -60,4 +61,4 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-24
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "react-toastify";
|
||||
import { getAccessToken, setAccessToken, clearAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
function extractErrorMessage(error) {
|
||||
const errors = error?.response?.data?.errors;
|
||||
@@ -17,48 +18,77 @@ const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
"X-CSRF-Token": "",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// اضافه کردن interceptor برای افزودن token به ریکوستهایی که نیاز به احراز هویت دارند
|
||||
let refreshPromise = null;
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = fetch("/api/auth/refresh", { method: "POST" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
const token = data?.access_token ?? null;
|
||||
setAccessToken(token);
|
||||
return token;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function handleSessionExpired() {
|
||||
clearAccessToken();
|
||||
const hostname = window.location.hostname;
|
||||
const domain = hostname.includes("localhost")
|
||||
? undefined
|
||||
: `.${hostname.split(".").slice(-2).join(".")}`;
|
||||
const opts = { path: "/", ...(domain && { domain }) };
|
||||
["uuid", "userInfo"].forEach((key) => Cookies.remove(key, opts));
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// فقط اگر requireAuth در config تنظیم شده باشد، token اضافه میشود
|
||||
if (config.requireAuth) {
|
||||
const token = Cookies.get("access_token");
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (error?.response?.status === 401) {
|
||||
const hostname = window.location.hostname;
|
||||
const domain = hostname.includes("localhost")
|
||||
? undefined
|
||||
: `.${hostname.split(".").slice(-2).join(".")}`;
|
||||
const opts = { path: "/", ...(domain && { domain }) };
|
||||
["access_token", "refresh_token", "uuid", "userInfo"].forEach((key) => {
|
||||
Cookies.remove(key, opts);
|
||||
});
|
||||
window.location.href = "/login";
|
||||
} else if (!error?.config?.skipErrorToast) {
|
||||
toast.error(extractErrorMessage(error));
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const original = error?.config;
|
||||
|
||||
if (
|
||||
error?.response?.status === 401 &&
|
||||
original?.requireAuth &&
|
||||
!original._retried &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
original._retried = true;
|
||||
const token = await refreshAccessToken();
|
||||
if (token) {
|
||||
original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
|
||||
return api(original);
|
||||
}
|
||||
handleSessionExpired();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !error?.config?.skipErrorToast) {
|
||||
toast.error(extractErrorMessage(error));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -11,30 +11,6 @@ export const request = {
|
||||
},
|
||||
removeTokenHead
|
||||
),
|
||||
getToken: (grant_type, client_id, client_secret, uuid, code, scope = "nobat724") => {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("grant_type", grant_type);
|
||||
formData.append("client_id", client_id);
|
||||
formData.append("client_secret", client_secret);
|
||||
formData.append("uuid", uuid);
|
||||
formData.append("code", code);
|
||||
formData.append("scope", scope);
|
||||
|
||||
return api.post("oauth/token", formData, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: "",
|
||||
},
|
||||
});
|
||||
},
|
||||
postRefreshToken: (formData) => {
|
||||
return api.post("oauth/token", formData, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: "",
|
||||
},
|
||||
});
|
||||
},
|
||||
getUserProfile: (uuid) => api.get(`/api/v1/user-profile/${uuid}`, { requireAuth: true }),
|
||||
postUserProfile: (data) => api.post("api/v1/user-profile", data, { requireAuth: true }),
|
||||
patchUserProfile: (data, uuid) =>
|
||||
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { clearAccessToken } from "@/lib/tokenStore";
|
||||
|
||||
const removeToken = () => {
|
||||
Cookies.remove("access_token", { path: "/" });
|
||||
Cookies.remove("refresh_token", { path: "/" });
|
||||
Cookies.remove("uuid", { path: "/" });
|
||||
Cookies.remove("userInfo", { path: "/" });
|
||||
if (typeof window !== "undefined") {
|
||||
clearAccessToken();
|
||||
}
|
||||
};
|
||||
|
||||
export { removeToken };
|
||||
|
||||
Reference in New Issue
Block a user