/** * SchoolHub Launcher - Backend Logic * Core architecture: Google Sheets as Database */ const SPREADSHEET_ID = '1oWvHLC2tnLHHO36IxTErSCuvDWobsbI_LTlQwtMYiZU'; const APP_ID = 'launcher'; // --- CORE ENTRY POINT --- function doGet() { return HtmlService .createTemplateFromFile('Index') .evaluate() .setTitle('SchoolHub Launcher') .addMetaTag('viewport', 'width=device-width, initial-scale=1') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); } function include(filename) { return HtmlService.createHtmlOutputFromFile(filename).getContent(); } // --- DATABASE SETUP --- function setupDatabase() { const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheets = [ { name: 'Users', headers: ['email', 'name', 'phone', 'status', 'unit', 'isAdmin', 'createdAt', 'updatedAt'] }, { name: 'Access', headers: ['email', 'appId', 'roles', 'active', 'notes', 'updatedAt'] }, { name: 'Apps', headers: ['appId', 'appName', 'appUrl', 'active', 'sortOrder'] }, { name: 'AuditLog', headers: ['timestamp', 'email', 'appId', 'action', 'status', 'message', 'actor'] }, { name: 'OTP', headers: ['phone', 'code', 'expiresAt'] }, { name: 'Sessions', headers: ['sessionId', 'email', 'expiresAt'] }, { name: 'SystemDocs', headers: ['docType', 'content', 'updatedAt'] } ]; sheets.forEach(({ name, headers }) => { let sheet = ss.getSheetByName(name); if (!sheet) sheet = ss.insertSheet(name); // Always update headers to ensure schema is current sheet.getRange(1, 1, 1, headers.length).setValues([headers]); }); return { ok: true, message: 'Database setup complete. Sheets and headers initialized.' }; } // --- JSON SERIALIZATION HELPERS --- function serializeForClient(value) { if (value === null || value === undefined) return null; if (Object.prototype.toString.call(value) === '[object Date]') { return value.toISOString(); } if (Array.isArray(value)) { return value.map(serializeForClient); } if (typeof value === 'object') { const out = {}; Object.keys(value).forEach((k) => { out[k] = serializeForClient(value[k]); }); return out; } return value; } // --- UTILITY HELPERS --- function normalizeEmail(email) { if (!email) return ''; return String(email).trim().toLowerCase(); } function normalizeBoolString(value) { return String(value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'; } function isTrue_(value) { return String(value).toUpperCase() === 'TRUE'; } function toSafeText_(value) { return value === null || value === undefined ? '' : String(value).trim(); } function getCurrentUserEmail() { return normalizeEmail(Session.getActiveUser().getEmail()); } function rowsToObjects_(values) { if (!values || values.length < 2) return []; const headers = values[0]; const out = []; for (let i = 1; i < values.length; i++) { const row = values[i]; const obj = {}; for (let c = 0; c < headers.length; c++) obj[headers[c]] = row[c]; out.push(obj); } return out; } // --- PRIVATE DATABASE READERS --- function readUsers_(ss) { const sheet = ss.getSheetByName('Users'); if (!sheet) return {}; const rows = rowsToObjects_(sheet.getDataRange().getValues()); const map = {}; rows.forEach((obj) => { const email = normalizeEmail(obj.email); if (email) map[email] = obj; }); return map; } function readUsersByEmail_(ss, email) { const users = readUsers_(ss); return users[normalizeEmail(email)] || null; } function readApps_(ss) { const sheet = ss.getSheetByName('Apps'); if (!sheet) return []; return rowsToObjects_(sheet.getDataRange().getValues()) .filter(app => isTrue_(app.active)) .map(app => ({ appId: toSafeText_(app.appId), appName: toSafeText_(app.appName), appUrl: toSafeText_(app.appUrl), active: true, sortOrder: Number(app.sortOrder || 0) })); } function readAccessRow_(ss, email, appId) { const sheet = ss.getSheetByName('Access'); if (!sheet) return null; const rows = rowsToObjects_(sheet.getDataRange().getValues()); return rows.find(r => normalizeEmail(r.email) === normalizeEmail(email) && String(r.appId) === String(appId)) || null; } function readAccessByEmail_(ss, email) { const sheet = ss.getSheetByName('Access'); if (!sheet) return []; return rowsToObjects_(sheet.getDataRange().getValues()) .filter(r => normalizeEmail(r.email) === normalizeEmail(email)); } // --- AUTH & PERMISSIONS LOGIC --- function getAuthCacheKey_(email, appId) { return `auth_${appId}_${email}`; } function clearAuthCache_(email, appId = APP_ID) { CacheService.getScriptCache().remove(getAuthCacheKey_(email, appId)); } function isAllowed(email, appId) { if (!email) return { isAllowed: false, reason: 'Email is missing.' }; const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const ownerEmail = normalizeEmail(ss.getOwner().getEmail()); const users = readUsers_(ss); let user = users[email]; // Auto-admin for owner if (!user && email === ownerEmail) { const userSheet = ss.getSheetByName('Users'); const now = new Date(); userSheet.appendRow([email, 'Owner Admin', '', 'active', 'System', 'TRUE', now, now]); user = { email, status: 'active', isAdmin: 'TRUE' }; clearAuthCache_(email, appId); } if (!user) return { isAllowed: false, reason: 'Email not whitelisted.' }; if (String(user.status || '').toLowerCase() !== 'active') return { isAllowed: false, reason: 'Account is inactive.' }; if (isTrue_(user.isAdmin)) return { isAllowed: true, reason: '' }; if (appId === APP_ID) return { isAllowed: true, reason: '' }; const access = readAccessRow_(ss, email, appId); if (!access || !isTrue_(access.active)) return { isAllowed: false, reason: 'No active access for this app.' }; return { isAllowed: true, reason: '' }; } function getAuthContext(email, appId) { const cache = CacheService.getScriptCache(); const cacheKey = getAuthCacheKey_(email, appId); const cached = cache.get(cacheKey); if (cached) return JSON.parse(cached); const allowed = isAllowed(email, appId); if (!allowed.isAllowed) { const denied = { isAllowed: false, reason: allowed.reason, roles: [] }; cache.put(cacheKey, JSON.stringify(denied), 300); return denied; } const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const userRow = readUsersByEmail_(ss, email); const accessRow = readAccessRow_(ss, email, appId); const roles = accessRow && accessRow.roles ? String(accessRow.roles).split(',').map(r => r.trim()).filter(Boolean) : (userRow && isTrue_(userRow.isAdmin) ? ['admin'] : []); const result = { isAllowed: true, user: { email: userRow ? userRow.email : email, name: userRow ? (userRow.name || '') : '', unit: userRow ? (userRow.unit || '') : '' }, roles, permissions: { canManageUsers: roles.includes('admin') || (userRow && isTrue_(userRow.isAdmin)), canEditApp: roles.includes('editor') || roles.includes('admin'), canViewApp: true } }; cache.put(cacheKey, JSON.stringify(result), 300); return result; } function getAllowedApps(email) { const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const apps = readApps_(ss); const user = readUsersByEmail_(ss, email); if (user && isTrue_(user.isAdmin)) { return apps.sort((a, b) => a.sortOrder - b.sortOrder); } const accessRows = readAccessByEmail_(ss, email); const allowedIds = new Set(accessRows.filter(r => isTrue_(r.active)).map(r => String(r.appId))); return apps .filter(a => allowedIds.has(String(a.appId))) .sort((a, b) => a.sortOrder - b.sortOrder); } // --- PUBLIC API: FRONTEND DATA --- function getInitialData() { try { const email = getCurrentUserEmail(); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const userSheet = ss.getSheetByName('Users'); const isSystemEmpty = !userSheet || userSheet.getLastRow() < 2; if (isSystemEmpty) { return { ok: true, data: serializeForClient({ isSystemEmpty: true, userEmail: email }) }; } if (!email) return { ok: true, data: serializeForClient({ needsAuth: true }) }; const auth = getAuthContext(email, APP_ID); if (!auth.isAllowed) { logAccess({ email, appId: APP_ID, action: 'ACCESS_DENIED', status: 'FAILURE', message: auth.reason }); return { ok: false, message: auth.reason }; } const apps = getAllowedApps(email); logAccess({ email, appId: APP_ID, action: 'ACCESS_GRANTED', status: 'SUCCESS', message: 'User loaded launcher' }); return { ok: true, data: serializeForClient({ user: auth.user, apps, isAdmin: auth.roles.includes('admin') }) }; } catch (e) { return { ok: false, message: e.message }; } } function registerFirstAdmin(name) { try { const email = getCurrentUserEmail(); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Users'); if (sheet && sheet.getLastRow() >= 2) throw new Error('System already initialized.'); if (!email) throw new Error('User email not available.'); const now = new Date(); sheet.appendRow([email, toSafeText_(name), '', 'active', 'System Admin', 'TRUE', now, now]); clearAuthCache_(email); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } // --- PUBLIC API: ADMIN USER MANAGEMENT --- function getAdminUsers() { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const values = ss.getSheetByName('Users').getDataRange().getValues(); return { ok: true, data: serializeForClient(rowsToObjects_(values)) }; } catch (e) { return { ok: false, message: e.message }; } } function saveUser(userData) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const targetEmail = normalizeEmail(userData.email); if (!targetEmail || !userData.name) throw new Error('Email and Name are required'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Users'); const values = sheet.getDataRange().getValues(); const headers = values[0]; const emailIdx = headers.indexOf('email'); let rowIndex = -1; for (let i = 1; i < values.length; i++) { if (normalizeEmail(values[i][emailIdx]) === targetEmail) { rowIndex = i + 1; break; } } const now = new Date(); const rowValue = headers.map(h => { if (h === 'email') return targetEmail; if (h === 'name') return toSafeText_(userData.name); if (h === 'status') return toSafeText_(userData.status).toLowerCase() === 'inactive' ? 'inactive' : 'active'; if (h === 'unit') return toSafeText_(userData.unit); if (h === 'isAdmin') return normalizeBoolString(userData.isAdmin); if (h === 'updatedAt') return now; if (h === 'createdAt' && rowIndex > 0) return values[rowIndex - 1][headers.indexOf('createdAt')]; if (h === 'createdAt') return now; return toSafeText_(userData[h]); }); if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]); else sheet.appendRow(rowValue); clearAuthCache_(targetEmail); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } function deleteUser(targetEmail) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const userSheet = ss.getSheetByName('Users'); const accessSheet = ss.getSheetByName('Access'); const normalizedTarget = normalizeEmail(targetEmail); const deleteByEmail = (sheet) => { const data = sheet.getDataRange().getValues(); const emailIdx = data[0].indexOf('email'); for (let i = data.length - 1; i >= 1; i--) { if (normalizeEmail(data[i][emailIdx]) === normalizedTarget) sheet.deleteRow(i + 1); } }; deleteByEmail(userSheet); deleteByEmail(accessSheet); clearAuthCache_(normalizedTarget); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } // --- PUBLIC API: ADMIN APP MANAGEMENT --- function getAdminApps() { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const values = ss.getSheetByName('Apps').getDataRange().getValues(); return { ok: true, data: serializeForClient(rowsToObjects_(values)) }; } catch (e) { return { ok: false, message: e.message }; } } function saveApp(appData) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const appId = toSafeText_(appData.appId); if (!appId || !appData.appName || !appData.appUrl) throw new Error('App ID, Name, and URL are required'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Apps'); const values = sheet.getDataRange().getValues(); const headers = values[0]; const appIdIdx = headers.indexOf('appId'); let rowIndex = -1; for (let i = 1; i < values.length; i++) { if (String(values[i][appIdIdx]) === appId) { rowIndex = i + 1; break; } } const rowValue = headers.map(h => { if (h === 'appId') return appId; if (h === 'appName') return toSafeText_(appData.appName); if (h === 'appUrl') return toSafeText_(appData.appUrl); if (h === 'active') return normalizeBoolString(appData.active); if (h === 'sortOrder') return Number(appData.sortOrder || 0); return ''; }); if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]); else sheet.appendRow(rowValue); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } function deleteApp(appId) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Apps'); const data = sheet.getDataRange().getValues(); const appIdIdx = data[0].indexOf('appId'); for (let i = data.length - 1; i >= 1; i--) { if (String(data[i][appIdIdx]) === String(appId)) { sheet.deleteRow(i + 1); return { ok: true }; } } throw new Error('App not found'); } catch (e) { return { ok: false, message: e.message }; } } // --- PUBLIC API: ACCESS MANAGEMENT --- function getAccessForUser(email) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); return { ok: true, data: serializeForClient({ userAccess: readAccessByEmail_(ss, email), availableApps: readApps_(ss) }) }; } catch (e) { return { ok: false, message: e.message }; } } function saveAccess(accessData) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const targetEmail = normalizeEmail(accessData.email); const appId = toSafeText_(accessData.appId); if (!targetEmail || !appId) throw new Error('Email and App ID are required'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Access'); const values = sheet.getDataRange().getValues(); const headers = values[0]; const emailIdx = headers.indexOf('email'); const appIdIdx = headers.indexOf('appId'); let rowIndex = -1; for (let i = 1; i < values.length; i++) { if (normalizeEmail(values[i][emailIdx]) === targetEmail && String(values[i][appIdIdx]) === appId) { rowIndex = i + 1; break; } } const rowValue = headers.map(h => { if (h === 'email') return targetEmail; if (h === 'appId') return appId; if (h === 'roles') return toSafeText_(accessData.roles); if (h === 'active') return normalizeBoolString(accessData.active); if (h === 'notes') return toSafeText_(accessData.notes); if (h === 'updatedAt') return new Date(); return ''; }); if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]); else sheet.appendRow(rowValue); clearAuthCache_(targetEmail); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } function deleteAccess(email, appId) { try { const auth = getAuthContext(getCurrentUserEmail(), APP_ID); if (!auth.roles.includes('admin')) throw new Error('Unauthorized'); const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('Access'); const data = sheet.getDataRange().getValues(); const emailIdx = data[0].indexOf('email'); const appIdIdx = data[0].indexOf('appId'); for (let i = data.length - 1; i >= 1; i--) { if (normalizeEmail(data[i][emailIdx]) === normalizeEmail(email) && String(data[i][appIdIdx]) === String(appId)) { sheet.deleteRow(i + 1); } } clearAuthCache_(email); return { ok: true }; } catch (e) { return { ok: false, message: e.message }; } } // --- PUBLIC API: AUTHENTICATION & OTP --- const GO_WHATSAPP_API_KEY = 'YOUR_API_KEY'; const GO_WHATSAPP_URL = 'https://api.gowhatsapp.com/send'; function sendOTP(phone) { try { const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues()); if (!users.find(u => String(u.phone).trim() === String(phone).trim())) { throw new Error('Nomor WhatsApp Anda belum terdaftar.'); } const code = Math.floor(100000 + Math.random() * 900000).toString(); const expiresAt = new Date(Date.now() + 5 * 60 * 1000); const otpSheet = ss.getSheetByName('OTP'); const otpData = otpSheet.getDataRange().getValues(); for (let i = otpData.length - 1; i >= 1; i--) { if (otpData[i][0] === phone) otpSheet.deleteRow(i + 1); } otpSheet.appendRow([phone, code, expiresAt]); UrlFetchApp.fetch(GO_WHATSAPP_URL, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ apiKey: GO_WHATSAPP_API_KEY, phone: phone, message: `Kode OTP SchoolHub Anda adalah: ${code}. Berlaku selama 5 menit.` }), muteHttpExceptions: true }); return { ok: true, message: 'OTP telah dikirim ke WhatsApp Anda.' }; } catch (e) { return { ok: false, message: e.message }; } } function verifyOTP(phone, code) { try { const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const otpData = ss.getSheetByName('OTP').getDataRange().getValues(); const validOtp = otpData.find(r => r[0] === phone && String(r[1]) === String(code) && new Date(r[2]) > new Date()); if (!validOtp) throw new Error('Kode OTP salah atau kadaluwarsa.'); const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues()); const user = users.find(u => String(u.phone).trim() === String(phone).trim()); if (!user) throw new Error('User tidak ditemukan.'); const sessionId = Utilities.getUuid(); ss.getSheetByName('Sessions').appendRow([sessionId, user.email, new Date(Date.now() + 24 * 60 * 60 * 1000)]); return { ok: true, data: { sessionId, email: user.email } }; } catch (e) { return { ok: false, message: e.message }; } } function loginWithGoogle() { try { const email = getCurrentUserEmail(); if (!email) return { ok: false, message: 'Email tidak terdeteksi.' }; const auth = isAllowed(email, APP_ID); if (!auth.isAllowed) return { ok: false, message: auth.reason }; const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sessionSheet = ss.getSheetByName('Sessions'); if (!sessionSheet) { throw new Error('System Error: "Sessions" sheet not found. Please run setupDatabase().'); } const sessionId = Utilities.getUuid(); sessionSheet.appendRow([sessionId, email, new Date(Date.now() + 24 * 60 * 60 * 1000)]); return { ok: true, data: { sessionId, email } }; } catch (e) { return { ok: false, message: e.message }; } } // --- LOGGING --- function logAccess(event) { try { const ss = SpreadsheetApp.openById(SPREADSHEET_ID); const sheet = ss.getSheetByName('AuditLog'); if (!sheet) return; sheet.appendRow([ new Date(), event.email || '', event.appId || '', event.action || '', event.status || '', event.message || '', event.actor || 'System' ]); } catch (e) { console.error('Logging failed: ' + e.message); } }