AdSense

網頁

2026/1/20

Vue Nuxt 3 側邊選單權限控管

本篇介紹Vue Nuxt 3的側邊功能選單(Sidebar)的權限控管實作。


專案目前只有一個使用者[admin],其擁有側邊選單所有權限的角色[admin],換句話說就是可見和可點擊側邊選單的所有功能項目。接下來會加入一個一般使用者與角色[user],只允許使用[出口業務],但無法使用[會員管理]。

這種根據使用者擁有的角色的權限控管方式簡稱為RBAC(Role-Based Access Control,基於角色的存取控制)

範例只有兩個角色且僅控制頁面是否隱藏,所以僅使用[角色]來做權限控制,但實務上比較複雜的權限控制會帶入[群組(Group)]和[權限(Permission)]的概念。


事前要求

參考「Vue Nuxt 3 加入側邊選單」,為側邊選單來源。

參考「Vue Nuxt 3 登入驗證」,了解登入驗證方式。

參考「Vue Nuxt 3 登入狀態管理」,了解前端狀態同步。


建立User介面

在專案根目錄新增shared/types/user.ts。Nuxt專案的shared目錄中的程式碼可讓前端Vue和server公享(參照)。

User介面如下,另外定義去除敏感資訊password屬性的PublicUser介面。

shared/types/user.ts

export interface User {
  username: string;
  password: string;
  name: string;
  role: "admin" | "user";
}

export type PublicUser = Omit<User, 'password'>


建立模擬資料

新增server/data/users.ts,用來模擬後端的使用者資料及所持的角色,可假想為資料表中的資料。引入User介面作為使用者的資料格式。

定義兩個使用者,角色(role)分別為管理員[admin]和一般使用者[user]。

server/data/users.ts

import type { User } from '~/shared/types/user'

export const users: User[] = [
  {
    username: "admin",
    password: "1234",
    name: "admin",
    role: "admin",
  },
  {
    username: "wang1",
    password: "1234",
    name: "王一",
    role: "user",
  },
];


修改登入API

修改p登入API]檔案server/api/auth/login.post.ts,把檢驗帳號密碼的部分改為從server/data/users.ts取得使用者資料;修改token為帶username的格式fake-token-${username};把使用者資料放入回應物件回傳給前端。

server/api/auth/login.post.ts

import { users } from "~/server/data/users";

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  const { username, password } = body;

  // 依帳號密碼尋找使用者
  const user = users.find(
    (u) => u.username === username && u.password === password,
  );

  if (!user) {
    return {
      success: false,
    };
  }

  // 模擬token
  const token = `fake-token-${username}`;
  setCookie(event, "token", token, {
    httpOnly: true,
    maxAge: 60 * 60 * 24, // 1 天
  });

  return {
    success: true,
    token,
    user: {
      username: user.username,
      name: user.name,
      role: user.role,
    },
  };
});


修改LoginResponse介面

修改types.auth.ts中的LoginResponse,新增PublicUser欄位來接收[登入API]回傳的使用者資料。

types/auth.ts

import type { PublicUser } from "~/shared/types/user";

export interface LoginResponse {
  success: boolean;
  token?: string;
  user?: PublicUser; // <--
}


新增使用者狀態

新增composables/useUser.ts,定義組合函式useUser建立[使用者狀態]user來存放使用者資料供前後端執行時存取。

composables/useUser.ts

import type { PublicUser } from "~/shared/types/user";

export const useUser = () => useState<PublicUser | null>("user", () => null);


修改登入頁面

修改pages/login.vue即登入頁面中接收[登入API]回應資料的部分,要接收PublicUser資料並放入狀態userState,也就是說登入後前端SPA路由都可從[使用者狀態]中取得使用者資料。

pages/login.vue

<template>
  <h1>登入</h1>
  <!--..._-->
</template>

<script setup lang="ts">
definePageMeta({
  layout: 'auth'
})

import type { LoginResponse } from '~/types/auth'

const isLogin = useAuth()
const userState = useUser()
const username = ref('')
const password = ref('')

const login = async () => {
  try {
    const { success, token, user } = await $fetch<LoginResponse>('/api/auth/login', {
      method: 'POST',
      body: { username: username.value, password: password.value }
    })
    if (success && token && user) {
      isLogin.value = true
      userState.value = user // <--
      navigateTo('/home')
    } else {
      alert('帳號或密碼錯誤')
    }
  } catch (err) {
    alert('登入失敗,請稍後再試')
  }
}
</script>


建立SidebarItem介面

建立types/sidebar.ts,定義SidebarItem為側邊選單元素的資料格式。

types/sidebar.ts

export interface SidebarItem {
  label: string;
  path: string;
  roles: string[];
}


新增使用者資料API

新增server/api/users.get.ts,即建立模擬API [GET] /api/users,從前端傳入的請求頭Bearer token中的username來獲取對應的使用者資料。

server/api/users.get.ts

import { users } from "~/server/data/users";
import type { PublicUser } from "~/shared/types/user";

export default defineEventHandler((event): PublicUser => {
  const authHeader = getHeader(event, "authorization");

  if (!authHeader) {
    throw createError({
      statusCode: 401,
      statusMessage: "Missing Authorization header",
    });
  }

  // Bearer fake-token-username
  const [type, token] = authHeader.split(" ");

  if (type !== "Bearer" || !token) {
    throw createError({
      statusCode: 401,
      statusMessage: "Invalid Authorization format",
    });
  }

  const username = token.replace("fake-token-", "");

  const user = users.find((u) => u.username === username);

  if (!user) {
    throw createError({
      statusCode: 401,
      statusMessage: "User not found",
    });
  }

  return {
    username: user.username,
    name: user.name,
    role: user.role,
  };
});


新增驗證使用者函式

新增server/utils/auth.ts,建立verifyUser函式,帶入token來呼叫[使用者資料API]來取得使用者資料。Middleware驗證SSR路由時需要利用此函式來同步[使用者狀態]。

server/utils/auth.ts

import type { PublicUser } from "~/shared/types/user";

export async function verifyUser(token: string): Promise<PublicUser | null> {
  try {
    // 呼叫後端使用者資料API,帶入token取得使用者資料
    const user = await $fetch<PublicUser>("/api/users", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });

    return user;
  } catch (err) {
    return null;
  }
}


修改路由驗證Middleware

修改middleware/auth.global.ts,在SSR路由驗證部分要呼叫verifyUser函式並帶入從Cookie取得的token(在一開始登入成功後token就由後端放入Cookie)來驗證並取得使用者資料,然後放入userState中來同步前端與SSR的[使用者狀態]。

加入驗證非[admin]角色不可前往/home/member頁面的邏輯。

middleware/auth.global.ts

import { verifyUser } from "~/server/utils/auth";

export default defineNuxtRouteMiddleware(async (to) => {
  if (to.path === "/login") return; // 登入頁面不用驗證

  const isLogin = useAuth();
  const userState = useUser();

  // 檢查SSR路由登入驗證
  if (import.meta.server) {
    const token = useCookie("token").value;
    if (!token) {
      return navigateTo("/login");
    }

    // 同步前端登入狀態
    isLogin.value = true;
    userState.value = await verifyUser(token); // <--
  }

  // 檢查SPA路由登入驗證
  if (import.meta.client) {
    if (!isLogin.value) {
      return navigateTo("/login");
    }
  }

  if (to.path === "/home/member" && userState.value?.role !== "admin") {
    return navigateTo("/home");
  }
});


修改側邊選單組件

修改側邊選單組件components/Sidebar.vue,使用SidebarItem陣列定義選單項目可使用的角色;選單項目改用v-forSidebarItem[]陣列取得,並從[使用者狀態]取得使用者的角色來過濾目前使用者可使用的選單項目。注意光靠前端隱藏功能並不安全,功能權限最終仍需要在後端API進行把控。

components/Sidebar.vue

<template>
  <aside class="sidebar">
    <nav>
      <ul>
        <li v-for="item in visibleItems">
          <NuxtLink :key="item.path" :to="item.path" active-class="active">
            {{ item.label }}
          </NuxtLink>
        </li>
      </ul>
    </nav>
  </aside>
</template>

<script setup lang="ts">
import type { SidebarItem } from '~/types/sidebar'

// Sidebar 功能清單
const sidebarItems: SidebarItem[] = [
  { label: '會員設定', path: '/home/member', roles: ['admin'] },
  { label: '出口業務', path: '/home/export', roles: ['admin', 'user'] },
]

const userState = useUser()

// 依角色過濾可見項目
const visibleItems = computed(() => {
  if (!userState.value) return []
  return sidebarItems.filter(item =>
    userState.value &&
    item.roles.includes(userState.value.role))
})
</script>


修改導覽列組件

在導覽列組件components/Header.vue加入使用者名稱和角色的顯示,和調整CSS。

components/Header.vue

<template>
  <header class="header">
    <nav class="nav-left">
      <ul>
        <li>
          <NuxtLink to="/">Home</NuxtLink>
        </li>
        <li>
          <NuxtLink to="/about">About</NuxtLink>
        </li>
      </ul>
    </nav>
    <!-- 右上角使用者資訊 -->
    <div class="nav-right" v-if="user">
      {{ user.name }} ({{ user.role }})
    </div>
  </header>
</template>

<script setup lang="ts">
const user = useUser()
</script>

<style scoped>
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #ccc;
  padding: 8px 16px;
}

.nav-left ul {
  display: flex;
  gap: 8px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.nav-right {
  font-weight: bold;
}
</style>

github



測試

使用一般使用者帳號登入後,側邊選單看不到[會員設定]項目,右上角也顯示使用者名稱與角色。直接在瀏覽器輸入http://localhost:3000/home/member會被導向至http://localhost:3000/home

目前尚未實作登出功能,可開啟瀏覽器開發者模式刪除此網站的token Cookie即可登出。



接著參考「Vue Nuxt 3 使用Page Meta管理頁面角色存取」把改用Page Meta設定頁面的存取規則。

沒有留言:

AdSense