新增登入後才能使用的業務頁面

從多語系文案和頁面路由開始,建立能正確處理登入、電子郵件驗證和 2FA 的工作區。

這一篇要建立 /workspace 工作區。訪客必須完成專案要求的身分驗證步驟才能開啟這個頁面,尚未登入、尚未驗證電子郵件或尚未完成 2FA 時,會進入對應的處理頁面。

請先確認本機專案能透過 npm run dev 啟動,而且可以註冊、登入。本篇先建立頁面入口,下一篇再為業務功能新增資料 API。

一、先準備頁面文案

在 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-Hant/workspace。在頁面中新增工作區連結時,沿用現有導覽元件的設定方式,或使用 getFullPath('/workspace', locale) 產生目前語言的網址。

四、需要管理員權限時如何修改

如果這個頁面是管理工具,可以在同一個檔案中新增匯入:

import { authz } from '@/authz';

接著在身分驗證成功後、回傳頁面前新增:

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

這裡選擇對一般使用者回傳 404,避免顯示管理入口。如果產品需要明確顯示沒有權限,也可以設計專用的 403 頁面,文案一樣放進語言資源。

這段程式碼是管理頁面的延伸做法。一般使用者的工作區不需要加入管理員檢查。付費功能的判斷方式請見為業務功能串接付費權益。

五、頁面和 API 分別保護

工作區後續透過 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。這三個指令分別檢查程式碼規範、型別和建置,無法互相取代。

常見問題包括路由尚未註冊、語言鍵放錯層級,或將所有身分驗證失敗的情況都重新導向登入頁面。遇到問題時先檢查這三處,再參考使用者身分驗證與帳號中的工作階段說明。