;s;
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
||||
|
||||
const COOKIE_NAME = "pb_session";
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(request: NextRequest) {
|
||||
const domain = getAuthCookieDomain(request);
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
|
||||
res.cookies.set(COOKIE_NAME, "", {
|
||||
...(domain && { domain }),
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPocketBaseConfig } from "@/config/services.config";
|
||||
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
|
||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
||||
|
||||
const COOKIE_NAME = "pb_session";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookie = request.cookies.get(COOKIE_NAME)?.value;
|
||||
const domain = getAuthCookieDomain(request);
|
||||
if (!cookie) {
|
||||
return NextResponse.json({ ok: true, user: null });
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const r = NextResponse.json({ ok: true, user: null });
|
||||
r.cookies.set(COOKIE_NAME, "", { domain: AUTH_COOKIE_DOMAIN, path: "/", maxAge: 0 });
|
||||
r.cookies.set(COOKIE_NAME, "", { ...(domain && { domain }), path: "/", maxAge: 0 });
|
||||
return r;
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -41,7 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
token: newToken,
|
||||
});
|
||||
r.cookies.set(COOKIE_NAME, encoded, {
|
||||
domain: AUTH_COOKIE_DOMAIN,
|
||||
...(domain && { domain }),
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AUTH_COOKIE_DOMAIN } from "@/config/domain.config";
|
||||
import { getAuthCookieDomain } from "@/config/domain.config";
|
||||
|
||||
const COOKIE_NAME = "pb_session";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 365 天,登录态持续有效直至用户主动退出
|
||||
@@ -19,9 +19,10 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
const encoded = Buffer.from(payload, "utf-8").toString("base64url");
|
||||
|
||||
const domain = getAuthCookieDomain(request);
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE_NAME, encoded, {
|
||||
domain: AUTH_COOKIE_DOMAIN,
|
||||
...(domain && { domain }),
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||
import { getStoredUserEmail } from "@/app/lib/pocketbase";
|
||||
import AuthModal from "./AuthModal";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
||||
const navLinks = [
|
||||
{ labelKey: "roadmap" as const, href: "#roadmap" },
|
||||
@@ -45,11 +46,6 @@ export default function Header() {
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await pbLogout();
|
||||
setUserEmail(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
|
||||
@@ -81,17 +77,7 @@ export default function Header() {
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
|
||||
{userEmail ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 dark:text-slate-400 sm:inline">
|
||||
{userEmail}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
<UserAvatar email={userEmail} onLogout={() => setUserEmail(null)} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthOpen(true)}
|
||||
@@ -160,17 +146,9 @@ export default function Header() {
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
{userEmail ? (
|
||||
<div className="mt-2 flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<span className="truncate text-sm text-slate-600">{userEmail}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="text-sm font-medium text-sky-500"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<UserAvatar email={userEmail} onLogout={() => { setUserEmail(null); setMobileOpen(false); }} />
|
||||
<span className="truncate text-sm text-slate-600 dark:text-slate-400">{userEmail}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
||||
60
app/components/UserAvatar.tsx
Normal file
60
app/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { pbLogout } from "@/app/lib/pocketbase";
|
||||
|
||||
function getAvatarLetter(email: string): string {
|
||||
const local = email.split("@")[0];
|
||||
if (!local) return "?";
|
||||
return local.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
interface UserAvatarProps {
|
||||
email: string;
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
/** 圆形头像 + 点击展开退出按钮,供 digital / meetup / vip 三站复用 */
|
||||
export default function UserAvatar({ email, onLogout }: UserAvatarProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await pbLogout();
|
||||
setOpen(false);
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold text-white shadow-sm ring-2 ring-white dark:ring-slate-900 hover:bg-sky-600"
|
||||
aria-label={t("logout")}
|
||||
>
|
||||
{getAvatarLetter(email)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 min-w-[120px] rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,19 +79,26 @@ export async function pbLogout(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前存储的用户邮箱 */
|
||||
export function getStoredUserEmail(): string | null {
|
||||
/** 获取当前存储的用户(id + email) */
|
||||
export function getStoredUser(): AuthUser | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
||||
if (!raw) return null;
|
||||
const user = JSON.parse(raw);
|
||||
return user?.email ?? null;
|
||||
if (!user?.id || !user?.email) return null;
|
||||
return { id: user.id, email: user.email };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前存储的用户邮箱 */
|
||||
export function getStoredUserEmail(): string | null {
|
||||
const user = getStoredUser();
|
||||
return user?.email ?? null;
|
||||
}
|
||||
|
||||
/** 获取存储的 token(用于 API 请求) */
|
||||
export function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
pbRegister,
|
||||
pbSaveAuth,
|
||||
pbLogout,
|
||||
getStoredUser,
|
||||
getStoredUserEmail,
|
||||
getStoredToken,
|
||||
} from "./auth";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getStoredUser, getStoredToken } from "@/app/lib/pocketbase";
|
||||
|
||||
export interface AuthAndVipState {
|
||||
user: { id: string; email: string } | null;
|
||||
@@ -14,7 +15,7 @@ export function useAuthAndVip(): AuthAndVipState {
|
||||
const [vip, setVip] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refetch = useCallback(async (): Promise<{ user: typeof user; vip: boolean }> => {
|
||||
const refetch = useCallback(async (): Promise<{ user: { id: string; email: string } | null; vip: boolean }> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [meRes, vipRes] = await Promise.all([
|
||||
@@ -23,15 +24,36 @@ export function useAuthAndVip(): AuthAndVipState {
|
||||
]);
|
||||
const meData = await meRes.json();
|
||||
const vipData = await vipRes.json();
|
||||
const newUser = meData?.user ?? null;
|
||||
const newVip = vipData?.vip === true;
|
||||
let newUser = meData?.user ?? null;
|
||||
let newVip = vipData?.vip === true;
|
||||
|
||||
// 与 Header 一致:API 无用户时,尝试从 localStorage 恢复(如 cookie 未同步、localhost 开发等)
|
||||
if (!newUser) {
|
||||
const storedUser = getStoredUser();
|
||||
const storedToken = getStoredToken();
|
||||
if (storedUser && storedToken) {
|
||||
newUser = storedUser;
|
||||
try {
|
||||
await fetch("/api/auth/sync-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: storedToken, record: storedUser }),
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setUser(newUser);
|
||||
setVip(newVip);
|
||||
return { user: newUser, vip: newVip };
|
||||
} catch {
|
||||
setUser(null);
|
||||
const storedUser = getStoredUser();
|
||||
setUser(storedUser);
|
||||
setVip(false);
|
||||
return { user: null, vip: false };
|
||||
return { user: storedUser, vip: false };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user