新增登录后的业务页面

从多语言文案和页面路由开始,创建一个正确处理登录、邮箱验证和 2FA 的工作台。

这一篇创建 /workspace 工作台。访问者必须完成项目要求的认证步骤才能打开它,未登录、未验证邮箱或尚未完成 2FA 时,进入相应的处理页面。

先确保本地项目能通过 npm run dev 启动,并且可以注册、登录。本篇只建立页面入口,下一篇再为业务添加数据接口。

一、先准备页面文案

在 locales/en.json、locales/zh-Hans.json 和 locales/zh-Hant.json 中,分别把 workspace 合并到已有的 pages 对象。不要用下面的片段覆盖整个语言文件。

英文:

"workspace": {
  "title": "My workspace",
  "description": "Manage your saved links here."
}

简体中文:

"workspace": {
  "title": "我的工作台",
  "description": "在这里管理你收藏的链接。"
}

繁体中文:

"workspace": {
  "title": "我的工作區",
  "description": "在這裡管理你收藏的連結。"
}

资源键以英文文件推导类型,先添加资源再写页面,可以让 TypeScript 检查键名。若项目已启用其他语言,也要补齐同样的键。

二、新建页面文件

新建 src/pages/workspace.tsx:

import { serverLocalized } from '@/components/server/Localized';
import { authenticatedGuard } from '@/core/services/auth/guards/authenticated';
import { createLayoutRenderer } from '@/layout';
import { createLayoutHandlers } from '@/libs/renderer/render';
import { getFullPath } from '@/libs/utils';
import type { Ctx } from '@/types';

function WorkspacePage({ c }: { c: Ctx }) {
    return (
        <main className="container mx-auto px-4 py-12">
            <h1 className="text-3xl font-bold">
                {serverLocalized({ c, id: 'pages.workspace.title' })}
            </h1>
            <p className="mt-4 text-muted-foreground">
                {serverLocalized({ c, id: 'pages.workspace.description' })}
            </p>
        </main>
    );
}

const workspacePageHandlers = createLayoutHandlers(
    createLayoutRenderer('page'),
    async (c: Ctx) => {
        const guard = authenticatedGuard(c);
        if (!guard.success) {
            const redirectUrl = guard.details?.redirectUrl;
            if (typeof redirectUrl !== 'string') {
                throw new Error('Authenticated guard returned no redirect URL');
            }
            return c.redirect(getFullPath(redirectUrl, c.var.getLocale()));
        }

        return <WorkspacePage c={c} />;
    },
    { pageRootClassName: 'flex flex-col' },
);

export default workspacePageHandlers;

authenticatedGuard 读取现有请求上下文中的认证状态,并按配置检查登录、邮箱验证和 2FA。使用它返回的 redirectUrl,才能把用户送到当前需要完成的步骤。

页面上的条件渲染不能代替这一步。只把内容藏起来,或在客户端加载后再跳转,都无法保护服务端返回的数据。

三、注册页面路由

在 src/pages/routes.tsx 导入新文件:

import workspacePageHandlers from './workspace';

在已有 pageRouter 路由声明处添加:

pageRouter.get('/workspace', ...workspacePageHandlers);

把它放在 pageRouter 挂载到上级应用之前。国际化入口会处理语言前缀,不需要另建 /:locale/workspace 路由。

启动开发服务器后,打开 /zh-Hans/workspace。在页面中添加工作台链接时,沿用现有导航组件的配置方式,或使用 getFullPath('/workspace', locale) 生成当前语言的地址。

四、需要管理员权限时怎么改

如果这个页面是管理工具,可以在同一文件增加导入:

import { authz } from '@/authz';

然后在认证成功后、返回页面前添加:

if (!(await authz.isAdmin(c))) {
    return c.notFound();
}

这里选择对普通用户返回 404,避免展示管理入口。若产品需要明确显示无权限,也可以设计专门的 403 页面,文案仍放进语言资源。

这段代码是管理页面的变体。普通用户的工作台不需要添加管理员检查。付费功能的判断见为业务功能接入付费权益。

五、页面和 API 分别保护

工作台后续通过 API 读取数据时,API 仍需独立检查身份。用户可以绕过页面直接请求接口,页面上的检查不会自动保护另一条路由。

API 中沿用相同的 Guard,但返回 JSON 和状态码:

import { gResultCode } from '@/errors';

// 放在 API handler 内,此处的 c 是当前请求上下文。
const guard = authenticatedGuard(c);
if (!guard.success) {
    return c.json(
        guard,
        guard.code === gResultCode.authLoginRequired ? 401 : 403,
    );
}

const { authContext } = guard.data;
const userId = authContext.userId;

userId 来自服务端会话。读取个人数据时,把它传给业务 Service,不要接收前端提交的用户 ID 作为当前身份。API 如何接入后续层次,会在下一篇完整实现。

需要浏览器交互的表单或列表,应沿用现有客户端组件注册和 SSRWrapper 水合方式。直接在服务端页面里写点击事件,并不会让页面自动具有客户端交互能力。

六、验证页面行为

依次检查以下情况:

情况预期结果
未登录直接打开工作台进入登录流程
项目要求验证邮箱,用户尚未验证进入邮箱验证流程
用户的 2FA 状态尚未满足要求进入 Guard 指定的设置或验证流程
已完成认证显示工作台标题和说明
切换到已启用的其他语言URL 和文案随语言变化
管理页面变体由普通用户访问返回 404

邮箱与 2FA 用例以你启用的配置和账户状态为准。不要为了让页面通过而关闭已有认证要求。

完成后运行 npm run lint、npm run typecheck 和 npm run build。这三个命令分别检查代码规范、类型和构建,不能互相替代。

常见问题是路由没有注册、语言键放错层级,或者把所有认证失败都重定向到登录页。遇到问题先检查这三处,再参考认证与账号中的会话说明。