在前 15 天裡,我們完成了核心預約系統、Telegram/Email 雙管道通知與第一階段的正式部署。然而,目前系統主要依賴使用者自行填寫 Email,缺乏嚴格的身分查驗與權限控管。
今天我們將為系統導入 Google OAuth 2.0 / SSO(單一簽入)身分驗證,並在後端建立 RBAC(Role-Based Access Control,基於角色的權限控制)架構。配合系統五間指定房間(A1、A2、B1、B2、洽談室)的營運規則,本篇將完整闡述權限機制,並提供 Google Cloud Console OAuth 2.0 Client ID 申請流程。
要在前端 React 應用中啟用 Google SSO 登入,必須先在 Google Cloud Console 建立 OAuth 用戶端憑證。
NGO-Room-Booking-System),點擊 「建立 (Create)」。房間預約系統
openid、email 與 profile。Room Booking React Frontend。http://localhost:5173(或你的 Vite / React 本地 Port)[https://your-app.vercel.app](https://your-app.vercel.app)(你的前端託管網址)http://localhost:5173)。123456789-xxxxxxxx.apps.googleusercontent.com),複製並妥善保存,此 ID 將配置於 React .env 檔案中。系統的角色劃分為 一般職工 (User) 與 管理員 (Admin)。預約邏輯採用 「時段空置即直接預約」 的規則:
APPROVED。| 功能項目 | 一般職工 (User) | 管理員 (Admin) |
|---|---|---|
| 瀏覽房間與行事曆 | 🟩 允許 | 🟩 允許 |
| 發起預約(房間該時段空置) | 🟩 狀態為 APPROVED |
🟩 狀態為 APPROVED |
| 審核 / 批准 / 駁回預約 | 🟥 拒絕 | 🟩 允許 |
| 取消他人預約 | 🟥 拒絕 | 🟩 允許 |
| 查看審計日誌與統計報表 | 🟥 拒絕 | 🟩 允許 |
| 變更系統設定與權限名單 | 🟥 拒絕 | 🟩 允許 |
除了 Bookings 預約表外,我們新增 User_Roles(角色權限對照表)與 Rooms(房間清單表,使用 A1、A2、B1、B2、洽談室,其中 A1、A2、B1、B2 可容納人數皆為 20 人):
User_Roles)| Email (主鍵) | Role (角色) | Department (部門) | Status (狀態) |
|---|---|---|---|
admin@ngo.org |
ADMIN |
行政總務部 (Administration) | ACTIVE |
socialworker@ngo.org |
USER |
社工服務部 (Social Work Services) | ACTIVE |
volunteer.coord@ngo.org |
USER |
義工服務部 (Volunteer Services) | ACTIVE |
Rooms)| Room_ID | Room_Name | Capacity | Status |
|---|---|---|---|
ROOM_A1 |
A1 | 20 | ACTIVE |
ROOM_A2 |
A2 | 20 | ACTIVE |
ROOM_B1 |
B1 | 20 | ACTIVE |
ROOM_B2 |
B2 | 20 | ACTIVE |
ROOM_VIP |
洽談室 | 4 | ACTIVE |
AuthAndApprovalService.gs)包含 Token 驗證、權限檢查以及時段空置檢測 API:
/**
* 驗證 Google OAuth Token 並取得使用者角色
*/
function verifyAndGetUserProfile(idToken) {
if (!idToken) throw new Error('UNAUTHORIZED: 缺少認證 Token');
const authUrl = `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`;
const response = UrlFetchApp.fetch(authUrl, { muteHttpExceptions: true });
if (response.getResponseCode() !== 200) {
throw new Error('UNAUTHORIZED: Token 無效或已過期');
}
const tokenInfo = JSON.parse(response.getContentText());
const userEmail = tokenInfo.email;
if (!tokenInfo.email_verified) {
throw new Error('FORBIDDEN: Email 未通過 Google 驗證');
}
const roleInfo = getUserRoleFromSheet(userEmail);
return {
email: userEmail,
name: tokenInfo.name || userEmail.split('@')[0],
picture: tokenInfo.picture || '',
role: roleInfo.role, // ADMIN, USER
department: roleInfo.department,
status: roleInfo.status
};
}
/**
* 從 Sheet 查詢角色
*/
function getUserRoleFromSheet(email) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('User_Roles');
if (!sheet) return { role: 'USER', department: 'Social Work Services', status: 'ACTIVE' };
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (data[i][0].toLowerCase() === email.toLowerCase()) {
return { role: data[i][1], department: data[i][2], status: data[i][3] };
}
}
return { role: 'USER', department: 'Social Work Services', status: 'ACTIVE' };
}
/**
* 檢查房間時段是否空置
*/
function isRoomVacant(roomId, startTimeStr, endTimeStr) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const sheet = ss.getSheetByName('Bookings');
if (!sheet) return true;
const data = sheet.getDataRange().getValues();
const start = new Date(startTimeStr).getTime();
const end = new Date(endTimeStr).getTime();
for (let i = 1; i < data.length; i++) {
const existingRoom = data[i][1];
const existingStart = new Date(data[i][3]).getTime();
const existingEnd = new Date(data[i][4]).getTime();
const existingStatus = data[i][5]; // APPROVED, REJECTED
// 僅針對同房間且非 REJECTED 的預約進行檢測
if (existingRoom === roomId && existingStatus !== 'REJECTED') {
// 時間重疊檢測條件:(StartA < EndB) && (EndA > StartB)
if (start < existingEnd && end > existingStart) {
return false; // 非空置,存在重疊
}
}
}
return true; // 空置無人預約
}
/**
* 發起預約 API(僅限時段空置時可預約)
*/
function createBooking(userProfile, bookingData) {
const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
const bookingSheet = ss.getSheetByName('Bookings');
// 檢測房間該時段是否空置
const vacant = isRoomVacant(bookingData.roomId, bookingData.startTime, bookingData.endTime);
if (!vacant) {
throw new Error('CONFLICT: 該時段已有預約,不開放預約');
}
const bookingId = 'BK_' + new Date().getTime();
bookingSheet.appendRow([
bookingId,
bookingData.roomId,
userProfile.email,
bookingData.startTime,
bookingData.endTime,
'APPROVED',
new Date()
]);
return { bookingId, status: 'APPROVED', isVacant: true };
}
App.jsx)import React, { useState } from 'react';
import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google';
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
export default function App() {
const [user, setUser] = useState(null);
const handleLoginSuccess = async (credentialResponse) => {
const idToken = credentialResponse.credential;
try {
const res = await fetch(import.meta.env.VITE_GAS_API_URL, {
method: 'POST',
body: JSON.stringify({ action: 'VERIFY_AUTH', idToken })
});
const data = await res.json();
if (data.status === 'SUCCESS') {
setUser({ ...data.profile, idToken });
}
} catch (err) {
console.error('認證失敗', err);
}
};
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<div className="app-container">
{!user ? (
<div className="login-box">
<h2>房間預約系統 - 單一簽入</h2>
<GoogleLogin onSuccess={handleLoginSuccess} onError={() => console.log('Login Failed')} />
</div>
) : (
<main>
<header>
<h3>歡迎,{user.name} ({user.role}) - 部門:{user.department}</h3>
</header>
</main>
)}
</div>
</GoogleOAuthProvider>
);
}
| 測試編號 | 測試情境 | 測試角色 | 預期結果 |
|---|---|---|---|
| TC-07 | 預約空置時段(A1/A2/B1/B2/洽談室) | USER / ADMIN |
預約成功,狀態設為 APPROVED |
| TC-08 | 預約已存在其他預約的重疊時段 | USER / ADMIN |
系統拒絕預約,回傳時段已佔用提示 |
| TC-09 | 管理員執行取消預約功能 | ADMIN |
成功取消預約 |