const CONFIG = { SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q", // ID from mail_forwarding SHEET_NAME: "Form Responses 1", COL_FORWARD_TO_ADDRESS: 4, // 1-indexed (Column D from mail_forwarding) ADMIN_EMAIL: "info@haumdaucher.de", PROJECT_ID: "haumdaucher", // Used in the Identity Toolkit REST API DRY_RUN: false, SEND_EMAIL_ON_CREATION: false, }; /** * Run this function from the Apps Script IDE after pushing code * to establish the required background triggers. */ function setup() { const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); // Clear old triggers const existingTriggers = ScriptApp.getProjectTriggers(); for (const trigger of existingTriggers) { if (trigger.getHandlerFunction() === 'syncUsers') { ScriptApp.deleteTrigger(trigger); } } // Install triggers ScriptApp.newTrigger('syncUsers') .forSpreadsheet(ss) .onFormSubmit() .create(); ScriptApp.newTrigger('syncUsers') .forSpreadsheet(ss) .onChange() .create(); const triggers = ScriptApp.getProjectTriggers(); console.log(`Setup complete. ${triggers.length} trigger(s) are now active for 'syncUsers'.`); } /** * Main Entry Point */ function syncUsers() { console.log(`Starting syncUsers... (DRY_RUN: ${CONFIG.DRY_RUN})`); try { const desiredEmails = getDesiredState(); console.log(`Desired state: ${desiredEmails.length} valid email(s) found in sheet.`); const currentState = getCurrentState(); console.log(`Current state: ${Object.keys(currentState).length} user(s) found in Firebase.`); const actions = reconcileUsers(desiredEmails, currentState); if (actions.length > 0) { console.log(`Found ${actions.length} action(s) to execute.`); sendAdminReport(actions); } else { console.log("States are synchronized. No actions required."); } } catch (err) { console.error("Error during syncUsers:", err); MailApp.sendEmail({ to: CONFIG.ADMIN_EMAIL, subject: "Haumdaucher Firebase Sync Failed", body: `The Firebase User sync script failed with the following error:\n\n${err.toString()}` }); } } /** * Extracts valid emails from the Google Sheet. */ function getDesiredState() { const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); const sheet = ss.getSheetByName(CONFIG.SHEET_NAME); const data = sheet.getDataRange().getValues(); const emails = new Set(); // Start from row 1 (skipping header row 0) for (let i = 1; i < data.length; i++) { const row = data[i]; const email = row[CONFIG.COL_FORWARD_TO_ADDRESS - 1]; if (email && typeof email === 'string' && email.trim() !== "") { const cleanEmail = email.trim().toLowerCase(); emails.add(cleanEmail); } } return Array.from(emails); } /** * Fetches all users from Firebase Identity Platform. * Returns an object map: { "email@example.com": { localId: "...", disabled: false } } */ function getCurrentState() { const token = ScriptApp.getOAuthToken(); const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:query`; // We use query with an empty payload to list users. // Note: For very large datasets, pagination (nextPageToken) would be required. const payload = { returnUserInfo: true, maxResults: 1000 // Safely assuming < 1000 users as per requirements }; const options = { method: 'post', contentType: 'application/json', headers: { "Authorization": "Bearer " + token }, payload: JSON.stringify(payload), muteHttpExceptions: true }; console.log("Calling Identity Toolkit accounts:query..."); const response = UrlFetchApp.fetch(url, options); const json = JSON.parse(response.getContentText()); if (response.getResponseCode() !== 200) { throw new Error(`Firebase API Error: ${response.getContentText()}`); } const userMap = {}; if (json.userInfo) { json.userInfo.forEach(user => { if (user.email) { userMap[user.email.toLowerCase()] = { localId: user.localId, disabled: user.disabled || false }; } }); } return userMap; } /** * Evaluates differences and executes mutations against Firebase. */ function reconcileUsers(desiredEmails, currentState) { const token = ScriptApp.getOAuthToken(); const executedActions = []; // Build a Set for O(1) lookup performance (avoids O(n²) with .includes()) const desiredSet = new Set(desiredEmails); // 1. Check for Creations and Re-enables for (const email of desiredEmails) { const fbUser = currentState[email]; if (!fbUser) { // Missing in Firebase -> CREATE console.log(`[ACTION: CREATE] User missing in Firebase: ${email}`); if (!CONFIG.DRY_RUN) { const password = generateSecurePassword(); const success = createUserInFirebase(email, password, token); if (success) { executedActions.push(`Created user: ${email}`); if (CONFIG.SEND_EMAIL_ON_CREATION) { sendPasswordReset(email, token); executedActions.push(`Sent password reset email to: ${email}`); } } else { executedActions.push(`[ERROR] Failed to create user: ${email}`); } } else { executedActions.push(`[DRY RUN] Would create user: ${email}`); } } else if (fbUser.disabled) { // Exists but disabled -> RE-ENABLE console.log(`[ACTION: RE-ENABLE] User is disabled in Firebase: ${email}`); if (!CONFIG.DRY_RUN) { const success = updateUserStatus(fbUser.localId, false, token); if (success) { executedActions.push(`Re-enabled user: ${email}`); } else { executedActions.push(`[ERROR] Failed to re-enable user: ${email}`); } } else { executedActions.push(`[DRY RUN] Would re-enable user: ${email}`); } } } // 2. Check for Disablements for (const [email, fbUser] of Object.entries(currentState)) { if (!desiredSet.has(email) && !fbUser.disabled) { // Exists in Firebase, but NOT in sheet -> DISABLE (Soft Delete) console.log(`[ACTION: DISABLE] User not in sheet: ${email}`); if (!CONFIG.DRY_RUN) { const success = updateUserStatus(fbUser.localId, true, token); if (success) { executedActions.push(`Disabled user: ${email}`); } else { executedActions.push(`[ERROR] Failed to disable user: ${email}`); } } else { executedActions.push(`[DRY RUN] Would disable user: ${email}`); } } } return executedActions; } /** * Calls Identity Toolkit to create a user. */ function createUserInFirebase(email, password, token) { const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts`; const payload = { email: email, password: password, emailVerified: true // Pre-verified since it comes from our trusted sheet }; const options = { method: 'post', contentType: 'application/json', headers: { "Authorization": "Bearer " + token }, payload: JSON.stringify(payload), muteHttpExceptions: true }; const res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() !== 200) { console.error(`Failed to create ${email}: ${res.getContentText()}`); return false; } return true; } /** * Calls Identity Toolkit to update user status (enable/disable). */ function updateUserStatus(localId, disabled, token) { const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:update`; const payload = { localId: localId, disableUser: disabled }; const options = { method: 'post', contentType: 'application/json', headers: { "Authorization": "Bearer " + token }, payload: JSON.stringify(payload), muteHttpExceptions: true }; const res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() !== 200) { console.error(`Failed to update ${localId}: ${res.getContentText()}`); return false; } return true; } /** * Calls Identity Toolkit to send a password reset email. */ function sendPasswordReset(email, token) { const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:sendOobCode`; const payload = { requestType: "PASSWORD_RESET", email: email }; const options = { method: 'post', contentType: 'application/json', headers: { "Authorization": "Bearer " + token }, payload: JSON.stringify(payload), muteHttpExceptions: true }; const res = UrlFetchApp.fetch(url, options); if (res.getResponseCode() !== 200) { console.error(`Failed to send reset email to ${email}: ${res.getContentText()}`); } } /** * Generates a 24-character cryptographically secure random password. * Uses Utilities.getSecureRandomBytes() instead of Math.random() for security. */ function generateSecurePassword() { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"; const bytes = Utilities.getSecureRandomBytes(24); let pwd = ""; for (let i = 0; i < 24; i++) { // Mask to avoid modulo bias: chars.length (88) fits safely in a byte (0-255) pwd += chars.charAt(bytes[i] % chars.length); } return pwd; } /** * Sends the final admin report. */ function sendAdminReport(actions) { const subject = `Firebase User Sync Report ${CONFIG.DRY_RUN ? '[DRY RUN]' : ''}`; const body = `The synchronization process has completed.\n\nActions taken:\n` + actions.map(a => `- ${a}`).join("\n"); MailApp.sendEmail({ to: CONFIG.ADMIN_EMAIL, subject: subject, body: body }); }