Sync Feature: manual #2
+660
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user