diff --git a/MEMORY.md b/MEMORY.md index 26295a8..65e1892 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -119,7 +119,7 @@ - 登录页主视觉允许使用装饰性动效(如浮动背景与角色动画),但必须保持登录/注册接口调用链路与鉴权行为不变。 - 首页怪兽交互基线:眼睛跟随鼠标,密码输入框聚焦时主动挪开视线(避免“盯着密码输入”观感)。 -- 当前实现位于 `web/src/app/page.tsx`;若后续继续扩展动效,优先抽离样式与展示组件,避免登录业务与视觉代码耦合过深。 +- 当前实现位于 `web/src/app/login/page.tsx`;若后续继续扩展动效,优先抽离样式与展示组件,避免登录业务与视觉代码耦合过深。 ## AI 聊天口径(2026-04-13) @@ -456,7 +456,7 @@ ## 登录页双角色视觉口径(2026-04-23) -- 登录页主视觉已从单怪兽升级为“双角色怪兽”(毛怪 + 大眼仔)构图,实现在 `web/src/app/page.tsx`。 +- 登录页主视觉已从单怪兽升级为“双角色怪兽”(毛怪 + 大眼仔)构图,当前登录页实现位于 `web/src/app/login/page.tsx`。 - 交互基线保持:眼睛跟随鼠标;密码输入时执行避视动作(毛怪转头,大眼仔轻微眯眼)。 - 视觉实现采用纯前端结构与 CSS 动效,不引入外部图片资源,不影响登录/注册链路。 @@ -557,14 +557,15 @@ - 安全边界: - 前端仅负责显隐与交互;最终权限判定以后端依赖校验为准。 -## 首页与登录口径(2026-04-23) +## 首页与登录口径(2026-05-16) -- `/` 默认作为登录入口页,不再承载“已登录后停留的首页面板”。 -- 登录态(含刷新会话恢复)进入 `/` 时,前端立即跳转 `/users`,实现“登录后直达后台”。 +- `/login` 作为规范登录入口;在当前部署 `NEXT_PUBLIC_APP_BASE_PATH=/fl` 下,对外地址为 `/fl/login`。 +- `/` 不再直接承载登录表单,仅用于重定向到 `/login`。 +- 登录态(含刷新会话恢复)进入 `/login` 或 `/` 时,前端立即跳转 `/users`,实现“登录后直达后台”。 - 后台壳层文案对齐: - - 未登录访问后台时提示“前往登录”(`/`); + - 未登录访问后台时提示“前往登录”(`/login`); - 账号菜单提供“用户管理”(`/users`)作为默认后台入口。 -- 退出登录口径:统一跳转到登录页 `/`(不保留在当前后台路由)。 +- 退出登录口径:统一跳转到登录页 `/login`(不保留在当前后台路由)。 ## 站点标题口径(2026-04-24) @@ -577,7 +578,7 @@ - 左侧为品牌与机器人主题视觉区; - 右侧为白色登录卡片(品牌头、表单、主操作按钮、辅助链接)。 - 交互口径保持: - - 登录态进入 `/` 仍自动跳转 `/users`; + - 登录态进入 `/login` 或 `/` 仍自动跳转 `/users`; - 登录/注册逻辑不变,视觉改造不改变鉴权接口契约。 ## 后台账号入口口径(2026-04-23) @@ -615,9 +616,9 @@ - 兼容保留:`fquiz:theme:mode`(legacy 四态映射) - `AI 生成主题` 当前为交互与文案对齐态,未内置站内 AI 主题生成流程;“主题编辑器”默认跳转官方编辑器页。 -## 登录页文案口径(2026-04-24) +## 登录页文案口径(2026-05-16) -- 登录页(`web/src/app/page.tsx`)默认展示文案统一为中文,不再保留英文提示文案。 +- 登录页(`web/src/app/login/page.tsx`)默认展示文案统一为中文,不再保留英文提示文案。 ## 前端编译口径(2026-04-24) @@ -660,10 +661,16 @@ - 后台“仪表盘”页面已下线,不再作为可访问菜单和默认首页。 - 前端路由口径: - `/admin`、`/dashboard` 统一重定向到 `/users`; - - 登录态进入 `/` 默认跳转到 `/users`; + - 登录态进入 `/login` 或 `/` 默认跳转到 `/users`; - `web/src/app/admin/page.tsx` 改为重定向页,不再渲染卡片工作台。 - 后端菜单口径: - `seed_service.DEFAULT_MENUS` 删除 `dashboard`; + +## 前端菜单路由兼容口径(2026-05-16) + +- 前端公开菜单路由的规范地址统一使用真实页面路径,例如 `/users`、`/roles`、`/menus`、`/system-params`、`/power-lines`。 +- 历史别名路径(如 `/user`、`/role`、`/menu`、`/system-param`、`/power-line`、`/worker`、`/tower-model`、`/file`)由前端路由层自动规范到对应正式地址,避免旧菜单数据或手输地址直接落到 404。 +- 后台菜单渲染与菜单管理页默认展示规范化后的 path,减少“菜单能点但高亮错位”或“列表里还是旧地址”的前后不一致。 - `ROLE_MENU_BINDINGS` 删除 admin/user 对 `dashboard` 的绑定; - `legacy_authz_service`、`legacy_admin_rbac_service`、`admin_service` 对 `dashboard` 统一加入下线过滤集合,屏蔽历史库残留菜单记录。 diff --git a/memory/2026-05-16.md b/memory/2026-05-16.md index 641efd8..50d4444 100644 --- a/memory/2026-05-16.md +++ b/memory/2026-05-16.md @@ -72,3 +72,59 @@ - 风险与关注点: - 当前在线 `REFRESH_COOKIE_SECURE=true`;若继续通过纯 HTTP IP 访问,浏览器可能仍不会持久化 refresh cookie。若部署后出现“登录后刷新即掉线”,需将当前环境的 `REFRESH_COOKIE_SECURE` 调整为 `false` 或切到 HTTPS 入口。 + +## Work Log - 登录入口迁移到 `/fl/login`(2026-05-16) + +- 背景: + - 当前前端登录页挂在应用根路径;在生产 basePath=`/fl` 下,对外登录地址表现为 `/fl`。 + - 需求要求将登录路由显式调整为 `/fl/login`,同时保留旧入口兼容跳转。 + +- 本次改动: + - `web/src/app/login/page.tsx` + - 新增独立登录页,沿用现有登录表单、记住密码和登录态自动跳转逻辑。 + - `web/src/app/page.tsx` + - 根页改为重定向到 `/login`,不再直接承载登录表单。 + - `web/src/middleware.ts` + - 将 `/login` 标记为公开直达路由,避免被后台 rewrite 到 `/admin/login`。 + - `web/src/components/auth-provider.tsx` + - 退出登录后统一跳转到 `/login`。 + - `web/src/app/admin/layout.tsx` + - 未登录态提示中的“前往登录”入口改为 `/login`。 + - `MEMORY.md` + - 同步更新长期路由口径:`/login` 为规范登录入口,`/` 仅做跳转。 + +- 验证: + - `npm --prefix web run lint -- src/app/page.tsx src/app/login/page.tsx src/middleware.ts src/components/auth-provider.tsx src/app/admin/layout.tsx` + 通过;仅保留既有 `` 使用警告,无新增 lint error。 + - 已本地确认 Next.js 16 安装包的 app redirect 渲染链会按 basePath 处理重定向,当前 `redirect("/login")` 口径可用于 `/fl/login` 场景。 + - `npm --prefix web run build` 已启动并进入 `next build --webpack`,但在本次会话等待窗口内未返回最终退出码;期间未出现与本次改动相关的编译报错输出。 + +- 风险与关注点: + - 仓库内仍有多处“返回首页”按钮保留指向 `/`;当前会经过根页跳转到 `/login`,功能不受影响,但若后续希望所有入口都直接落到规范地址,可再统一替换为 `/login`。 + +## Work Log - 菜单旧路由兼容修复(2026-05-16) + +- 背景: + - 用户反馈 `/fl/user` 按预期应进入用户页面,但实际返回 404。 + - 当前前端真实页面路径为 `/users`,而部分历史菜单 path/手输地址仍可能使用旧别名(如 `/user`、`/role`、`/menu`)。 + +- 本次改动: + - `web/src/lib/app-route-path.ts` + - 新增统一路由规范化 helper,集中维护旧菜单别名到正式公开路由的映射。 + - `web/src/middleware.ts` + - 对 `/user`、`/role`、`/menu` 等旧地址,以及 `/admin/*` 旧入口,统一先重定向到规范公开路由,再走后台 rewrite。 + - `web/src/app/admin/layout.tsx` + - 后台菜单树 path 统一先做规范化,避免菜单点击后高亮丢失或 path 仍指向旧地址。 + - `web/src/app/admin/menus/page.tsx` + - 菜单管理页读取菜单列表时展示规范化 path;提交菜单编辑时也会把旧别名收敛到正式地址。 + - `MEMORY.md` + - 补充“前端菜单路由兼容口径”,记录规范地址与旧别名自动收敛规则。 + +- 验证: + - `npm --prefix web run lint -- src/lib/app-route-path.ts src/middleware.ts src/app/admin/layout.tsx src/app/admin/menus/page.tsx` + 通过;仅保留既有 `` warning,无新增 lint error。 + - `git diff --check -- web/src/lib/app-route-path.ts web/src/middleware.ts web/src/app/admin/layout.tsx web/src/app/admin/menus/page.tsx` + 通过。 + +- 风险与关注点: + - 当前别名映射基于已知历史菜单路径补齐;若库里还存在其他非常规旧 path,仍需按实际反馈继续补别名表。 diff --git a/web/src/app/admin/layout.tsx b/web/src/app/admin/layout.tsx index 13ab9f3..3702fe8 100644 --- a/web/src/app/admin/layout.tsx +++ b/web/src/app/admin/layout.tsx @@ -37,6 +37,7 @@ import { import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; +import { normalizeAppRoutePath } from "@/lib/app-route-path"; import type { MenuTreeItem } from "@/types/auth"; import { useThemeAppearance } from "@/components/ui-antd"; import { withBasePath } from "@/lib/base-path"; @@ -60,16 +61,7 @@ function ThemeIcon() { } function normalizeAdminPath(path: string | null): string | null { - if (!path) { - return path; - } - if (path === "/admin" || path === "/admin/") { - return "/users"; - } - if (path.startsWith("/admin/")) { - return path.slice("/admin".length); - } - return path; + return normalizeAppRoutePath(path); } function normalizeMenuTreePaths(items: MenuTreeItem[]): MenuTreeItem[] { @@ -152,7 +144,8 @@ function AdminCenteredState({ children }: { children: ReactNode }) { } export default function AdminLayout({ children }: { children: ReactNode }) { - const pathname = usePathname(); + const rawPathname = usePathname(); + const pathname = normalizeAppRoutePath(rawPathname) ?? rawPathname; const screens = Grid.useBreakpoint(); const isDesktop = screens.md === true; const { user, initializing, fetchWithAuth, logout } = useAuth(); @@ -340,7 +333,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { subTitle="登录后才能访问后台工作台。" extra={( )} /> diff --git a/web/src/app/admin/menus/page.tsx b/web/src/app/admin/menus/page.tsx index 9aa7d0c..d0d3cbb 100644 --- a/web/src/app/admin/menus/page.tsx +++ b/web/src/app/admin/menus/page.tsx @@ -28,6 +28,7 @@ import type { CSSProperties, ComponentType } from "react"; import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; +import { normalizeAppRoutePath } from "@/lib/app-route-path"; import type { MenuItem, MenuListResponse } from "@/types/auth"; const AntCard = Card as unknown as ComponentType; @@ -82,6 +83,13 @@ function compareMenuIds(a: string, b: string): number { return a.localeCompare(b, "zh-CN"); } +function normalizeMenuItemPath(menu: MenuItem): MenuItem { + return { + ...menu, + path: normalizeAppRoutePath(menu.path), + }; +} + export default function AdminMenusPage() { const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const { message: messageApi, modal } = App.useApp(); @@ -166,7 +174,7 @@ export default function AdminMenusPage() { } const payload = (await response.json()) as MenuListResponse; - setMenus(payload.items); + setMenus(payload.items.map(normalizeMenuItemPath)); setLoading(false); }, [canRead, fetchWithAuth]); @@ -227,7 +235,7 @@ export default function AdminMenusPage() { const payload = { code: values.code.trim(), name: values.name.trim(), - path: values.path?.trim() ? values.path.trim() : null, + path: normalizeAppRoutePath(values.path?.trim() ? values.path.trim() : null), icon: values.icon?.trim() ? values.icon.trim() : null, parent_id: values.parent_id?.trim() ? values.parent_id.trim() : null, type: values.type, diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx new file mode 100644 index 0000000..dabd2ac --- /dev/null +++ b/web/src/app/login/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { IdcardOutlined, LockOutlined } from "@ant-design/icons"; +import { ChangeEvent, FormEvent, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Alert, Button, Checkbox, Input, Space, Typography } from "antd"; + +import { useAuth } from "@/components/auth-provider"; +import { Card } from "@/components/ui-antd"; +import { withBasePath } from "@/lib/base-path"; + +const LOGIN_REMEMBER_KEY = "login.remember"; +const LOGIN_USER_ID_KEY = "login.user_id"; +const LOGIN_PASSWORD_KEY = "login.password"; + +export default function LoginPage() { + const router = useRouter(); + const { user, initializing, login } = useAuth(); + + const [userId, setUserId] = useState(""); + const [password, setPassword] = useState(""); + const [rememberPassword, setRememberPassword] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!initializing && user) { + router.replace("/users"); + } + }, [initializing, router, user]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const remembered = window.localStorage.getItem(LOGIN_REMEMBER_KEY) === "1"; + if (!remembered) { + return; + } + + setRememberPassword(true); + setUserId(window.localStorage.getItem(LOGIN_USER_ID_KEY) ?? ""); + setPassword(window.localStorage.getItem(LOGIN_PASSWORD_KEY) ?? ""); + }, []); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(""); + setBusy(true); + + try { + const normalizedUserId = userId.trim(); + await login(normalizedUserId, password); + + if (typeof window !== "undefined") { + if (rememberPassword) { + window.localStorage.setItem(LOGIN_REMEMBER_KEY, "1"); + window.localStorage.setItem(LOGIN_USER_ID_KEY, normalizedUserId); + window.localStorage.setItem(LOGIN_PASSWORD_KEY, password); + } else { + window.localStorage.removeItem(LOGIN_REMEMBER_KEY); + window.localStorage.removeItem(LOGIN_USER_ID_KEY); + window.localStorage.removeItem(LOGIN_PASSWORD_KEY); + } + } + + setPassword(""); + } catch (submitError) { + const message = submitError instanceof Error ? submitError.message : "未知错误"; + setError(message); + } finally { + setBusy(false); + } + }; + + if (initializing) { + return ( +
+ 正在初始化会话... +
+ ); + } + + if (user) { + return ( +
+ 正在跳转到控制台... +
+ ); + } + + return ( +
+ + +
+ 高压电塔图标 +
+ + + 防雷计算 + + +
+ } + placeholder="用户 ID" + onChange={(event: ChangeEvent) => setUserId(event.currentTarget.value)} + autoComplete="username" + required + /> + + } + placeholder="密码" + onChange={(event: ChangeEvent) => setPassword(event.currentTarget.value)} + autoComplete="current-password" + minLength={1} + maxLength={128} + required + /> + +
+ setRememberPassword(event.target.checked)} + > + 记住密码 + +
+ + + + + {error && } +
+
+
+ ); +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 3df6069..9f85280 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,153 +1,5 @@ -"use client"; - -import { IdcardOutlined, LockOutlined } from "@ant-design/icons"; -import { ChangeEvent, FormEvent, useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Alert, Button, Checkbox, Input, Space, Typography } from "antd"; - -import { useAuth } from "@/components/auth-provider"; -import { Card } from "@/components/ui-antd"; -import { withBasePath } from "@/lib/base-path"; - -const LOGIN_REMEMBER_KEY = "login.remember"; -const LOGIN_USER_ID_KEY = "login.user_id"; -const LOGIN_PASSWORD_KEY = "login.password"; +import { redirect } from "next/navigation"; export default function Home() { - const router = useRouter(); - const { user, initializing, login } = useAuth(); - - const [userId, setUserId] = useState(""); - const [password, setPassword] = useState(""); - const [rememberPassword, setRememberPassword] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - useEffect(() => { - if (!initializing && user) { - router.replace("/users"); - } - }, [initializing, router, user]); - - useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const remembered = window.localStorage.getItem(LOGIN_REMEMBER_KEY) === "1"; - if (!remembered) { - return; - } - - setRememberPassword(true); - setUserId(window.localStorage.getItem(LOGIN_USER_ID_KEY) ?? ""); - setPassword(window.localStorage.getItem(LOGIN_PASSWORD_KEY) ?? ""); - }, []); - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - setError(""); - setBusy(true); - - try { - const normalizedUserId = userId.trim(); - await login(normalizedUserId, password); - - if (typeof window !== "undefined") { - if (rememberPassword) { - window.localStorage.setItem(LOGIN_REMEMBER_KEY, "1"); - window.localStorage.setItem(LOGIN_USER_ID_KEY, normalizedUserId); - window.localStorage.setItem(LOGIN_PASSWORD_KEY, password); - } else { - window.localStorage.removeItem(LOGIN_REMEMBER_KEY); - window.localStorage.removeItem(LOGIN_USER_ID_KEY); - window.localStorage.removeItem(LOGIN_PASSWORD_KEY); - } - } - - setPassword(""); - } catch (submitError) { - const message = submitError instanceof Error ? submitError.message : "未知错误"; - setError(message); - } finally { - setBusy(false); - } - }; - - if (initializing) { - return ( -
- 正在初始化会话... -
- ); - } - - if (user) { - return ( -
- 正在跳转到控制台... -
- ); - } - - return ( -
- - -
- 高压电塔图标 -
- - - 防雷计算 - - -
- } - placeholder="用户 ID" - onChange={(event: ChangeEvent) => setUserId(event.currentTarget.value)} - autoComplete="username" - required - /> - - } - placeholder="密码" - onChange={(event: ChangeEvent) => setPassword(event.currentTarget.value)} - autoComplete="current-password" - minLength={1} - maxLength={128} - required - /> - -
- setRememberPassword(event.target.checked)} - > - 记住密码 - -
- - - - - {error && } -
-
-
- ); + redirect("/login"); } diff --git a/web/src/components/auth-provider.tsx b/web/src/components/auth-provider.tsx index 1f204f7..4fda4ec 100644 --- a/web/src/components/auth-provider.tsx +++ b/web/src/components/auth-provider.tsx @@ -14,6 +14,8 @@ import { getApiBaseUrl, readApiError } from "@/lib/api"; import { withBasePath } from "@/lib/base-path"; import type { AuthTokenResponse, UserPublic } from "@/types/auth"; +const AUTH_SESSION_HINT_KEY = "auth.session_hint"; + type AuthContextValue = { user: UserPublic | null; accessToken: string | null; @@ -41,6 +43,27 @@ function withApiPath(path: string): string { return `${getApiBaseUrl()}${path}`; } +function hasSessionHint(): boolean { + if (typeof window === "undefined") { + return false; + } + return window.localStorage.getItem(AUTH_SESSION_HINT_KEY) === "1"; +} + +function persistSessionHint(): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(AUTH_SESSION_HINT_KEY, "1"); +} + +function clearSessionHint(): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.removeItem(AUTH_SESSION_HINT_KEY); +} + export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [accessToken, setAccessToken] = useState(null); @@ -57,12 +80,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const clearAuth = useCallback(() => { setUser(null); applyToken(null); + clearSessionHint(); }, [applyToken]); const applyAuthPayload = useCallback( (payload: AuthTokenResponse) => { setUser(payload.user); applyToken(payload.access_token); + persistSessionHint(); }, [applyToken], ); @@ -183,7 +208,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }); } finally { if (typeof window !== "undefined") { - window.location.replace(withBasePath("/")); + window.location.replace(withBasePath("/login")); return; } clearAuth(); @@ -192,6 +217,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const bootstrap = async () => { + if (!hasSessionHint()) { + setInitializing(false); + return; + } + try { await refreshAccessToken(); } finally { diff --git a/web/src/lib/app-route-path.ts b/web/src/lib/app-route-path.ts new file mode 100644 index 0000000..8aef5c5 --- /dev/null +++ b/web/src/lib/app-route-path.ts @@ -0,0 +1,37 @@ +const APP_ROUTE_ALIASES: Record = { + "/admin": "/users", + "/dashboard": "/users", + "/user": "/users", + "/role": "/roles", + "/menu": "/menus", + "/system-param": "/system-params", + "/power-line": "/power-lines", + "/lightning-current": "/lightning-currents", + "/worker": "/workers", + "/tower-model": "/tower-models", + "/file": "/files", +}; + +function trimTrailingSlash(pathname: string): string { + if (pathname === "/") { + return pathname; + } + return pathname.replace(/\/+$/, "") || "/"; +} + +export function normalizeAppRoutePath(path: string | null): string | null { + if (!path) { + return path; + } + + if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("//")) { + return path; + } + + const normalizedPath = trimTrailingSlash(path.startsWith("/") ? path : `/${path}`); + const publicPath = normalizedPath.startsWith("/admin/") + ? trimTrailingSlash(normalizedPath.slice("/admin".length) || "/") + : normalizedPath; + + return APP_ROUTE_ALIASES[publicPath] ?? publicPath; +} diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 2a74537..2db5f35 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { normalizeAppRoutePath } from "./lib/app-route-path"; + function normalizeBasePath(value: string | undefined): string { const trimmed = value?.trim() ?? ""; if (!trimmed || trimmed === "/") { @@ -50,7 +52,7 @@ function withBasePath(pathname: string): string { } function isBypassedPath(pathname: string): boolean { - if (pathname === "/") { + if (pathname === "/" || pathname === "/login" || pathname === "/login/") { return true; } if (PUBLIC_FILE.test(pathname)) { @@ -68,26 +70,23 @@ export function middleware(request: NextRequest) { return NextResponse.next(); } - // Keep backward compatibility for existing /admin links. - if (pathname === "/admin" || pathname === "/admin/") { - const url = request.nextUrl.clone(); - url.pathname = withBasePath("/users"); - return NextResponse.redirect(url); - } - if (pathname === "/dashboard" || pathname === "/dashboard/") { - const url = request.nextUrl.clone(); - url.pathname = withBasePath("/users"); - return NextResponse.redirect(url); - } + const canonicalPath = normalizeAppRoutePath(pathname) ?? pathname; + + // Keep backward compatibility for existing /admin links and legacy menu aliases. if (pathname.startsWith("/admin/")) { const url = request.nextUrl.clone(); - url.pathname = withBasePath(pathname.slice("/admin".length) || "/users"); + url.pathname = withBasePath(canonicalPath); + return NextResponse.redirect(url); + } + if (canonicalPath !== pathname) { + const url = request.nextUrl.clone(); + url.pathname = withBasePath(canonicalPath); return NextResponse.redirect(url); } // New public URLs without /admin are internally rewritten to existing routes. const url = request.nextUrl.clone(); - url.pathname = withBasePath(`/admin${pathname}`); + url.pathname = withBasePath(`/admin${canonicalPath}`); return NextResponse.rewrite(url); }