diff --git a/app/Code.gs b/app/Code.gs new file mode 100644 index 0000000..8418f62 --- /dev/null +++ b/app/Code.gs @@ -0,0 +1,660 @@ +/** + * 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); + } +}