refactor(router): introduce app navigation manager
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isAuthenticatedUser,
|
||||
resolveAuthenticatedNavigation,
|
||||
resolveRouteGuardRedirect,
|
||||
} from "../navigation-resolver";
|
||||
import { ROUTE_BUILDERS, ROUTES } from "../routes";
|
||||
|
||||
describe("navigation resolver", () => {
|
||||
it("treats only real login providers as authenticated users", () => {
|
||||
expect(isAuthenticatedUser("notLoggedIn")).toBe(false);
|
||||
expect(isAuthenticatedUser("guest")).toBe(false);
|
||||
expect(isAuthenticatedUser("email")).toBe(true);
|
||||
expect(isAuthenticatedUser("facebook")).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps protected navigation with auth redirect for guests", () => {
|
||||
expect(
|
||||
resolveAuthenticatedNavigation({
|
||||
loginStatus: "guest",
|
||||
targetUrl: "/subscription?type=vip",
|
||||
}),
|
||||
).toBe(ROUTE_BUILDERS.authWithRedirect("/subscription?type=vip"));
|
||||
});
|
||||
|
||||
it("keeps protected navigation targets for authenticated users", () => {
|
||||
expect(
|
||||
resolveAuthenticatedNavigation({
|
||||
loginStatus: "google",
|
||||
targetUrl: "/subscription?type=topup",
|
||||
}),
|
||||
).toBe("/subscription?type=topup");
|
||||
});
|
||||
|
||||
it("redirects not logged in session routes to splash", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "notLoggedIn",
|
||||
pathname: ROUTES.chat,
|
||||
}),
|
||||
).toBe(ROUTES.splash);
|
||||
});
|
||||
|
||||
it("redirects non-real users on subscription routes to auth", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "guest",
|
||||
pathname: ROUTES.subscription,
|
||||
searchParams: "type=vip&returnTo=chat",
|
||||
}),
|
||||
).toBe(
|
||||
ROUTE_BUILDERS.authWithRedirect(
|
||||
"/subscription?type=vip&returnTo=chat",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows public and authorized routes", () => {
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "guest",
|
||||
pathname: ROUTES.coinsRules,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveRouteGuardRedirect({
|
||||
loginStatus: "email",
|
||||
pathname: ROUTES.subscription,
|
||||
searchParams: "type=vip",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRouteAccess, isInternalRoute } from "../route-meta";
|
||||
import { ROUTES } from "../routes";
|
||||
|
||||
describe("route meta", () => {
|
||||
it("classifies static routes by access level", () => {
|
||||
expect(getRouteAccess(ROUTES.splash)).toBe("authOnly");
|
||||
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
|
||||
expect(getRouteAccess(ROUTES.chat)).toBe("session");
|
||||
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
|
||||
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
|
||||
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
|
||||
});
|
||||
|
||||
it("classifies known dynamic chat routes", () => {
|
||||
expect(getRouteAccess("/chat/image/msg_1")).toBe("session");
|
||||
expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public");
|
||||
});
|
||||
|
||||
it("treats unknown routes as public by default", () => {
|
||||
expect(getRouteAccess("/unknown")).toBe("public");
|
||||
});
|
||||
|
||||
it("accepts only internal redirect targets", () => {
|
||||
expect(isInternalRoute("/chat")).toBe(true);
|
||||
expect(isInternalRoute("/subscription?type=vip")).toBe(true);
|
||||
expect(isInternalRoute("https://example.com/chat")).toBe(false);
|
||||
expect(isInternalRoute("//example.com/chat")).toBe(false);
|
||||
expect(isInternalRoute(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
|
||||
import { resolveRouteGuardRedirect } from "./navigation-resolver";
|
||||
|
||||
export function AppNavigationGuard() {
|
||||
const authState = useAuthState();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authState.hasInitialized || authState.isLoading) return;
|
||||
|
||||
const redirectTo = resolveRouteGuardRedirect({
|
||||
loginStatus: authState.loginStatus,
|
||||
pathname,
|
||||
searchParams: window.location.search.replace(/^\?/, ""),
|
||||
});
|
||||
if (redirectTo) router.replace(redirectTo);
|
||||
}, [
|
||||
authState.hasInitialized,
|
||||
authState.isLoading,
|
||||
authState.loginStatus,
|
||||
pathname,
|
||||
router,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LoginStatus } from "@/data/dto/auth";
|
||||
|
||||
import { getRouteAccess } from "./route-meta";
|
||||
import { ROUTE_BUILDERS, ROUTES } from "./routes";
|
||||
|
||||
export function isAuthenticatedUser(loginStatus: LoginStatus): boolean {
|
||||
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
|
||||
}
|
||||
|
||||
export function resolveAuthenticatedNavigation(input: {
|
||||
loginStatus: LoginStatus;
|
||||
targetUrl: string;
|
||||
}): string {
|
||||
if (isAuthenticatedUser(input.loginStatus)) return input.targetUrl;
|
||||
return ROUTE_BUILDERS.authWithRedirect(input.targetUrl);
|
||||
}
|
||||
|
||||
export function resolveRouteGuardRedirect(input: {
|
||||
loginStatus: LoginStatus;
|
||||
pathname: string;
|
||||
searchParams?: string;
|
||||
}): string | null {
|
||||
const access = getRouteAccess(input.pathname);
|
||||
|
||||
if (access === "session" && input.loginStatus === "notLoggedIn") {
|
||||
return ROUTES.splash;
|
||||
}
|
||||
|
||||
if (access === "realUser" && !isAuthenticatedUser(input.loginStatus)) {
|
||||
return ROUTE_BUILDERS.authWithRedirect(
|
||||
buildCurrentUrl(input.pathname, input.searchParams),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCurrentUrl(pathname: string, searchParams?: string): string {
|
||||
if (!searchParams) return pathname;
|
||||
return `${pathname}?${searchParams}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import type {
|
||||
PendingChatUnlockKind,
|
||||
PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
export type AppSubscriptionType = "vip" | "topup";
|
||||
export type AppSubscriptionReturnTo = "chat" | null;
|
||||
|
||||
export interface OpenSubscriptionInput {
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
returnTo?: AppSubscriptionReturnTo;
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
export interface StartMessageUnlockInput {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
stage?: PendingChatUnlockStage;
|
||||
onAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export interface OpenSubscriptionForPendingUnlockInput {
|
||||
messageId: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
returnUrl: string;
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
export type RouteAccess = "public" | "session" | "realUser" | "authOnly";
|
||||
|
||||
const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
|
||||
[ROUTES.root]: "public",
|
||||
[ROUTES.splash]: "authOnly",
|
||||
[ROUTES.auth]: "authOnly",
|
||||
[ROUTES.chat]: "session",
|
||||
[ROUTES.sidebar]: "session",
|
||||
[ROUTES.subscription]: "realUser",
|
||||
[ROUTES.coinsRules]: "public",
|
||||
};
|
||||
|
||||
export function getRouteAccess(pathname: string): RouteAccess {
|
||||
if (pathname.startsWith("/chat/image/")) return "session";
|
||||
if (pathname.startsWith("/chat/deviceid/")) return "public";
|
||||
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
|
||||
}
|
||||
|
||||
export function isInternalRoute(value: string | null | undefined): boolean {
|
||||
return Boolean(value && value.startsWith("/") && !value.startsWith("//"));
|
||||
}
|
||||
@@ -48,26 +48,6 @@ export const ROUTE_BUILDERS = {
|
||||
`${ROUTES.auth}?redirect=${encodeURIComponent(redirectTo)}` as const,
|
||||
} as const;
|
||||
|
||||
/** 仅未登录态可见的路由(命中后已登录用户应跳走) */
|
||||
export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
ROUTES.auth,
|
||||
] as const;
|
||||
|
||||
/** 仅登录态可见的路由(命中后未登录用户应跳走) */
|
||||
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.chat,
|
||||
ROUTES.sidebar,
|
||||
ROUTES.subscription,
|
||||
] as const;
|
||||
|
||||
/** 公开路由(游客态也允许访问) */
|
||||
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.chat,
|
||||
ROUTES.sidebar,
|
||||
ROUTES.coinsRules,
|
||||
] as const;
|
||||
|
||||
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
|
||||
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||
ROUTES.splash,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { NavigationStorage } from "@/data/storage/navigation";
|
||||
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
|
||||
import { useAuthState } from "@/stores/auth/auth-context";
|
||||
import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import {
|
||||
ROUTE_BUILDERS,
|
||||
ROUTES,
|
||||
type Route,
|
||||
} from "./routes";
|
||||
import {
|
||||
isAuthenticatedUser as resolveIsAuthenticatedUser,
|
||||
resolveAuthenticatedNavigation,
|
||||
} from "./navigation-resolver";
|
||||
import type {
|
||||
OpenSubscriptionForPendingUnlockInput,
|
||||
OpenSubscriptionInput,
|
||||
StartMessageUnlockInput,
|
||||
} from "./navigation-types";
|
||||
|
||||
export interface AppNavigator {
|
||||
push: (href: string, options?: { scroll?: boolean }) => void;
|
||||
replace: (href: string, options?: { scroll?: boolean }) => void;
|
||||
openAuth: (redirectTo?: string) => void;
|
||||
openSubscription: (input: OpenSubscriptionInput) => void;
|
||||
startMessageUnlock: (input: StartMessageUnlockInput) => void;
|
||||
openSubscriptionForPendingUnlock: (
|
||||
input: OpenSubscriptionForPendingUnlockInput,
|
||||
) => void;
|
||||
getDefaultPayChannel: () => ReturnType<typeof getDefaultPayChannelForCountryCode>;
|
||||
isAuthenticatedUser: boolean;
|
||||
}
|
||||
|
||||
export function useAppNavigator(): AppNavigator {
|
||||
const router = useRouter();
|
||||
const authState = useAuthState();
|
||||
const userState = useUserState();
|
||||
const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus);
|
||||
|
||||
function push(href: string, options?: { scroll?: boolean }): void {
|
||||
router.push(href, options);
|
||||
}
|
||||
|
||||
function replace(href: string, options?: { scroll?: boolean }): void {
|
||||
router.replace(href, options);
|
||||
}
|
||||
|
||||
function openAuth(redirectTo: string = ROUTES.chat): void {
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
|
||||
}
|
||||
|
||||
function getDefaultPayChannel() {
|
||||
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
|
||||
}
|
||||
|
||||
function openSubscription({
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
returnTo = null,
|
||||
replace: shouldReplace = false,
|
||||
}: OpenSubscriptionInput): void {
|
||||
const target = ROUTE_BUILDERS.subscription(type, {
|
||||
payChannel,
|
||||
returnTo: returnTo ?? undefined,
|
||||
});
|
||||
|
||||
const nextUrl = resolveAuthenticatedNavigation({
|
||||
loginStatus: authState.loginStatus,
|
||||
targetUrl: target,
|
||||
});
|
||||
|
||||
if (shouldReplace) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
router.push(nextUrl);
|
||||
}
|
||||
|
||||
function startMessageUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage = "auth",
|
||||
onAuthenticated,
|
||||
}: StartMessageUnlockInput): void {
|
||||
if (isAuthenticatedUser) {
|
||||
onAuthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage,
|
||||
});
|
||||
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
|
||||
})();
|
||||
}
|
||||
|
||||
function openSubscriptionForPendingUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
}: OpenSubscriptionForPendingUnlockInput): void {
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
messageId,
|
||||
kind,
|
||||
returnUrl,
|
||||
stage: "payment",
|
||||
});
|
||||
openSubscription({
|
||||
type,
|
||||
payChannel,
|
||||
returnTo: "chat",
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
openAuth,
|
||||
openSubscription,
|
||||
startMessageUnlock,
|
||||
openSubscriptionForPendingUnlock,
|
||||
getDefaultPayChannel,
|
||||
isAuthenticatedUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type { Route };
|
||||
Reference in New Issue
Block a user