feat(auth): 重构认证逻辑,支持 HttpOnly Cookie 登录;移除不再使用的 Token 存储方式
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-06-30 11:41:57 +08:00
parent ba77dc1f5a
commit 6e248207ff
16 changed files with 193 additions and 118 deletions

View File

@@ -136,8 +136,6 @@ export function AdminNumericStepper({
inputMode={integer ? "numeric" : "decimal"} inputMode={integer ? "numeric" : "decimal"}
value={value} value={value}
disabled={disabled} disabled={disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-invalid={outOfRange || undefined} aria-invalid={outOfRange || undefined}
className={cn( className={cn(
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none", "h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMemo, type ReactElement } from "react"; import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { import {
AdminSidebarNav, AdminSidebarNav,
@@ -28,9 +28,12 @@ function AdminSidebarSkeleton(): ReactElement {
<SidebarMenu className="h-full w-full"> <SidebarMenu className="h-full w-full">
<SidebarMenuItem className="h-full"> <SidebarMenuItem className="h-full">
<div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center"> <div className="flex h-10 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
<img <Image
src="/logo.png" src="/logo.png"
alt="N lotto" 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" 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> </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" className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
aria-hidden aria-hidden
> >
<img <Image
src="/image6.png" src="/image6.png"
alt="" alt=""
fill
sizes="var(--sidebar-width)"
className="h-full w-full object-cover object-bottom" 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" /> <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" 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"> <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" src="/logo.png"
alt="N lotto" 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" 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> </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" className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
aria-hidden aria-hidden
> >
<img <Image
src="/image6.png" src="/image6.png"
alt="" alt=""
fill
sizes="var(--sidebar-width)"
className="h-full w-full object-cover object-bottom" 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" /> <div className="absolute inset-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />

View File

@@ -6,7 +6,6 @@ import { useEffect, useState, type ReactNode } from "react";
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking"; import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
import { verifyStoredAdminSession } from "@/lib/admin-session-verify"; import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
import { useAdminSessionStore } from "@/stores/admin-session"; import { useAdminSessionStore } from "@/stores/admin-session";
import { readToken } from "@/stores/admin-token";
type ShellAuthGateProps = { type ShellAuthGateProps = {
children: ReactNode; children: ReactNode;
@@ -14,13 +13,8 @@ type ShellAuthGateProps = {
type GateStatus = "pending" | "authed" | "guest"; 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) { export function ShellAuthGate({ children }: ShellAuthGateProps) {
const router = useRouter(); const router = useRouter();
@@ -34,13 +28,6 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
setShellAuthPending(true); setShellAuthPending(true);
async function run() { async function run() {
if (!hasAdminToken(bearerToken)) {
if (!cancelled) {
setStatus("guest");
}
return;
}
if (!cancelled) { if (!cancelled) {
setStatus("pending"); setStatus("pending");
} }

View File

@@ -21,7 +21,6 @@ import {
validateAdminLoginAccount, validateAdminLoginAccount,
validateAdminPassword, validateAdminPassword,
} from "@/lib/admin-input-validation"; } from "@/lib/admin-input-validation";
import { readToken } from "@/stores/admin-token";
import { authModuleMeta } from "@/modules/auth/meta"; import { authModuleMeta } from "@/modules/auth/meta";
import { useAdminSessionStore } from "@/stores/admin-session"; import { useAdminSessionStore } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -60,20 +59,12 @@ export function LoginForm() {
} finally { } finally {
setLoadingCaptcha(false); setLoadingCaptcha(false);
} }
}, []); }, [tRef]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function bootstrap() { async function bootstrap() {
if (!readToken()) {
if (!cancelled) {
setCheckingSession(false);
void loadCaptcha();
}
return;
}
const ok = await verifyStoredAdminSession(); const ok = await verifyStoredAdminSession();
if (cancelled) { if (cancelled) {
return; return;
@@ -131,7 +122,7 @@ export function LoginForm() {
captcha_key: captchaKey, captcha_key: captchaKey,
captcha_code: captchaCode.trim(), captcha_code: captchaCode.trim(),
}); });
setBearerToken(result.token); setBearerToken(result.token ?? "__cookie__");
setAdminProfile(result.admin); setAdminProfile(result.admin);
toast.success( toast.success(
t("welcome", { name: result.admin.nickname || result.admin.username }), t("welcome", { name: result.admin.nickname || result.admin.username }),
@@ -278,11 +269,12 @@ export function LoginForm() {
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")} aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
> >
{captchaSrc ? ( {captchaSrc ? (
<img <Image
src={captchaSrc} src={captchaSrc}
alt="" alt=""
width={160} width={160}
height={48} height={48}
unoptimized
className="pointer-events-none block h-full w-auto max-w-full object-contain" className="pointer-events-none block h-full w-auto max-w-full object-contain"
/> />
) : ( ) : (

View File

@@ -22,6 +22,7 @@ import {
useAdminProfile, useAdminProfile,
useAdminSessionStore, useAdminSessionStore,
} from "@/stores/admin-session"; } from "@/stores/admin-session";
import { clearAdminSessionCookie } from "@/lib/admin-http";
export function ShellToolbar() { export function ShellToolbar() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
@@ -35,6 +36,7 @@ export function ShellToolbar() {
t("toolbar.defaultAdmin"); t("toolbar.defaultAdmin");
function onLogout() { function onLogout() {
void clearAdminSessionCookie();
clearSession(); clearSession();
toast.success(t("toolbar.loggedOut")); toast.success(t("toolbar.loggedOut"));
router.replace("/admin/login"); router.replace("/admin/login");

View File

@@ -102,8 +102,12 @@ export function handleAdminAuthRejected(): void {
authRejectHandling = true; authRejectHandling = true;
const hadSession = readToken() != null; const session = getAdminSessionState();
getAdminSessionState().clearSession(); 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) { if (hadSession) {
toast.error( toast.error(

View File

@@ -20,7 +20,7 @@ export function getAdminBearerTokenPayload(): string | null {
export function withAdminAuthHeader( export function withAdminAuthHeader(
config: AxiosRequestConfig, config: AxiosRequestConfig,
): AxiosRequestConfig { ): AxiosRequestConfig {
if (!adminBearerPayload) { if (!adminBearerPayload || adminBearerPayload === "__cookie__") {
return config; return config;
} }
const merged: AxiosRequestConfig = { ...config }; const merged: AxiosRequestConfig = { ...config };

View File

@@ -125,3 +125,11 @@ export const adminRequest = {
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">, config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
) => request<T>({ ...config, url, method: "PUT", 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,
});
}

View File

@@ -1,19 +1,13 @@
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me"; import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
import { isAdminAuthRejected } from "@/lib/admin-auth-reject"; import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
import { getAdminSessionState } from "@/stores/admin-session"; 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> { export async function verifyStoredAdminSession(): Promise<boolean> {
const token = readToken();
if (!token) {
return false;
}
const session = getAdminSessionState(); const session = getAdminSessionState();
session.setBearerToken(token); session.setBearerToken("__cookie__");
try { try {
const result = await fetchAdminMeDeduped(); const result = await fetchAdminMeDeduped();

View File

@@ -1,4 +1,4 @@
/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */ /** HttpOnly Cookie 键名,须与 middleware / API proxy 一致 */
export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token"; export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */ /** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */

View File

@@ -3,6 +3,13 @@ import {
ADMIN_TOKEN_STORAGE_KEY, ADMIN_TOKEN_STORAGE_KEY,
} from "@/lib/admin-token-constants"; } 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( export function readAdminTokenFromCookieString(
cookieHeader: string | null | undefined, cookieHeader: string | null | undefined,
): string | null { ): string | null {
@@ -32,30 +39,24 @@ export function readAdminTokenFromCookieString(
} }
export function readAdminTokenFromDocumentCookie(): string | null { export function readAdminTokenFromDocumentCookie(): string | null {
if (typeof document === "undefined") { return null;
return null;
}
return readAdminTokenFromCookieString(document.cookie);
} }
export function writeAdminTokenCookie(token: string | null): void { export function writeAdminTokenCookie(token: string | null): void {
if (typeof document === "undefined") { void token;
return; }
}
export function adminTokenCookieOptions(secure: boolean) {
const secure = return {
typeof window !== "undefined" && window.location.protocol === "https:"; ...COOKIE_BASE_OPTIONS,
const base = `path=/; SameSite=Lax`; secure,
};
if (!token || token.trim() === "") { }
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`;
return; export function expiredAdminTokenCookieOptions(secure: boolean) {
} return {
...COOKIE_BASE_OPTIONS,
const value = encodeURIComponent(token.trim()); secure,
const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`; maxAge: 0,
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${ };
secure ? "; Secure" : ""
}`;
} }

View File

@@ -1,5 +1,11 @@
import { type NextRequest, NextResponse } from "next/server"; 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"; import { lotteryApiOrigin } from "@/lib/lottery-api-base";
const HOP_BY_HOP = new Set([ const HOP_BY_HOP = new Set([
@@ -27,12 +33,23 @@ export async function proxyLotteryApi(
): Promise<NextResponse> { ): Promise<NextResponse> {
const path = pathSegments.join("/"); const path = pathSegments.join("/");
const target = `${lotteryApiOrigin()}/api/${path}${request.nextUrl.search}`; 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); const headers = new Headers(request.headers);
headers.delete("host"); headers.delete("host");
headers.delete("connection"); headers.delete("connection");
headers.delete("accept-encoding"); headers.delete("accept-encoding");
const cookieToken = readAdminTokenFromCookieString(
request.headers.get("cookie"),
);
if (cookieToken && !headers.has("authorization")) {
headers.set("authorization", `Bearer ${cookieToken}`);
}
const init: RequestInit = { const init: RequestInit = {
method: request.method, method: request.method,
headers, headers,
@@ -61,9 +78,108 @@ export async function proxyLotteryApi(
responseHeaders.delete(name); responseHeaders.delete(name);
} }
return new NextResponse(upstream.body, { if (isAdminAuthLogin) {
return handleAdminLoginResponse(request, upstream, responseHeaders);
}
const response = new NextResponse(upstream.body, {
status: upstream.status, status: upstream.status,
statusText: upstream.statusText, statusText: upstream.statusText,
headers: responseHeaders, 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;
} }

View File

@@ -18,7 +18,7 @@ function readTokenFromRequest(request: NextRequest): string | null {
return readAdminTokenFromCookieString(request.headers.get("cookie")); return readAdminTokenFromCookieString(request.headers.get("cookie"));
} }
export function middleware(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (!pathname.startsWith("/admin")) { if (!pathname.startsWith("/admin")) {

View File

@@ -1,5 +1,5 @@
/** /**
* 管理端会话:Bearer Token + 登录接口返回的 {@link AdminProfile}。 * 管理端会话:HttpOnly Cookie 登录态 + 登录接口返回的 {@link AdminProfile}。
* *
* - **组件内**`useAdminProfile()`、`useAdminSessionStore(...)` * - **组件内**`useAdminProfile()`、`useAdminSessionStore(...)`
* - **组件外**axios、工具函数`getAdminProfile()`、`useAdminSessionStore.getState()` * - **组件外**axios、工具函数`getAdminProfile()`、`useAdminSessionStore.getState()`
@@ -13,7 +13,7 @@ import {
isAdminAuthRejected, isAdminAuthRejected,
} from "@/lib/admin-auth-reject"; } from "@/lib/admin-auth-reject";
import { readProfile, writeProfile } from "@/stores/admin-profile"; 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"; import type { AdminProfile } from "@/types/api/admin-auth";
export type AdminSessionState = { export type AdminSessionState = {
@@ -25,7 +25,7 @@ export type AdminSessionState = {
setBearerToken: (token: string | null) => void; setBearerToken: (token: string | null) => void;
setAdminProfile: (profile: AdminProfile | null) => void; setAdminProfile: (profile: AdminProfile | null) => void;
clearSession: () => void; clearSession: () => void;
/** 从 localStorage 恢复 Token 与管理员摘要(仅客户端) */ /** 从 HttpOnly Cookie 会话恢复管理员摘要(仅客户端) */
rehydrate: () => void; rehydrate: () => void;
refreshAdminProfile: () => Promise<void>; refreshAdminProfile: () => Promise<void>;
/** @deprecated 使用 {@link clearSession} */ /** @deprecated 使用 {@link clearSession} */
@@ -70,24 +70,13 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }
const token = readToken();
const profile = readProfile(); const profile = readProfile();
if (token) { setAdminBearerToken(null);
setAdminBearerToken(token); set({ bearerToken: "__cookie__", adminProfile: profile });
set({ bearerToken: token, adminProfile: profile }); void get().refreshAdminProfile();
void get().refreshAdminProfile();
} else {
setAdminBearerToken(null);
set({ bearerToken: null, adminProfile: null });
}
}, },
refreshAdminProfile: async () => { refreshAdminProfile: async () => {
const token = get().bearerToken ?? readToken();
if (!token) {
return;
}
try { try {
const result = await fetchAdminMeDeduped(); const result = await fetchAdminMeDeduped();
writeProfile(result.admin); writeProfile(result.admin);

View File

@@ -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 { export function readToken(): string | null {
if (typeof window === "undefined") { return null;
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 { export function writeToken(t: string | null): void {
if (typeof window === "undefined") { void t;
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);
}
} }

View File

@@ -25,7 +25,7 @@ export type AdminAccountKind =
| "agent_operator" | "agent_operator"
| "platform_account"; | "platform_account";
/** 登录成功后缓存于会话localStorage的管理员摘要 */ /** 登录成功后缓存于会话的管理员摘要 */
export type AdminProfile = { export type AdminProfile = {
id: number; id: number;
username: string; username: string;
@@ -52,8 +52,8 @@ export type AdminProfile = {
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */
export type AdminAuthLoginResponse = { export type AdminAuthLoginResponse = {
token: string; token?: string;
token_type: string; token_type?: string;
admin: AdminProfile; admin: AdminProfile;
}; };