Spaces:
Running
Running
Upload 10 files
Browse files- src/services/auth.js +128 -0
- src/services/firebase.js +4 -1
- src/views/AdminView.js +18 -0
- src/views/InstructorView.js +215 -57
src/services/auth.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { auth, googleProvider, db } from "./firebase.js";
|
| 2 |
+
import { signInWithPopup, signOut } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
| 3 |
+
import {
|
| 4 |
+
doc,
|
| 5 |
+
getDoc,
|
| 6 |
+
setDoc,
|
| 7 |
+
updateDoc,
|
| 8 |
+
collection,
|
| 9 |
+
getDocs,
|
| 10 |
+
deleteDoc,
|
| 11 |
+
serverTimestamp,
|
| 12 |
+
query
|
| 13 |
+
} from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
|
| 14 |
+
|
| 15 |
+
const INSTRUCTORS_COLLECTION = "instructors";
|
| 16 |
+
const SUPER_ADMIN_EMAIL = "t92206@gmail.com";
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Sign in with Google
|
| 20 |
+
*/
|
| 21 |
+
export async function signInWithGoogle() {
|
| 22 |
+
try {
|
| 23 |
+
const result = await signInWithPopup(auth, googleProvider);
|
| 24 |
+
return result.user;
|
| 25 |
+
} catch (error) {
|
| 26 |
+
console.error("Google Sign-In Error:", error);
|
| 27 |
+
throw error;
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Sign out
|
| 33 |
+
*/
|
| 34 |
+
export async function signOutUser() {
|
| 35 |
+
try {
|
| 36 |
+
await signOut(auth);
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error("Sign Out Error:", error);
|
| 39 |
+
throw error;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Check if user is an instructor and get permissions
|
| 45 |
+
* Bootstraps the Super Admin if not exists
|
| 46 |
+
* @param {object} user - Firebase User object
|
| 47 |
+
* @returns {Promise<object|null>} Instructor data or null if not authorized
|
| 48 |
+
*/
|
| 49 |
+
export async function checkInstructorPermission(user) {
|
| 50 |
+
if (!user || !user.email) return null;
|
| 51 |
+
|
| 52 |
+
const email = user.email;
|
| 53 |
+
const instructorRef = doc(db, INSTRUCTORS_COLLECTION, email);
|
| 54 |
+
const snap = await getDoc(instructorRef);
|
| 55 |
+
|
| 56 |
+
// Bootstrap Super Admin
|
| 57 |
+
if (email === SUPER_ADMIN_EMAIL) {
|
| 58 |
+
const adminData = {
|
| 59 |
+
name: user.displayName || "Super Admin",
|
| 60 |
+
email: email,
|
| 61 |
+
role: 'admin',
|
| 62 |
+
permissions: ['create_room', 'add_question', 'manage_instructors'],
|
| 63 |
+
lastLogin: serverTimestamp()
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
if (!snap.exists()) {
|
| 67 |
+
await setDoc(instructorRef, {
|
| 68 |
+
...adminData,
|
| 69 |
+
createdAt: serverTimestamp()
|
| 70 |
+
});
|
| 71 |
+
} else {
|
| 72 |
+
// Ensure admin always has full permissions
|
| 73 |
+
await updateDoc(instructorRef, {
|
| 74 |
+
role: 'admin',
|
| 75 |
+
permissions: ['create_room', 'add_question', 'manage_instructors'],
|
| 76 |
+
lastLogin: serverTimestamp()
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
return adminData;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (snap.exists()) {
|
| 83 |
+
const data = snap.data();
|
| 84 |
+
await updateDoc(instructorRef, { lastLogin: serverTimestamp() });
|
| 85 |
+
return data;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return null; // Not an instructor
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Get all instructors (Admin Only)
|
| 93 |
+
*/
|
| 94 |
+
export async function getInstructors() {
|
| 95 |
+
const q = query(collection(db, INSTRUCTORS_COLLECTION));
|
| 96 |
+
const snapshot = await getDocs(q);
|
| 97 |
+
return snapshot.docs.map(doc => doc.data());
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Add new instructor (Admin Only)
|
| 102 |
+
*/
|
| 103 |
+
export async function addInstructor(email, name, permissions) {
|
| 104 |
+
const instructorRef = doc(db, INSTRUCTORS_COLLECTION, email);
|
| 105 |
+
await setDoc(instructorRef, {
|
| 106 |
+
email,
|
| 107 |
+
name,
|
| 108 |
+
role: 'instructor',
|
| 109 |
+
permissions,
|
| 110 |
+
createdAt: serverTimestamp()
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* Update instructor (Admin Only)
|
| 116 |
+
*/
|
| 117 |
+
export async function updateInstructor(email, data) {
|
| 118 |
+
const instructorRef = doc(db, INSTRUCTORS_COLLECTION, email);
|
| 119 |
+
await updateDoc(instructorRef, data);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Remove instructor (Admin Only)
|
| 124 |
+
*/
|
| 125 |
+
export async function removeInstructor(email) {
|
| 126 |
+
if (email === SUPER_ADMIN_EMAIL) throw new Error("Cannot remove Super Admin");
|
| 127 |
+
await deleteDoc(doc(db, INSTRUCTORS_COLLECTION, email));
|
| 128 |
+
}
|
src/services/firebase.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
|
| 2 |
import { getFirestore } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
|
|
|
|
| 3 |
|
| 4 |
const firebaseConfig = {
|
| 5 |
apiKey: "AIzaSyDq2a-yE6pbaeNf6KzkTcogh9oi-6nQbKk",
|
|
@@ -12,5 +13,7 @@ const firebaseConfig = {
|
|
| 12 |
|
| 13 |
const app = initializeApp(firebaseConfig);
|
| 14 |
const db = getFirestore(app);
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
export { db };
|
|
|
|
| 1 |
import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js";
|
| 2 |
import { getFirestore } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
|
| 3 |
+
import { getAuth, GoogleAuthProvider } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js";
|
| 4 |
|
| 5 |
const firebaseConfig = {
|
| 6 |
apiKey: "AIzaSyDq2a-yE6pbaeNf6KzkTcogh9oi-6nQbKk",
|
|
|
|
| 13 |
|
| 14 |
const app = initializeApp(firebaseConfig);
|
| 15 |
const db = getFirestore(app);
|
| 16 |
+
const auth = getAuth(app);
|
| 17 |
+
const googleProvider = new GoogleAuthProvider();
|
| 18 |
|
| 19 |
+
export { db, auth, googleProvider };
|
src/views/AdminView.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import { getChallenges, createChallenge, updateChallenge, deleteChallenge } from "../services/classroom.js";
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export function renderAdminView() {
|
| 4 |
return `
|
|
@@ -66,6 +68,22 @@ export function renderAdminView() {
|
|
| 66 |
}
|
| 67 |
|
| 68 |
export function setupAdminEvents() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
loadChallenges();
|
| 70 |
|
| 71 |
document.getElementById('back-instructor-btn').addEventListener('click', () => {
|
|
|
|
| 1 |
import { getChallenges, createChallenge, updateChallenge, deleteChallenge } from "../services/classroom.js";
|
| 2 |
+
import { checkInstructorPermission } from "../services/auth.js";
|
| 3 |
+
import { auth } from "../services/firebase.js";
|
| 4 |
|
| 5 |
export function renderAdminView() {
|
| 6 |
return `
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
export function setupAdminEvents() {
|
| 71 |
+
// Permission Check
|
| 72 |
+
const user = auth.currentUser;
|
| 73 |
+
if (!user) {
|
| 74 |
+
alert("請先登入");
|
| 75 |
+
window.location.hash = ''; // Back to Landing
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
checkInstructorPermission(user).then(inst => {
|
| 80 |
+
if (!inst || !inst.permissions?.includes('add_question')) {
|
| 81 |
+
alert("您沒有權限管理題目");
|
| 82 |
+
window.location.hash = 'instructor';
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
loadChallenges();
|
| 88 |
|
| 89 |
document.getElementById('back-instructor-btn').addEventListener('click', () => {
|
src/views/InstructorView.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser } from "../services/classroom.js";
|
|
|
|
| 2 |
import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.js";
|
| 3 |
|
| 4 |
// Load html-to-image dynamically (Better support than html2canvas)
|
|
@@ -19,10 +20,51 @@ export async function renderInstructorView() {
|
|
| 19 |
|
| 20 |
return `
|
| 21 |
<div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur z-50 flex items-center justify-center">
|
| 22 |
-
<div class="bg-gray-800 p-8 rounded-xl border border-gray-600 shadow-2xl max-w-sm w-full">
|
| 23 |
-
<h2 class="text-xl font-bold
|
| 24 |
-
<
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
</div>
|
| 28 |
|
|
@@ -218,7 +260,10 @@ export async function renderInstructorView() {
|
|
| 218 |
<button id="group-photo-btn" class="hidden bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold py-2 px-4 rounded-lg transition-all shadow-lg border border-pink-400/30 flex items-center space-x-2">
|
| 219 |
<span>📸 大合照</span>
|
| 220 |
</button>
|
| 221 |
-
<button id="nav-
|
|
|
|
|
|
|
|
|
|
| 222 |
管理題目
|
| 223 |
</button>
|
| 224 |
<div id="create-room-container" class="flex items-center space-x-2">
|
|
@@ -262,66 +307,183 @@ export async function renderInstructorView() {
|
|
| 262 |
|
| 263 |
export function setupInstructorEvents() {
|
| 264 |
let roomUnsubscribe = null;
|
|
|
|
| 265 |
|
| 266 |
-
//
|
| 267 |
-
const authBtn = document.getElementById('auth-btn');
|
| 268 |
-
const pwdInput = document.getElementById('instructor-password');
|
| 269 |
const authModal = document.getElementById('auth-modal');
|
|
|
|
| 270 |
|
| 271 |
-
//
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
-
};
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
|
|
|
|
|
|
| 292 |
try {
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
authModal.classList.add('hidden');
|
| 296 |
-
|
| 297 |
-
|
|
|
|
| 298 |
} else {
|
| 299 |
-
|
| 300 |
-
|
|
|
|
| 301 |
}
|
| 302 |
-
} catch (
|
| 303 |
-
console.error(
|
| 304 |
-
|
|
|
|
| 305 |
} finally {
|
| 306 |
-
authBtn.textContent = "確認進入";
|
| 307 |
authBtn.disabled = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
};
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
});
|
| 315 |
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
// Snapshot Logic
|
| 327 |
snapshotBtn.addEventListener('click', async () => {
|
|
@@ -705,15 +867,11 @@ export function setupInstructorEvents() {
|
|
| 705 |
});
|
| 706 |
|
| 707 |
// Logout Logic
|
| 708 |
-
document.getElementById('logout-btn').addEventListener('click', () => {
|
| 709 |
if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
|
| 710 |
-
|
| 711 |
sessionStorage.removeItem('vibecoding_instructor_in_room');
|
| 712 |
sessionStorage.removeItem('vibecoding_admin_referer');
|
| 713 |
-
// We can optionally clear room history or keep it
|
| 714 |
-
// localStorage.removeItem('vibecoding_instructor_room');
|
| 715 |
-
|
| 716 |
-
// Clear hash to trigger main router back to Landing or default
|
| 717 |
window.location.hash = '';
|
| 718 |
window.location.reload();
|
| 719 |
}
|
|
@@ -732,10 +890,10 @@ export function setupInstructorEvents() {
|
|
| 732 |
}
|
| 733 |
});
|
| 734 |
|
| 735 |
-
// Check Previous Session
|
| 736 |
-
if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
|
| 737 |
-
|
| 738 |
-
}
|
| 739 |
|
| 740 |
// Check Active Room State
|
| 741 |
const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
|
|
|
|
| 1 |
import { createRoom, subscribeToRoom, getChallenges, resetProgress, removeUser } from "../services/classroom.js";
|
| 2 |
+
import { signInWithGoogle, signOutUser, checkInstructorPermission, getInstructors, addInstructor, updateInstructor, removeInstructor } from "../services/auth.js";
|
| 3 |
import { generateMonsterSVG, getNextMonster, MONSTER_DEFS } from "../utils/monsterUtils.js";
|
| 4 |
|
| 5 |
// Load html-to-image dynamically (Better support than html2canvas)
|
|
|
|
| 20 |
|
| 21 |
return `
|
| 22 |
<div id="auth-modal" class="fixed inset-0 bg-black bg-opacity-90 backdrop-blur z-50 flex items-center justify-center">
|
| 23 |
+
<div class="bg-gray-800 p-8 rounded-xl border border-gray-600 shadow-2xl max-w-sm w-full text-center">
|
| 24 |
+
<h2 class="text-xl font-bold mb-6 text-white">🔒 講師登入</h2>
|
| 25 |
+
<p class="text-gray-400 mb-6 text-sm">請使用已授權的 Google 帳號登入</p>
|
| 26 |
+
<button id="google-auth-btn" class="w-full bg-white text-gray-900 font-bold py-3 rounded-lg flex items-center justify-center space-x-2 hover:bg-gray-100 transition-colors">
|
| 27 |
+
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" class="w-5 h-5">
|
| 28 |
+
<span>Sign in with Google</span>
|
| 29 |
+
</button>
|
| 30 |
+
<p id="auth-error-msg" class="text-red-500 mt-4 hidden text-sm"></p>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- Instructor Management Modal -->
|
| 35 |
+
<div id="instructor-modal" class="fixed inset-0 bg-gray-900/95 backdrop-blur z-[80] hidden flex flex-col items-center justify-center p-4">
|
| 36 |
+
<div class="bg-gray-800 rounded-xl border border-gray-700 shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
| 37 |
+
<div class="p-6 border-b border-gray-700 flex justify-between items-center">
|
| 38 |
+
<h2 class="text-2xl font-bold text-white">👥 管理講師權限 (Manage Instructors)</h2>
|
| 39 |
+
<button onclick="document.getElementById('instructor-modal').classList.add('hidden')" class="text-gray-400 hover:text-white text-2xl">✕</button>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="p-6 flex-1 overflow-y-auto">
|
| 43 |
+
<div class="mb-6 flex space-x-2">
|
| 44 |
+
<input type="email" id="new-inst-email" placeholder="Email (Gmail)" class="bg-gray-900 border border-gray-600 text-white px-4 py-2 rounded flex-1">
|
| 45 |
+
<input type="text" id="new-inst-name" placeholder="姓名" class="bg-gray-900 border border-gray-600 text-white px-4 py-2 rounded w-32">
|
| 46 |
+
<div class="flex items-center space-x-2 text-sm text-gray-300 bg-gray-900 px-3 rounded border border-gray-600">
|
| 47 |
+
<label><input type="checkbox" id="perm-room" checked> 開房</label>
|
| 48 |
+
<label><input type="checkbox" id="perm-q" checked> 題目</label>
|
| 49 |
+
<label><input type="checkbox" id="perm-inst"> 管理人</label>
|
| 50 |
+
</div>
|
| 51 |
+
<button id="btn-add-inst" class="bg-green-600 hover:bg-green-500 text-white px-4 py-2 rounded font-bold">新增</button>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<table class="w-full text-left border-collapse">
|
| 55 |
+
<thead>
|
| 56 |
+
<tr class="text-gray-400 border-b border-gray-700">
|
| 57 |
+
<th class="p-3">姓名</th>
|
| 58 |
+
<th class="p-3">Email</th>
|
| 59 |
+
<th class="p-3">權限</th>
|
| 60 |
+
<th class="p-3">動作</th>
|
| 61 |
+
</tr>
|
| 62 |
+
</thead>
|
| 63 |
+
<tbody id="instructor-list-body" class="text-gray-300">
|
| 64 |
+
<!-- Dynamic list -->
|
| 65 |
+
</tbody>
|
| 66 |
+
</table>
|
| 67 |
+
</div>
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
|
|
|
|
| 260 |
<button id="group-photo-btn" class="hidden bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold py-2 px-4 rounded-lg transition-all shadow-lg border border-pink-400/30 flex items-center space-x-2">
|
| 261 |
<span>📸 大合照</span>
|
| 262 |
</button>
|
| 263 |
+
<button id="nav-instructors-btn" class="hidden bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2 px-4 rounded-lg transition-all border border-indigo-400/30 mr-2">
|
| 264 |
+
👥 管理講師
|
| 265 |
+
</button>
|
| 266 |
+
<button id="nav-admin-btn" class="hidden bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg transition-all border border-gray-600">
|
| 267 |
管理題目
|
| 268 |
</button>
|
| 269 |
<div id="create-room-container" class="flex items-center space-x-2">
|
|
|
|
| 307 |
|
| 308 |
export function setupInstructorEvents() {
|
| 309 |
let roomUnsubscribe = null;
|
| 310 |
+
let currentInstructor = null;
|
| 311 |
|
| 312 |
+
// UI References
|
| 313 |
+
const authBtn = document.getElementById('google-auth-btn');
|
|
|
|
| 314 |
const authModal = document.getElementById('auth-modal');
|
| 315 |
+
const authErrorMsg = document.getElementById('auth-error-msg');
|
| 316 |
|
| 317 |
+
// Core Buttons
|
| 318 |
+
const createBtn = document.getElementById('create-room-btn');
|
| 319 |
+
const navAdminBtn = document.getElementById('nav-admin-btn');
|
| 320 |
+
const navInstBtn = document.getElementById('nav-instructors-btn');
|
| 321 |
+
|
| 322 |
+
// Other UI
|
| 323 |
+
const roomInfo = document.getElementById('room-info');
|
| 324 |
+
const createContainer = document.getElementById('create-room-container');
|
| 325 |
+
const dashboardContent = document.getElementById('dashboard-content');
|
| 326 |
+
const displayRoomCode = document.getElementById('display-room-code');
|
| 327 |
+
const groupPhotoBtn = document.getElementById('group-photo-btn');
|
| 328 |
+
const snapshotBtn = document.getElementById('snapshot-btn');
|
| 329 |
+
let isSnapshotting = false;
|
| 330 |
+
|
| 331 |
+
// Permission Check Helper
|
| 332 |
+
const checkPermissions = (instructor) => {
|
| 333 |
+
if (!instructor) return;
|
| 334 |
+
|
| 335 |
+
currentInstructor = instructor;
|
| 336 |
+
|
| 337 |
+
// 1. Create Room Permission
|
| 338 |
+
if (instructor.permissions?.includes('create_room')) {
|
| 339 |
+
createBtn.classList.remove('hidden', 'opacity-50', 'cursor-not-allowed');
|
| 340 |
+
createBtn.disabled = false;
|
| 341 |
+
} else {
|
| 342 |
+
createBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
| 343 |
+
createBtn.disabled = true;
|
| 344 |
+
createBtn.title = "無此權限";
|
| 345 |
}
|
|
|
|
| 346 |
|
| 347 |
+
// 2. Add Question Permission (Admin Button)
|
| 348 |
+
if (instructor.permissions?.includes('add_question')) {
|
| 349 |
+
navAdminBtn.classList.remove('hidden');
|
| 350 |
+
} else {
|
| 351 |
+
navAdminBtn.classList.add('hidden');
|
| 352 |
+
}
|
| 353 |
|
| 354 |
+
// 3. Manage Instructors Permission
|
| 355 |
+
if (instructor.permissions?.includes('manage_instructors')) {
|
| 356 |
+
navInstBtn.classList.remove('hidden');
|
| 357 |
+
} else {
|
| 358 |
+
navInstBtn.classList.add('hidden');
|
| 359 |
+
}
|
| 360 |
+
};
|
| 361 |
|
| 362 |
+
// Google Auth Logic
|
| 363 |
+
authBtn.addEventListener('click', async () => {
|
| 364 |
try {
|
| 365 |
+
authBtn.disabled = true;
|
| 366 |
+
authBtn.classList.add('opacity-50');
|
| 367 |
+
|
| 368 |
+
const user = await signInWithGoogle();
|
| 369 |
+
const instructorData = await checkInstructorPermission(user);
|
| 370 |
+
|
| 371 |
+
if (instructorData) {
|
| 372 |
authModal.classList.add('hidden');
|
| 373 |
+
checkPermissions(instructorData);
|
| 374 |
+
// Save name for avatar
|
| 375 |
+
localStorage.setItem('vibecoding_instructor_name', instructorData.name);
|
| 376 |
} else {
|
| 377 |
+
authErrorMsg.textContent = "未授權的帳號 (Unauthorized Account)";
|
| 378 |
+
authErrorMsg.classList.remove('hidden');
|
| 379 |
+
await signOutUser();
|
| 380 |
}
|
| 381 |
+
} catch (error) {
|
| 382 |
+
console.error(error);
|
| 383 |
+
authErrorMsg.textContent = "登入失敗: " + error.message;
|
| 384 |
+
authErrorMsg.classList.remove('hidden');
|
| 385 |
} finally {
|
|
|
|
| 386 |
authBtn.disabled = false;
|
| 387 |
+
authBtn.classList.remove('opacity-50');
|
| 388 |
+
}
|
| 389 |
+
});
|
| 390 |
+
|
| 391 |
+
// Handle Instructor Management
|
| 392 |
+
navInstBtn.addEventListener('click', async () => {
|
| 393 |
+
const modal = document.getElementById('instructor-modal');
|
| 394 |
+
const listBody = document.getElementById('instructor-list-body');
|
| 395 |
+
|
| 396 |
+
// Load list
|
| 397 |
+
const instructors = await getInstructors();
|
| 398 |
+
listBody.innerHTML = instructors.map(inst => `
|
| 399 |
+
<tr class="border-b border-gray-700 hover:bg-gray-800">
|
| 400 |
+
<td class="p-3">${inst.name}</td>
|
| 401 |
+
<td class="p-3 font-mono text-sm text-cyan-400">${inst.email}</td>
|
| 402 |
+
<td class="p-3 text-xs">
|
| 403 |
+
${inst.permissions?.map(p => {
|
| 404 |
+
const map = { create_room: '開房', add_question: '題目', manage_instructors: '管理' };
|
| 405 |
+
return `<span class="bg-gray-700 px-2 py-1 rounded mr-1">${map[p] || p}</span>`;
|
| 406 |
+
}).join('')}
|
| 407 |
+
</td>
|
| 408 |
+
<td class="p-3">
|
| 409 |
+
${inst.role === 'admin' ? '<span class="text-gray-500 italic">不可移除</span>' :
|
| 410 |
+
`<button class="text-red-400 hover:text-red-300" onclick="window.removeInst('${inst.email}')">移除</button>`}
|
| 411 |
+
</td>
|
| 412 |
+
</tr>
|
| 413 |
+
`).join('');
|
| 414 |
+
|
| 415 |
+
modal.classList.remove('hidden');
|
| 416 |
+
});
|
| 417 |
+
|
| 418 |
+
// Add New Instructor
|
| 419 |
+
document.getElementById('btn-add-inst').addEventListener('click', async () => {
|
| 420 |
+
const email = document.getElementById('new-inst-email').value.trim();
|
| 421 |
+
const name = document.getElementById('new-inst-name').value.trim();
|
| 422 |
+
|
| 423 |
+
if (!email || !name) return alert("請輸入完整資料");
|
| 424 |
+
|
| 425 |
+
const perms = [];
|
| 426 |
+
if (document.getElementById('perm-room').checked) perms.push('create_room');
|
| 427 |
+
if (document.getElementById('perm-q').checked) perms.push('add_question');
|
| 428 |
+
if (document.getElementById('perm-inst').checked) perms.push('manage_instructors');
|
| 429 |
+
|
| 430 |
+
try {
|
| 431 |
+
await addInstructor(email, name, perms);
|
| 432 |
+
alert("新增成功");
|
| 433 |
+
navInstBtn.click(); // Reload list
|
| 434 |
+
document.getElementById('new-inst-email').value = '';
|
| 435 |
+
document.getElementById('new-inst-name').value = '';
|
| 436 |
+
} catch (e) {
|
| 437 |
+
alert("新增失敗: " + e.message);
|
| 438 |
+
}
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
// Global helper for remove (hacky but works for simple onclick)
|
| 442 |
+
window.removeInst = async (email) => {
|
| 443 |
+
if (confirm(`確定移除 ${email}?`)) {
|
| 444 |
+
try {
|
| 445 |
+
await removeInstructor(email);
|
| 446 |
+
navInstBtn.click(); // Reload
|
| 447 |
+
} catch (e) {
|
| 448 |
+
alert(e.message);
|
| 449 |
+
}
|
| 450 |
}
|
| 451 |
};
|
| 452 |
|
| 453 |
+
// Auto Check Auth (Persistence)
|
| 454 |
+
// We rely on Firebase Auth state observer instead of session storage for security?
|
| 455 |
+
// Or we can just check if user is already signed in.
|
| 456 |
+
import("../services/firebase.js").then(({ auth }) => {
|
| 457 |
+
auth.onAuthStateChanged(async (user) => {
|
| 458 |
+
if (user) {
|
| 459 |
+
const instructorData = await checkInstructorPermission(user);
|
| 460 |
+
if (instructorData) {
|
| 461 |
+
authModal.classList.add('hidden');
|
| 462 |
+
checkPermissions(instructorData);
|
| 463 |
+
} else {
|
| 464 |
+
// Logged in google but not instructor
|
| 465 |
+
authModal.classList.remove('hidden');
|
| 466 |
+
}
|
| 467 |
+
} else {
|
| 468 |
+
authModal.classList.remove('hidden');
|
| 469 |
+
}
|
| 470 |
+
});
|
| 471 |
});
|
| 472 |
|
| 473 |
+
// Define Kick Function globally (robust against auth flow)
|
| 474 |
+
window.confirmKick = async (userId, nickname) => {
|
| 475 |
+
if (confirm(`確定要踢出 ${nickname} 嗎?此動作無法復原。`)) {
|
| 476 |
+
try {
|
| 477 |
+
const { removeUser } = await import("../services/classroom.js");
|
| 478 |
+
await removeUser(userId);
|
| 479 |
+
// UI will update automatically via subscribeToRoom
|
| 480 |
+
} catch (e) {
|
| 481 |
+
console.error("Kick failed:", e);
|
| 482 |
+
alert("移除失敗");
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
|
| 488 |
// Snapshot Logic
|
| 489 |
snapshotBtn.addEventListener('click', async () => {
|
|
|
|
| 867 |
});
|
| 868 |
|
| 869 |
// Logout Logic
|
| 870 |
+
document.getElementById('logout-btn').addEventListener('click', async () => {
|
| 871 |
if (confirm('確定要登出講師模式嗎? (將會回到首頁)')) {
|
| 872 |
+
await signOutUser();
|
| 873 |
sessionStorage.removeItem('vibecoding_instructor_in_room');
|
| 874 |
sessionStorage.removeItem('vibecoding_admin_referer');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
window.location.hash = '';
|
| 876 |
window.location.reload();
|
| 877 |
}
|
|
|
|
| 890 |
}
|
| 891 |
});
|
| 892 |
|
| 893 |
+
// Check Previous Session (Handled by onAuthStateChanged now)
|
| 894 |
+
// if (sessionStorage.getItem('vibecoding_instructor_auth') === 'true') {
|
| 895 |
+
// authModal.classList.add('hidden');
|
| 896 |
+
// }
|
| 897 |
|
| 898 |
// Check Active Room State
|
| 899 |
const activeRoom = sessionStorage.getItem('vibecoding_instructor_in_room');
|