feat(auth): 重构认证逻辑,支持 HttpOnly Cookie 登录;移除不再使用的 Token 存储方式
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
This commit is contained in:
@@ -136,8 +136,6 @@ export function AdminNumericStepper({
|
||||
inputMode={integer ? "numeric" : "decimal"}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-invalid={outOfRange || undefined}
|
||||
className={cn(
|
||||
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSidebarNav,
|
||||
@@ -28,9 +28,12 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
<SidebarMenu className="h-full w-full">
|
||||
<SidebarMenuItem className="h-full">
|
||||
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||
<img
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={160}
|
||||
height={40}
|
||||
priority
|
||||
className="h-auto max-h-10 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -42,9 +45,11 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src="/image6.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="var(--sidebar-width)"
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
@@ -83,9 +88,12 @@ export function AdminAppSidebar() {
|
||||
className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||
<img
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="N lotto"
|
||||
width={160}
|
||||
height={40}
|
||||
priority
|
||||
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -107,9 +115,11 @@ export function AdminAppSidebar() {
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src="/image6.png"
|
||||
alt=""
|
||||
fill
|
||||
sizes="var(--sidebar-width)"
|
||||
className="h-full w-full object-cover object-bottom"
|
||||
/>
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
|
||||
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
|
||||
type ShellAuthGateProps = {
|
||||
children: ReactNode;
|
||||
@@ -14,13 +13,8 @@ type ShellAuthGateProps = {
|
||||
|
||||
type GateStatus = "pending" | "authed" | "guest";
|
||||
|
||||
function hasAdminToken(bearerToken: string | null): boolean {
|
||||
const token = bearerToken ?? readToken();
|
||||
return token != null && token.trim() !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell 路由守卫:无 Token 或 `/auth/me` 校验失败时跳转登录页。
|
||||
* Shell 路由守卫:无有效 HttpOnly Cookie 会话或 `/auth/me` 校验失败时跳转登录页。
|
||||
*/
|
||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
const router = useRouter();
|
||||
@@ -34,13 +28,6 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
setShellAuthPending(true);
|
||||
|
||||
async function run() {
|
||||
if (!hasAdminToken(bearerToken)) {
|
||||
if (!cancelled) {
|
||||
setStatus("guest");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setStatus("pending");
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
validateAdminLoginAccount,
|
||||
validateAdminPassword,
|
||||
} from "@/lib/admin-input-validation";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
import { authModuleMeta } from "@/modules/auth/meta";
|
||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -60,20 +59,12 @@ export function LoginForm() {
|
||||
} finally {
|
||||
setLoadingCaptcha(false);
|
||||
}
|
||||
}, []);
|
||||
}, [tRef]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function bootstrap() {
|
||||
if (!readToken()) {
|
||||
if (!cancelled) {
|
||||
setCheckingSession(false);
|
||||
void loadCaptcha();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await verifyStoredAdminSession();
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -131,7 +122,7 @@ export function LoginForm() {
|
||||
captcha_key: captchaKey,
|
||||
captcha_code: captchaCode.trim(),
|
||||
});
|
||||
setBearerToken(result.token);
|
||||
setBearerToken(result.token ?? "__cookie__");
|
||||
setAdminProfile(result.admin);
|
||||
toast.success(
|
||||
t("welcome", { name: result.admin.nickname || result.admin.username }),
|
||||
@@ -278,11 +269,12 @@ export function LoginForm() {
|
||||
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
||||
>
|
||||
{captchaSrc ? (
|
||||
<img
|
||||
<Image
|
||||
src={captchaSrc}
|
||||
alt=""
|
||||
width={160}
|
||||
height={48}
|
||||
unoptimized
|
||||
className="pointer-events-none block h-full w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useAdminProfile,
|
||||
useAdminSessionStore,
|
||||
} from "@/stores/admin-session";
|
||||
import { clearAdminSessionCookie } from "@/lib/admin-http";
|
||||
|
||||
export function ShellToolbar() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -35,6 +36,7 @@ export function ShellToolbar() {
|
||||
t("toolbar.defaultAdmin");
|
||||
|
||||
function onLogout() {
|
||||
void clearAdminSessionCookie();
|
||||
clearSession();
|
||||
toast.success(t("toolbar.loggedOut"));
|
||||
router.replace("/admin/login");
|
||||
|
||||
@@ -102,8 +102,12 @@ export function handleAdminAuthRejected(): void {
|
||||
|
||||
authRejectHandling = true;
|
||||
|
||||
const hadSession = readToken() != null;
|
||||
getAdminSessionState().clearSession();
|
||||
const session = getAdminSessionState();
|
||||
const hadSession = session.bearerToken != null || readToken() != null;
|
||||
void fetch("/api/v1/admin/auth/logout", { method: "POST" }).catch(() => {
|
||||
// Best-effort HttpOnly cookie cleanup; the local session is cleared below.
|
||||
});
|
||||
session.clearSession();
|
||||
|
||||
if (hadSession) {
|
||||
toast.error(
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getAdminBearerTokenPayload(): string | null {
|
||||
export function withAdminAuthHeader(
|
||||
config: AxiosRequestConfig,
|
||||
): AxiosRequestConfig {
|
||||
if (!adminBearerPayload) {
|
||||
if (!adminBearerPayload || adminBearerPayload === "__cookie__") {
|
||||
return config;
|
||||
}
|
||||
const merged: AxiosRequestConfig = { ...config };
|
||||
|
||||
@@ -125,3 +125,11 @@ export const adminRequest = {
|
||||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||||
) => request<T>({ ...config, url, method: "PUT", data }),
|
||||
};
|
||||
|
||||
export async function clearAdminSessionCookie(): Promise<void> {
|
||||
await adminHttp.request({
|
||||
url: "/admin/auth/logout",
|
||||
method: "POST",
|
||||
validateStatus: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
|
||||
import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
|
||||
import { getAdminSessionState } from "@/stores/admin-session";
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
|
||||
/**
|
||||
* 用 `/auth/me` 校验本地 Token 是否仍有效;失败时清会话(不跳转,由调用方决定)。
|
||||
* 用 `/auth/me` 校验 HttpOnly Cookie 会话是否仍有效;失败时清会话(不跳转,由调用方决定)。
|
||||
*/
|
||||
export async function verifyStoredAdminSession(): Promise<boolean> {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = getAdminSessionState();
|
||||
session.setBearerToken(token);
|
||||
session.setBearerToken("__cookie__");
|
||||
|
||||
try {
|
||||
const result = await fetchAdminMeDeduped();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */
|
||||
/** HttpOnly Cookie 键名,须与 middleware / API proxy 一致 */
|
||||
export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
|
||||
|
||||
/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */
|
||||
|
||||
@@ -3,6 +3,13 @@ import {
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
} from "@/lib/admin-token-constants";
|
||||
|
||||
const COOKIE_BASE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS,
|
||||
} as const;
|
||||
|
||||
export function readAdminTokenFromCookieString(
|
||||
cookieHeader: string | null | undefined,
|
||||
): string | null {
|
||||
@@ -32,30 +39,24 @@ export function readAdminTokenFromCookieString(
|
||||
}
|
||||
|
||||
export function readAdminTokenFromDocumentCookie(): string | null {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readAdminTokenFromCookieString(document.cookie);
|
||||
}
|
||||
|
||||
export function writeAdminTokenCookie(token: string | null): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
void token;
|
||||
}
|
||||
|
||||
const secure =
|
||||
typeof window !== "undefined" && window.location.protocol === "https:";
|
||||
const base = `path=/; SameSite=Lax`;
|
||||
|
||||
if (!token || token.trim() === "") {
|
||||
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`;
|
||||
return;
|
||||
export function adminTokenCookieOptions(secure: boolean) {
|
||||
return {
|
||||
...COOKIE_BASE_OPTIONS,
|
||||
secure,
|
||||
};
|
||||
}
|
||||
|
||||
const value = encodeURIComponent(token.trim());
|
||||
const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`;
|
||||
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${
|
||||
secure ? "; Secure" : ""
|
||||
}`;
|
||||
export function expiredAdminTokenCookieOptions(secure: boolean) {
|
||||
return {
|
||||
...COOKIE_BASE_OPTIONS,
|
||||
secure,
|
||||
maxAge: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
adminTokenCookieOptions,
|
||||
expiredAdminTokenCookieOptions,
|
||||
readAdminTokenFromCookieString,
|
||||
} from "@/lib/admin-token-cookie";
|
||||
import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
|
||||
import { lotteryApiOrigin } from "@/lib/lottery-api-base";
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
@@ -27,12 +33,23 @@ export async function proxyLotteryApi(
|
||||
): Promise<NextResponse> {
|
||||
const path = pathSegments.join("/");
|
||||
const target = `${lotteryApiOrigin()}/api/${path}${request.nextUrl.search}`;
|
||||
const isAdminAuthLogin =
|
||||
request.method === "POST" && path === "v1/admin/auth/login";
|
||||
const isAdminLogout =
|
||||
request.method === "POST" && path === "v1/admin/auth/logout";
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
headers.delete("accept-encoding");
|
||||
|
||||
const cookieToken = readAdminTokenFromCookieString(
|
||||
request.headers.get("cookie"),
|
||||
);
|
||||
if (cookieToken && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${cookieToken}`);
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
@@ -61,9 +78,108 @@ export async function proxyLotteryApi(
|
||||
responseHeaders.delete(name);
|
||||
}
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
if (isAdminAuthLogin) {
|
||||
return handleAdminLoginResponse(request, upstream, responseHeaders);
|
||||
}
|
||||
|
||||
const response = new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
if (isAdminLogout) {
|
||||
response.cookies.set(
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
"",
|
||||
expiredAdminTokenCookieOptions(isSecureRequest(request)),
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleAdminLoginResponse(
|
||||
request: NextRequest,
|
||||
upstream: Response,
|
||||
responseHeaders: Headers,
|
||||
): Promise<NextResponse> {
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await upstream.json();
|
||||
} catch {
|
||||
const response = NextResponse.json(
|
||||
{ msg: "Invalid login response from upstream" },
|
||||
{ status: 502 },
|
||||
);
|
||||
response.cookies.set(
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
"",
|
||||
expiredAdminTokenCookieOptions(request.nextUrl.protocol === "https:"),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const token = extractAdminToken(payload);
|
||||
if (token && upstream.ok) {
|
||||
stripAdminToken(payload);
|
||||
}
|
||||
|
||||
responseHeaders.delete("content-type");
|
||||
const response = NextResponse.json(payload, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
const secure = isSecureRequest(request);
|
||||
if (token && upstream.ok) {
|
||||
response.cookies.set(
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
token,
|
||||
adminTokenCookieOptions(secure),
|
||||
);
|
||||
} else {
|
||||
response.cookies.set(
|
||||
ADMIN_TOKEN_STORAGE_KEY,
|
||||
"",
|
||||
expiredAdminTokenCookieOptions(secure),
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function isSecureRequest(request: NextRequest): boolean {
|
||||
return (
|
||||
request.nextUrl.protocol === "https:" ||
|
||||
request.headers.get("x-forwarded-proto") === "https"
|
||||
);
|
||||
}
|
||||
|
||||
function extractAdminToken(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (payload as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object" || !("token" in data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = (data as { token?: unknown }).token;
|
||||
return typeof token === "string" && token.trim() !== "" ? token.trim() : null;
|
||||
}
|
||||
|
||||
function stripAdminToken(payload: unknown): void {
|
||||
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (payload as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
delete (data as { token?: unknown }).token;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function readTokenFromRequest(request: NextRequest): string | null {
|
||||
return readAdminTokenFromCookieString(request.headers.get("cookie"));
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/admin")) {
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 管理端会话:Bearer Token + 登录接口返回的 {@link AdminProfile}。
|
||||
* 管理端会话:HttpOnly Cookie 登录态 + 登录接口返回的 {@link AdminProfile}。
|
||||
*
|
||||
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
||||
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isAdminAuthRejected,
|
||||
} from "@/lib/admin-auth-reject";
|
||||
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||
import { readToken, writeToken } from "@/stores/admin-token";
|
||||
import { writeToken } from "@/stores/admin-token";
|
||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||
|
||||
export type AdminSessionState = {
|
||||
@@ -25,7 +25,7 @@ export type AdminSessionState = {
|
||||
setBearerToken: (token: string | null) => void;
|
||||
setAdminProfile: (profile: AdminProfile | null) => void;
|
||||
clearSession: () => void;
|
||||
/** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */
|
||||
/** 从 HttpOnly Cookie 会话恢复管理员摘要(仅客户端) */
|
||||
rehydrate: () => void;
|
||||
refreshAdminProfile: () => Promise<void>;
|
||||
/** @deprecated 使用 {@link clearSession} */
|
||||
@@ -70,24 +70,13 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const token = readToken();
|
||||
const profile = readProfile();
|
||||
if (token) {
|
||||
setAdminBearerToken(token);
|
||||
set({ bearerToken: token, adminProfile: profile });
|
||||
void get().refreshAdminProfile();
|
||||
} else {
|
||||
setAdminBearerToken(null);
|
||||
set({ bearerToken: null, adminProfile: null });
|
||||
}
|
||||
set({ bearerToken: "__cookie__", adminProfile: profile });
|
||||
void get().refreshAdminProfile();
|
||||
},
|
||||
|
||||
refreshAdminProfile: async () => {
|
||||
const token = get().bearerToken ?? readToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchAdminMeDeduped();
|
||||
writeProfile(result.admin);
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
|
||||
import {
|
||||
readAdminTokenFromDocumentCookie,
|
||||
writeAdminTokenCookie,
|
||||
} from "@/lib/admin-token-cookie";
|
||||
|
||||
export function readToken(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim();
|
||||
if (fromStorage) {
|
||||
return fromStorage;
|
||||
}
|
||||
|
||||
return readAdminTokenFromDocumentCookie();
|
||||
}
|
||||
|
||||
export function writeToken(t: string | null): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (t && t.trim() !== "") {
|
||||
const normalized = t.trim();
|
||||
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, normalized);
|
||||
writeAdminTokenCookie(normalized);
|
||||
} else {
|
||||
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
writeAdminTokenCookie(null);
|
||||
}
|
||||
void t;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export type AdminAccountKind =
|
||||
| "agent_operator"
|
||||
| "platform_account";
|
||||
|
||||
/** 登录成功后缓存于会话(localStorage)的管理员摘要 */
|
||||
/** 登录成功后缓存于会话的管理员摘要 */
|
||||
export type AdminProfile = {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -52,8 +52,8 @@ export type AdminProfile = {
|
||||
|
||||
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
|
||||
export type AdminAuthLoginResponse = {
|
||||
token: string;
|
||||
token_type: string;
|
||||
token?: string;
|
||||
token_type?: string;
|
||||
admin: AdminProfile;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user