Compare commits
10
Commits
1d85634740
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84edb1d074 | ||
|
|
a16ebd641e | ||
|
|
08c7ef4660 | ||
|
|
b260255980 | ||
|
|
6d43326562 | ||
|
|
82acc4e7d5 | ||
|
|
d4b4e19223 | ||
|
|
0a65f0f092 | ||
|
|
d7c89fda4e | ||
|
|
37337ffb13 |
@@ -35,3 +35,6 @@ dist-ssr
|
|||||||
|
|
||||||
# Deprecated (Inlined in Terraform)
|
# Deprecated (Inlined in Terraform)
|
||||||
firestore.rules
|
firestore.rules
|
||||||
|
|
||||||
|
# Clasp / Google Apps Script
|
||||||
|
.clasprc.json
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GEMINI.md - Haumdaucher Project Handbook
|
# AGENTS.md - Haumdaucher Project Handbook
|
||||||
|
|
||||||
## 🚨 Rules
|
## 🚨 Rules
|
||||||
**1. Infrastructure as Code is rule #1.** Manual creation of resources (e.g., via `gcloud` or Console) is forbidden. The use of Terraform/Tofu is mandatory.
|
**1. Infrastructure as Code is rule #1.** Manual creation of resources (e.g., via `gcloud` or Console) is forbidden. The use of Terraform/Tofu is mandatory.
|
||||||
@@ -49,6 +49,10 @@ Deployment is automated via the `./deploy.sh` script.
|
|||||||
4. **Distribution**: Pushes the image to `registry.haumdaucher.de/haumdaucher-website:latest`.
|
4. **Distribution**: Pushes the image to `registry.haumdaucher.de/haumdaucher-website:latest`.
|
||||||
5. **K8s Update**: Applies `k8s-manifests.yaml` and triggers a `kubectl rollout restart` to fetch the new image.
|
5. **K8s Update**: Applies `k8s-manifests.yaml` and triggers a `kubectl rollout restart` to fetch the new image.
|
||||||
|
|
||||||
|
## 🤖 Sub-module Documentation
|
||||||
|
Certain independent sub-modules contain their own highly specific operational guidelines. Always consult them when working in those directories:
|
||||||
|
- **`user_creation/AGENTS.md`**: Contains critical safeguards and architectural details for the declarative Google Apps Script that provisions Firebase users from Google Sheets.
|
||||||
|
|
||||||
## 🕹 The Haumdaucher Game
|
## 🕹 The Haumdaucher Game
|
||||||
- **Engine**: HTML5 Canvas rendering. Game style changes dynamically based on the site's active theme.
|
- **Engine**: HTML5 Canvas rendering. Game style changes dynamically based on the site's active theme.
|
||||||
- **Unlocking NAT Mode**:
|
- **Unlocking NAT Mode**:
|
||||||
@@ -9,6 +9,14 @@ TAG="latest"
|
|||||||
|
|
||||||
echo "🚀 Starting deployment for Haumdaucher..."
|
echo "🚀 Starting deployment for Haumdaucher..."
|
||||||
|
|
||||||
|
# Check cluster connectivity
|
||||||
|
echo "🔌 Verifying Kubernetes cluster connectivity..."
|
||||||
|
if ! kubectl cluster-info > /dev/null 2>&1; then
|
||||||
|
echo "❌ Error: Kubernetes cluster is unreachable (kubectl failed). Please check your KUBECONFIG or VPN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Cluster is reachable."
|
||||||
|
|
||||||
# Create namespace if it doesn't exist
|
# Create namespace if it doesn't exist
|
||||||
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"scriptId": "1pTpLk3kblp9xByrZ0rM-5gBU-5pZkbdKR25bzI89apFdU1RHPzBvzIl5",
|
||||||
|
"rootDir": "src",
|
||||||
|
"htmlExtensions": [
|
||||||
|
".html"
|
||||||
|
],
|
||||||
|
"jsonExtensions": [
|
||||||
|
".json"
|
||||||
|
],
|
||||||
|
"filePushOrder": [],
|
||||||
|
"skipSubdirectories": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This document provides context for AI agents operating on the `mail_forwarding` infrastructure in this repository.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
This directory contains Google Apps Script code configured as Infrastructure as Code (IaC).
|
||||||
|
It automates the creation of email forwarding in Google Workspace by reading from a Google Sheet and dynamically creating/managing Workspace Groups.
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
- We use `@google/clasp` to manage the deployment of the `.js` files to Google Apps Script. Note: do NOT write TypeScript as we bypass the local transpilation step.
|
||||||
|
- The entrypoint is `src/Code.js`.
|
||||||
|
- The manifest is `src/appsscript.json`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- **Current Mode:** Active Production (`DRY_RUN: false`).
|
||||||
|
- **Target:** All groups managed by this script are tagged with `[Auto-Forwarder] Managed by Google Sheets`.
|
||||||
|
|
||||||
|
## Rules & Safeguards (CRITICAL)
|
||||||
|
1. **Never alter the `GROUP_DESCRIPTION_TAG` logic.**
|
||||||
|
The script achieves declarative state management by finding and deleting Workspace Groups that are NOT present in the Google Sheet. To prevent the catastrophic deletion of real, human-managed groups (e.g., `board@haumdaucher.de`), the script relies on the `[Auto-Forwarder] Managed by Google Sheets` string in the group's description. The script must ALWAYS filter for this exact string before issuing any deletion API calls.
|
||||||
|
2. **Always deploy via `clasp`.** Do not instruct the user to copy-paste code manually if `clasp` is available.
|
||||||
|
3. **Trigger:** We use a combination of `onFormSubmit` (for Google Forms) and `onChange` (for manual sheet edits) triggers. This ensures reconciliation happens regardless of how the data was updated.
|
||||||
|
4. **Validation Logic**: `forwardAddress` must always be internal (`@haumdaucher.de`), while `forwardToAddress` must always be external (non-haumdaucher) to prevent routing loops.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Mail Forwarding Automation
|
||||||
|
|
||||||
|
This directory contains an Infrastructure-as-Code (IaC) deployment for Google Apps Script.
|
||||||
|
It automates the creation and synchronization of Google Workspace mail forwarding by reading from a Google Sheet (typically populated by Google Forms) and managing Workspace Groups.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
To deploy this code to your Google Workspace, you need the following installed on your machine:
|
||||||
|
- [Node.js & npm](https://nodejs.org/)
|
||||||
|
- Google Clasp CLI: Run `npm install -g @google/clasp`
|
||||||
|
|
||||||
|
## Step 1: Authentication & Setup
|
||||||
|
Before you can deploy, you must authenticate your local machine with your Google Workspace Admin account and enable the Apps Script API.
|
||||||
|
|
||||||
|
1. **Enable the API:** Go to [https://script.google.com/home/usersettings](https://script.google.com/home/usersettings) and turn **ON** the "Google Apps Script API".
|
||||||
|
2. **Login:** In your terminal, run:
|
||||||
|
```bash
|
||||||
|
clasp login
|
||||||
|
```
|
||||||
|
This will open a browser window. Sign in with your Workspace Admin account (`@haumdaucher.de`) and grant the necessary permissions.
|
||||||
|
|
||||||
|
## Step 2: Configuration
|
||||||
|
You must configure the script to point to your specific Google Sheet.
|
||||||
|
Open `src/Code.js` and modify the `CONFIG` block at the top of the file:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const CONFIG = {
|
||||||
|
// 1. The ID of the Google Sheet (found in the URL: https://docs.google.com/spreadsheets/d/<THIS_ID>/edit)
|
||||||
|
SPREADSHEET_ID: "YOUR_SHEET_ID_HERE",
|
||||||
|
|
||||||
|
// 2. The name of the tab at the bottom of the screen
|
||||||
|
SHEET_NAME: "Form Responses 1",
|
||||||
|
|
||||||
|
// 3. The column numbers containing the data (1 = A, 2 = B, 3 = C, etc.)
|
||||||
|
COL_FORWARD_ADDRESS: 6,
|
||||||
|
COL_FORWARD_TO_ADDRESS: 4,
|
||||||
|
|
||||||
|
// 4. Your admin email for receiving reports
|
||||||
|
ADMIN_EMAIL: "admin@haumdaucher.de",
|
||||||
|
|
||||||
|
// 5. Dry run mode. If true, script logs intended changes without modifying Workspace.
|
||||||
|
DRY_RUN: true,
|
||||||
|
|
||||||
|
// ... leave the GROUP_DESCRIPTION_TAG untouched!
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Deployment
|
||||||
|
Once configured, you need to create an Apps Script project in your Google Account and push this code to it.
|
||||||
|
|
||||||
|
1. Navigate to this `mail_forwarding` directory in your terminal.
|
||||||
|
2. Initialize the project as a standalone script:
|
||||||
|
```bash
|
||||||
|
clasp create --type standalone --title "Haumdaucher Mail Forwarding" --rootDir ./src
|
||||||
|
```
|
||||||
|
*(This creates a hidden `.clasp.json` file linking this directory to the cloud project).*
|
||||||
|
3. Push the code:
|
||||||
|
```bash
|
||||||
|
clasp push
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ Important:** `clasp push` only uploads the code. It does **not** install or update the background triggers. You must always re-run the `setup` function in the Apps Script IDE after a push to ensure the triggers are active and up to date.
|
||||||
|
|
||||||
|
## Step 4: Initialization
|
||||||
|
The code is now in the cloud, but the background triggers need to be activated and the Admin SDK authorized.
|
||||||
|
|
||||||
|
1. Open the project in your browser:
|
||||||
|
```bash
|
||||||
|
clasp open-script
|
||||||
|
```
|
||||||
|
2. **Ignore the large blue "Deploy" button.** You do *not* need to create a deployment. This script runs via background triggers, not as a web app.
|
||||||
|
3. In the toolbar directly above the code editor, look for a dropdown menu showing function names (it might currently say `syncForwardings`). Click the dropdown and select `setup`.
|
||||||
|
4. Click the **Run** button (the play icon) right next to the dropdown.
|
||||||
|
5. **Authorization Required:** Google will prompt you to review permissions. *(Note: This interactive browser consent screen is a strict Google Workspace security requirement for scripts accessing the Admin API, which is why this specific step cannot be automated via CLI).*
|
||||||
|
- Click "Review Permissions"
|
||||||
|
- Choose your Admin account.
|
||||||
|
- Click "Advanced" -> "Go to Haumdaucher Mail Forwarding (unsafe)"
|
||||||
|
- Click "Allow" to grant access to the Admin Directory API, Gmail API, and Google Sheets.
|
||||||
|
5. Once authorized, the `setup` function will finish executing. It installs the background `onChange` trigger.
|
||||||
|
|
||||||
|
## System Status: LIVE
|
||||||
|
The system is currently configured for active production (`DRY_RUN: false`). Any changes to the Google Sheet will result in real-time creation, modification, or deletion of Google Workspace Groups.
|
||||||
|
|
||||||
|
To revert to dry-run mode for testing:
|
||||||
|
1. Open `src/Code.js` and change `DRY_RUN: false` to `DRY_RUN: true`.
|
||||||
|
2. Run `clasp push`.
|
||||||
|
|
||||||
|
**You are done!** Whenever a new response is submitted to the configured Google Sheet via Forms, the script will automatically run in the background, reconcile the forwarding groups, and send you an email report.
|
||||||
|
|
||||||
|
## Troubleshooting & Analysis
|
||||||
|
If you find that the script is not triggering when a new form is submitted:
|
||||||
|
|
||||||
|
1. **Check Executions:** Run `clasp open-script` and click on the **Executions** tab (the list/bullet icon) in the left sidebar.
|
||||||
|
- **No entries?** The trigger never fired. You may need to run the `setup()` function again to ensure the triggers are installed.
|
||||||
|
- **Failed entries?** Click on the failed execution to see the exact error message and stack trace.
|
||||||
|
2. **Re-Run Setup:** If you have pushed new code or changed your Google Account permissions, always re-run the `setup` function in the Apps Script UI to refresh the background triggers and authorization.
|
||||||
|
3. **Trigger Source:** Ensure the Google Sheet you are using is the same one linked to your Google Form. The `onFormSubmit` trigger only fires on the specific sheet receiving the form responses.
|
||||||
Generated
+3509
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "haumdaucher-mail-forwarding",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Google Apps Script for automating mail forwarding via Workspace Groups",
|
||||||
|
"scripts": {
|
||||||
|
"login": "clasp login",
|
||||||
|
"push": "clasp push"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@google/clasp": "^2.4.2",
|
||||||
|
"@types/google-apps-script": "^1.0.83"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* CONFIGURATION BLOCK
|
||||||
|
* -------------------
|
||||||
|
* Update these values before running `npm run push` (clasp push) to deploy
|
||||||
|
* the script to your Google Workspace environment.
|
||||||
|
*/
|
||||||
|
const CONFIG = {
|
||||||
|
// If the script is bound to a sheet (using `clasp clone <id>`), you can leave this empty.
|
||||||
|
// If deployed as a standalone script, YOU MUST provide the exact Spreadsheet ID.
|
||||||
|
SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q",
|
||||||
|
|
||||||
|
// The exact name of the tab containing the data (e.g., from Google Forms)
|
||||||
|
SHEET_NAME: "Form Responses 1",
|
||||||
|
|
||||||
|
// Column indices (1-indexed).
|
||||||
|
// Example: If Forward Address is Column D, index is 4. If Forward To is Column F, index is 6.
|
||||||
|
COL_FORWARD_ADDRESS: 6,
|
||||||
|
COL_FORWARD_TO_ADDRESS: 4,
|
||||||
|
|
||||||
|
// The admin email that should receive the execution reports
|
||||||
|
ADMIN_EMAIL: "moritz@haumdaucher.de",
|
||||||
|
|
||||||
|
// The primary Workspace domain for validation and auto-appending
|
||||||
|
WORKSPACE_DOMAIN: "haumdaucher.de",
|
||||||
|
|
||||||
|
// Dry run mode. If true, the script will only log what it would do and send the email,
|
||||||
|
// but will NOT actually create, update, or delete any groups in Workspace.
|
||||||
|
DRY_RUN: false,
|
||||||
|
|
||||||
|
// DO NOT CHANGE THIS VALUE.
|
||||||
|
// This tag is added to the description of groups created by this script.
|
||||||
|
// It acts as a safety guard to ensure the script NEVER deletes manually created groups.
|
||||||
|
GROUP_DESCRIPTION_TAG: "[Auto-Forwarder] Managed by Google Sheets"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INSTALLATION:
|
||||||
|
* Run this function ONCE manually from the Apps Script IDE after pushing.
|
||||||
|
* It authorizes the script and installs the background trigger.
|
||||||
|
*/
|
||||||
|
function setup() {
|
||||||
|
const ss = CONFIG.SPREADSHEET_ID
|
||||||
|
? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
|
||||||
|
: SpreadsheetApp.getActiveSpreadsheet();
|
||||||
|
|
||||||
|
if (!ss) {
|
||||||
|
throw new Error("Could not find spreadsheet. Ensure you are bound to a sheet or have set CONFIG.SPREADSHEET_ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any existing triggers to prevent duplicates
|
||||||
|
const triggers = ScriptApp.getProjectTriggers();
|
||||||
|
for (let i = 0; i < triggers.length; i++) {
|
||||||
|
if (triggers[i].getHandlerFunction() === 'syncForwardings') {
|
||||||
|
ScriptApp.deleteTrigger(triggers[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the onFormSubmit trigger (specifically for Google Forms submissions)
|
||||||
|
ScriptApp.newTrigger('syncForwardings')
|
||||||
|
.forSpreadsheet(ss)
|
||||||
|
.onFormSubmit()
|
||||||
|
.create();
|
||||||
|
|
||||||
|
// Also install an onChange trigger as a fallback (for manual sheet edits)
|
||||||
|
ScriptApp.newTrigger('syncForwardings')
|
||||||
|
.forSpreadsheet(ss)
|
||||||
|
.onChange()
|
||||||
|
.create();
|
||||||
|
|
||||||
|
// Verify and log the installed triggers so we can confirm the setup is correct
|
||||||
|
const installedTriggers = ScriptApp.getProjectTriggers();
|
||||||
|
console.log(`Setup complete. ${installedTriggers.length} trigger(s) are now active:`);
|
||||||
|
for (const trigger of installedTriggers) {
|
||||||
|
console.log(` - Function: ${trigger.getHandlerFunction()}, Event: ${trigger.getEventType()}, Source: ${trigger.getTriggerSource()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAIN ENTRY POINT
|
||||||
|
* This is triggered automatically whenever the spreadsheet changes (e.g., new form submission).
|
||||||
|
*/
|
||||||
|
function syncForwardings() {
|
||||||
|
try {
|
||||||
|
console.log("Starting syncForwardings...");
|
||||||
|
if (CONFIG.DRY_RUN) {
|
||||||
|
console.log("dry_run enabled, no changes will be applied.");
|
||||||
|
} else {
|
||||||
|
console.log("dry_run is false, actively performing changes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredState = readDesiredStateFromSheet();
|
||||||
|
const currentState = readCurrentStateFromWorkspace();
|
||||||
|
|
||||||
|
const changelog = [];
|
||||||
|
|
||||||
|
// 1. Process Creations and Updates
|
||||||
|
for (const forwardAddress in desiredState) {
|
||||||
|
const forwardToAddresses = desiredState[forwardAddress];
|
||||||
|
|
||||||
|
if (!currentState[forwardAddress]) {
|
||||||
|
createForwardingGroup(forwardAddress, forwardToAddresses, changelog);
|
||||||
|
} else {
|
||||||
|
updateForwardingGroup(forwardAddress, currentState[forwardAddress], forwardToAddresses, changelog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process Deletions
|
||||||
|
for (const groupEmail in currentState) {
|
||||||
|
if (!desiredState[groupEmail]) {
|
||||||
|
deleteForwardingGroup(groupEmail, changelog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Send Admin Report
|
||||||
|
sendReport(desiredState, changelog);
|
||||||
|
|
||||||
|
console.log("Sync complete.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fatal error during syncForwardings:", err);
|
||||||
|
MailApp.sendEmail({
|
||||||
|
to: CONFIG.ADMIN_EMAIL,
|
||||||
|
subject: "[ERROR] Mail Forwarding Sync Failed",
|
||||||
|
body: "The mail forwarding automation encountered a fatal error:\n\n" + err.stack + "\n\nMessage: " + err.message
|
||||||
|
});
|
||||||
|
// Re-throw the error so Apps Script dashboard shows "Failed"
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the Google Sheet and builds a map of Source -> [Destinations]
|
||||||
|
*/
|
||||||
|
function readDesiredStateFromSheet() {
|
||||||
|
const ss = CONFIG.SPREADSHEET_ID
|
||||||
|
? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
|
||||||
|
: SpreadsheetApp.getActiveSpreadsheet();
|
||||||
|
|
||||||
|
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
|
||||||
|
if (!sheet) {
|
||||||
|
throw new Error(`Sheet '${CONFIG.SHEET_NAME}' not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = sheet.getDataRange().getValues();
|
||||||
|
const desiredState = {};
|
||||||
|
|
||||||
|
// Regex for basic email validation
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// Start at i=1 to skip the header row
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
const row = data[i];
|
||||||
|
let forwardAddress = (row[CONFIG.COL_FORWARD_ADDRESS - 1] || "").toString().trim().toLowerCase();
|
||||||
|
let forwardToAddress = (row[CONFIG.COL_FORWARD_TO_ADDRESS - 1] || "").toString().trim().toLowerCase();
|
||||||
|
|
||||||
|
// Ignore completely empty rows
|
||||||
|
if (!forwardAddress && !forwardToAddress) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Auto-append domain to forwardAddress if they just typed a name (e.g. "frederic")
|
||||||
|
if (forwardAddress && !forwardAddress.includes("@")) {
|
||||||
|
forwardAddress += `@${CONFIG.WORKSPACE_DOMAIN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate forwardAddress domain (MUST be internal)
|
||||||
|
if (forwardAddress && !forwardAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
|
||||||
|
console.warn(`Row ${i + 1}: Skipped. Forward address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${forwardAddress}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate forwardAddress is a valid email
|
||||||
|
if (forwardAddress && !emailRegex.test(forwardAddress)) {
|
||||||
|
console.warn(`Row ${i + 1}: Skipped. Invalid forward address format: '${forwardAddress}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Validate forwardToAddress domain (MUST NOT be internal)
|
||||||
|
if (forwardToAddress && forwardToAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
|
||||||
|
console.warn(`Row ${i + 1}: Skipped. Destination address cannot be an internal @${CONFIG.WORKSPACE_DOMAIN} address. Found: '${forwardToAddress}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate forwardToAddress is a valid email
|
||||||
|
if (forwardToAddress && !emailRegex.test(forwardToAddress)) {
|
||||||
|
console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${forwardToAddress}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forwardAddress && forwardToAddress) {
|
||||||
|
console.log(`Parsed Row ${i + 1}: Valid mapping [${forwardAddress} -> ${forwardToAddress}]`);
|
||||||
|
if (!desiredState[forwardAddress]) {
|
||||||
|
desiredState[forwardAddress] = [];
|
||||||
|
}
|
||||||
|
// Ensure we don't add duplicate destinations for the same source
|
||||||
|
if (desiredState[forwardAddress].indexOf(forwardToAddress) === -1) {
|
||||||
|
desiredState[forwardAddress].push(forwardToAddress);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Row ${i + 1}: Skipped. Missing either forward address or destination (Forward: '${forwardAddress}', To: '${forwardToAddress}')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return desiredState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the Admin Directory API for existing Groups managed by this script
|
||||||
|
*/
|
||||||
|
function readCurrentStateFromWorkspace() {
|
||||||
|
const currentState = {};
|
||||||
|
|
||||||
|
// customer: 'my_customer' is a special alias for the primary domain
|
||||||
|
let pageToken;
|
||||||
|
do {
|
||||||
|
const response = AdminDirectory.Groups.list({
|
||||||
|
customer: 'my_customer',
|
||||||
|
pageToken: pageToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = response.groups || [];
|
||||||
|
for (const group of groups) {
|
||||||
|
// ONLY process groups that have our specific safety tag
|
||||||
|
if (group.description && group.description.indexOf(CONFIG.GROUP_DESCRIPTION_TAG) !== -1) {
|
||||||
|
const email = group.email.toLowerCase();
|
||||||
|
currentState[email] = getGroupMembers(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = response.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all members for a specific group
|
||||||
|
*/
|
||||||
|
function getGroupMembers(groupEmail) {
|
||||||
|
const members = [];
|
||||||
|
let pageToken;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = AdminDirectory.Members.list(groupEmail, {
|
||||||
|
pageToken: pageToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberList = response.members || [];
|
||||||
|
for (const member of memberList) {
|
||||||
|
if (member.email) {
|
||||||
|
members.push(member.email.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageToken = response.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAction(changelog, message) {
|
||||||
|
console.log(message);
|
||||||
|
changelog.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Workspace Group to act as a forwarder
|
||||||
|
*/
|
||||||
|
function createForwardingGroup(forwardAddress, forwardToAddresses, changelog) {
|
||||||
|
if (CONFIG.DRY_RUN) {
|
||||||
|
logAction(changelog, `[DRY RUN - CREATED] Group: ${forwardAddress}`);
|
||||||
|
for (const forwardTo of forwardToAddresses) {
|
||||||
|
logAction(changelog, ` + [DRY RUN] Added member: ${forwardTo}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newGroup = {
|
||||||
|
email: forwardAddress,
|
||||||
|
name: `Auto-Forwarder: ${forwardAddress}`,
|
||||||
|
description: CONFIG.GROUP_DESCRIPTION_TAG
|
||||||
|
};
|
||||||
|
|
||||||
|
AdminDirectory.Groups.insert(newGroup);
|
||||||
|
logAction(changelog, `[CREATED] Group: ${forwardAddress}`);
|
||||||
|
|
||||||
|
// Propagation delay: Google Workspace APIs are eventually consistent.
|
||||||
|
// We must wait a few seconds after creation before we can add members to the new group.
|
||||||
|
Utilities.sleep(3000);
|
||||||
|
|
||||||
|
for (const forwardTo of forwardToAddresses) {
|
||||||
|
AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
|
||||||
|
logAction(changelog, ` + Added member: ${forwardTo}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logAction(changelog, `[ERROR] Failed to create group ${forwardAddress}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciles members of an existing forwarding group
|
||||||
|
*/
|
||||||
|
function updateForwardingGroup(forwardAddress, currentMembers, desiredMembers, changelog) {
|
||||||
|
// 1. Add missing members
|
||||||
|
for (const forwardTo of desiredMembers) {
|
||||||
|
if (currentMembers.indexOf(forwardTo) === -1) {
|
||||||
|
if (CONFIG.DRY_RUN) {
|
||||||
|
logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
|
||||||
|
logAction(changelog, `[UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`);
|
||||||
|
} catch (e) {
|
||||||
|
logAction(changelog, `[ERROR] Failed to add member ${forwardTo} to ${forwardAddress}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove obsolete members
|
||||||
|
for (const current of currentMembers) {
|
||||||
|
if (desiredMembers.indexOf(current) === -1) {
|
||||||
|
if (CONFIG.DRY_RUN) {
|
||||||
|
logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Removed member ${current}`);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
AdminDirectory.Members.remove(forwardAddress, current);
|
||||||
|
logAction(changelog, `[UPDATED] Group ${forwardAddress}: Removed member ${current}`);
|
||||||
|
} catch (e) {
|
||||||
|
logAction(changelog, `[ERROR] Failed to remove member ${current} from ${forwardAddress}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a forwarding group (Requires the safety tag check performed during read)
|
||||||
|
*/
|
||||||
|
function deleteForwardingGroup(groupEmail, changelog) {
|
||||||
|
if (CONFIG.DRY_RUN) {
|
||||||
|
logAction(changelog, `[DRY RUN - DELETED] Group: ${groupEmail}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
AdminDirectory.Groups.remove(groupEmail);
|
||||||
|
logAction(changelog, `[DELETED] Group: ${groupEmail}`);
|
||||||
|
} catch (e) {
|
||||||
|
logAction(changelog, `[ERROR] Failed to delete group ${groupEmail}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a summary report to the configured Admin Email
|
||||||
|
*/
|
||||||
|
function sendReport(desiredState, changelog) {
|
||||||
|
if (changelog.length === 0) {
|
||||||
|
console.log("No changes detected. Skipping email report.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = "[Haumdaucher] Mail Forwarding Sync Report";
|
||||||
|
|
||||||
|
let body = "The mail forwarding automation has executed successfully.\n\n";
|
||||||
|
|
||||||
|
body += "=== CHANGELOG ===\n";
|
||||||
|
body += changelog.join("\n") + "\n\n";
|
||||||
|
|
||||||
|
body += "=== ACTIVE FORWARDINGS ===\n";
|
||||||
|
const sources = Object.keys(desiredState).sort();
|
||||||
|
if (sources.length === 0) {
|
||||||
|
body += "No active forwardings configured in the sheet.\n";
|
||||||
|
} else {
|
||||||
|
for (const source of sources) {
|
||||||
|
body += `${source} -> ${desiredState[source].join(", ")}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MailApp.sendEmail({
|
||||||
|
to: CONFIG.ADMIN_EMAIL,
|
||||||
|
subject: subject,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"timeZone": "Europe/Berlin",
|
||||||
|
"dependencies": {
|
||||||
|
"enabledAdvancedServices": [
|
||||||
|
{
|
||||||
|
"userSymbol": "AdminDirectory",
|
||||||
|
"serviceId": "admin",
|
||||||
|
"version": "directory_v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"exceptionLogging": "STACKDRIVER",
|
||||||
|
"oauthScopes": [
|
||||||
|
"https://www.googleapis.com/auth/admin.directory.group",
|
||||||
|
"https://www.googleapis.com/auth/script.send_mail",
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
|
"https://www.googleapis.com/auth/script.scriptapp"
|
||||||
|
],
|
||||||
|
"runtimeVersion": "V8"
|
||||||
|
}
|
||||||
+18
@@ -8,6 +8,7 @@ import Datenschutz from './components/sections/Legal/Datenschutz.vue'
|
|||||||
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
|
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
|
||||||
import { messages } from './locales/i18n'
|
import { messages } from './locales/i18n'
|
||||||
import { useAuth } from './composables/useAuth'
|
import { useAuth } from './composables/useAuth'
|
||||||
|
import PasswordReset from './components/auth/PasswordReset.vue'
|
||||||
|
|
||||||
const { isAllowed } = useAuth()
|
const { isAllowed } = useAuth()
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ const theme = ref('classic')
|
|||||||
const showGame = ref(false)
|
const showGame = ref(false)
|
||||||
const isNatUnlocked = ref(false)
|
const isNatUnlocked = ref(false)
|
||||||
const boars = ref<{id: number, top: number}[]>([])
|
const boars = ref<{id: number, top: number}[]>([])
|
||||||
|
const isPasswordResetMode = ref(false)
|
||||||
|
const resetOobCode = ref('')
|
||||||
|
|
||||||
const toggleTheme = (newTheme: string) => {
|
const toggleTheme = (newTheme: string) => {
|
||||||
theme.value = newTheme
|
theme.value = newTheme
|
||||||
@@ -52,6 +55,12 @@ const startBoarRun = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
if (urlParams.get('mode') === 'resetPassword') {
|
||||||
|
isPasswordResetMode.value = true
|
||||||
|
resetOobCode.value = urlParams.get('oobCode') || ''
|
||||||
|
}
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem('haumdaucher-theme')
|
const savedTheme = localStorage.getItem('haumdaucher-theme')
|
||||||
if (savedTheme) toggleTheme(savedTheme)
|
if (savedTheme) toggleTheme(savedTheme)
|
||||||
|
|
||||||
@@ -72,6 +81,14 @@ const t = (key: string) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div v-if="isPasswordResetMode">
|
||||||
|
<PasswordReset
|
||||||
|
:oobCode="resetOobCode"
|
||||||
|
:t="t"
|
||||||
|
@close="isPasswordResetMode = false; window.history.replaceState({}, document.title, window.location.pathname)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<Header
|
<Header
|
||||||
:currentTheme="theme"
|
:currentTheme="theme"
|
||||||
:isNatUnlocked="isNatUnlocked"
|
:isNatUnlocked="isNatUnlocked"
|
||||||
@@ -117,6 +134,7 @@ const t = (key: string) => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { confirmPasswordReset, verifyPasswordResetCode } from 'firebase/auth'
|
||||||
|
import { auth } from '../../firebase'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
oobCode: string
|
||||||
|
t: (key: string) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
error.value = 'Die Passwörter stimmen nicht überein.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value.length < 6) {
|
||||||
|
error.value = 'Das Passwort muss mindestens 6 Zeichen lang sein.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Optional: First verify the code is valid
|
||||||
|
const email = await verifyPasswordResetCode(auth, props.oobCode)
|
||||||
|
|
||||||
|
// Then confirm the reset
|
||||||
|
await confirmPasswordReset(auth, props.oobCode, newPassword.value)
|
||||||
|
success.value = true
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Password reset error:', err)
|
||||||
|
if (err.code === 'auth/invalid-action-code') {
|
||||||
|
error.value = 'Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.'
|
||||||
|
} else {
|
||||||
|
error.value = 'Ein Fehler ist aufgetreten: ' + err.message
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="reset-container">
|
||||||
|
<div class="glass-panel auth-card">
|
||||||
|
<h2 class="section-title">Neues Passwort 🌭</h2>
|
||||||
|
|
||||||
|
<div v-if="success" class="success-message">
|
||||||
|
<h3>Sauber!</h3>
|
||||||
|
<p>Dein Passwort wurde erfolgreich geändert.</p>
|
||||||
|
<button class="club-btn" @click="emit('close')">Zurück zur Startseite</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="handleReset" class="auth-form">
|
||||||
|
<p class="description">Bitte gib dein neues geheimes Haumdaucher-Passwort ein.</p>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="newPassword"
|
||||||
|
placeholder="Mindestens 6 Zeichen"
|
||||||
|
required
|
||||||
|
class="club-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label>Passwort bestätigen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
placeholder="Nochmal zur Sicherheit"
|
||||||
|
required
|
||||||
|
class="club-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="club-btn text-btn" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button type="submit" class="club-btn" :disabled="isLoading">
|
||||||
|
{{ isLoading ? 'Speichern...' : 'Passwort ändern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reset-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club-input {
|
||||||
|
padding: 12px 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions .club-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-alert {
|
||||||
|
background: rgba(255, 75, 75, 0.1);
|
||||||
|
color: #ff4b4b;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px solid rgba(255, 75, 75, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message h3 {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+4
-26
@@ -94,7 +94,7 @@ resource "google_identity_platform_config" "default" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
email {
|
email {
|
||||||
enabled = false # We only want Google Sign-In
|
enabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ resource "google_identity_platform_config" "default" {
|
|||||||
resource "google_identity_platform_default_supported_idp_config" "google" {
|
resource "google_identity_platform_default_supported_idp_config" "google" {
|
||||||
provider = google-beta
|
provider = google-beta
|
||||||
project = var.project_id
|
project = var.project_id
|
||||||
enabled = true
|
enabled = false
|
||||||
idp_id = "google.com"
|
idp_id = "google.com"
|
||||||
client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data
|
client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data
|
||||||
client_secret = data.google_secret_manager_secret_version.oauth_client_secret.secret_data
|
client_secret = data.google_secret_manager_secret_version.oauth_client_secret.secret_data
|
||||||
@@ -130,28 +130,6 @@ resource "google_firestore_database" "database" {
|
|||||||
depends_on = [google_project_service.firestore]
|
depends_on = [google_project_service.firestore]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Allowlist Configuration Document
|
|
||||||
resource "google_firestore_document" "allowlist" {
|
|
||||||
provider = google-beta
|
|
||||||
project = var.project_id
|
|
||||||
database = google_firestore_database.database.name
|
|
||||||
collection = "config"
|
|
||||||
document_id = "allowlist"
|
|
||||||
|
|
||||||
# Serialize the list of emails into a JSON string map for the fields
|
|
||||||
fields = jsonencode({
|
|
||||||
emails = {
|
|
||||||
arrayValue = {
|
|
||||||
values = [
|
|
||||||
for email in var.allowed_users : {
|
|
||||||
stringValue = email
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
# Firestore Security Rules
|
# Firestore Security Rules
|
||||||
resource "google_firebaserules_ruleset" "firestore" {
|
resource "google_firebaserules_ruleset" "firestore" {
|
||||||
provider = google
|
provider = google
|
||||||
@@ -163,8 +141,8 @@ resource "google_firebaserules_ruleset" "firestore" {
|
|||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
match /config/allowlist {
|
match /{document=**} {
|
||||||
allow read: if request.auth != null;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"scriptId":"1rKL7JeNXhVPh6uX8DZxQOx7I4QJDUPHL4YH4_4qLSELcOgj_mm6XmaZA","rootDir":"./src"}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This document provides context for AI agents operating on the `user_creation` infrastructure.
|
||||||
|
|
||||||
|
## Architecture & Tooling
|
||||||
|
- This directory contains a standalone Google Apps Script project managed via `@google/clasp`.
|
||||||
|
- Do NOT use TypeScript. We use vanilla `.js` (`src/Code.js`) to bypass local transpilation complexity.
|
||||||
|
- **Authentication**: We use `ScriptApp.getOAuthToken()` directly to authenticate against the Google Identity Toolkit REST API. **DO NOT** implement Web API Keys or Service Accounts in the code. The script relies on its link to the underlying GCP project to inherit the trigger owner's permissions.
|
||||||
|
|
||||||
|
## Rules & Safeguards (CRITICAL)
|
||||||
|
1. **Never physically delete users.**
|
||||||
|
- The declarative logic must use Soft Deletes. If an email is removed from the Google Sheet, the script must issue an API update to set `disableUser: true` in Firebase. This preserves historical records and prevents data corruption.
|
||||||
|
2. **Respect `DRY_RUN`.**
|
||||||
|
- When `CONFIG.DRY_RUN` is true, the script must only evaluate state and log its intended API calls. It must completely bypass any `UrlFetchApp.fetch` calls that mutate Firebase state.
|
||||||
|
3. **Trigger Handling (`setup`).**
|
||||||
|
- The script uses dual triggers (`onFormSubmit` and `onChange`). Ensure both are cleared and re-created whenever `setup()` is called.
|
||||||
|
- `clasp push` does not update triggers. Always instruct the human user to run `setup()` manually in the IDE after a push.
|
||||||
|
4. **Conditional Email Logging.**
|
||||||
|
- Only dispatch the admin summary email (to `CONFIG.ADMIN_EMAIL`) if a mutation occurred. If the target state and current state are perfectly synchronized, exit silently to prevent inbox pollution.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
- **Email/Password Strategy**: We generate a highly secure random password locally during the `accounts` creation request. `CONFIG.SEND_EMAIL_ON_CREATION` controls whether password reset emails are sent automatically.
|
||||||
|
- **Custom Action URL**: The Firebase Password Reset templates have been modified via the Console to point to the Vue.js app (`https://haumdaucher.de/`). The frontend `App.vue` intercepts `?mode=resetPassword` and displays `PasswordReset.vue`. Do NOT change this logic without considering the UI impact.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Haumdaucher User Creation
|
||||||
|
|
||||||
|
This Google Apps Script automatically provisions local Google Firebase accounts (Email/Password) based on the target emails defined in the Haumdaucher Google Sheet.
|
||||||
|
|
||||||
|
It is designed to run silently and declaratively alongside the `mail_forwarding` module.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **Environment**: Google Apps Script (Standalone).
|
||||||
|
- **Authentication**: Native Google Cloud Platform (GCP) linking. The script authenticates via `ScriptApp.getOAuthToken()` using the underlying GCP project's identity, avoiding hardcoded API keys.
|
||||||
|
- **API**: Google Identity Toolkit REST API (`accounts:query`, `accounts`, `accounts:update`, `accounts:sendOobCode`).
|
||||||
|
- **Triggers**: `onFormSubmit` (for real-time form entries) and `onChange` (for manual sheet edits).
|
||||||
|
|
||||||
|
## Declarative Logic
|
||||||
|
The script compares the desired state (emails in the Sheet) with the current state (users in Firebase Auth):
|
||||||
|
- **Create**: User in sheet but not in Firebase -> Creates an account with a secure random password.
|
||||||
|
- **Disable**: User in Firebase but not in sheet -> Soft deletes the account (`disableUser: true`).
|
||||||
|
- **Re-Enable**: User in sheet and Firebase but disabled -> Re-enables the account.
|
||||||
|
- **Ignore**: User matches both states and is active -> No action.
|
||||||
|
|
||||||
|
## Operational Instructions
|
||||||
|
|
||||||
|
### 1. Linking to GCP (Required once)
|
||||||
|
To allow the script to call Firebase APIs securely without an API key:
|
||||||
|
1. Open the Apps Script project (`clasp open-script`).
|
||||||
|
2. Click the **Project Settings** (gear icon) on the left.
|
||||||
|
3. Under **Google Cloud Platform (GCP) Project**, click **Change project**.
|
||||||
|
4. Enter your GCP Project Number (e.g., `171880300854` - this is the `messagingSenderId` from Terraform outputs).
|
||||||
|
5. Click **Set Project**.
|
||||||
|
|
||||||
|
### 2. Manual Triggers (setup)
|
||||||
|
If you modify the code or push a new version, you must reinstall the background triggers:
|
||||||
|
1. Open the IDE (`clasp open-script`).
|
||||||
|
2. Select the `setup` function from the dropdown.
|
||||||
|
3. Click **Run**.
|
||||||
|
*(Note: `clasp push` only updates code, it does not update running triggers).*
|
||||||
|
|
||||||
|
### 3. Dry Run Mode
|
||||||
|
The code natively supports a `DRY_RUN` flag in `CONFIG`. When set to `true`, it will read states and print its intended actions to the Execution Logs, but will NOT mutate Firebase data.
|
||||||
|
Currently, this is configured for production (`DRY_RUN: false`).
|
||||||
|
|
||||||
|
### 4. Admin Reporting
|
||||||
|
The script will send an email to `info@haumdaucher.de` **only if** state changes occurred (creating, disabling, or re-enabling a user). If no changes are needed, it remains completely silent.
|
||||||
|
|
||||||
|
### 5. Email Templates & Custom Actions
|
||||||
|
The Identity Platform email templates (Password Reset, etc.) are NOT managed via Terraform due to provider limitations. They are stored in `email_templates.md` and must be manually copy-pasted into the Firebase Console.
|
||||||
|
The action URL for Password Resets has been customized to point to the main Vue.js frontend (`https://haumdaucher.de/`), which intercepts the `mode=resetPassword` parameter and displays the custom `PasswordReset.vue` component to maintain the club's aesthetic.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Firebase Email Templates (HAUMDAUCHER n.e.V.)
|
||||||
|
|
||||||
|
Da Google Cloud Identity Platform (Firebase) die Konfiguration von E-Mail-Vorlagen über Terraform (`google_identity_platform_config`) nicht nativ unterstützt, müssen diese Texte einmalig manuell in der Firebase Console hinterlegt werden.
|
||||||
|
|
||||||
|
## Globale Einstellungen für alle Templates
|
||||||
|
Bitte stelle in der Firebase Console unter **Authentication > Templates** (oder Identity Platform im GCP Dashboard) Folgendes ein:
|
||||||
|
- **Sender Name**: HAUMDAUCHER n.e.V.
|
||||||
|
- **Reply-to**: info@haumdaucher.de
|
||||||
|
|
||||||
|
*(Da `haumdaucher.de` bereits bei Google verifiziert ist, sollte der Versand über diese Domain problemlos funktionieren).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Passwort zurücksetzen (Password Reset)
|
||||||
|
|
||||||
|
**Subject:**
|
||||||
|
```text
|
||||||
|
🌭 Neues Passwort gefällig? (HAUMDAUCHER n.e.V.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Above Link):**
|
||||||
|
```text
|
||||||
|
Servus %DISPLAY_NAME%,
|
||||||
|
|
||||||
|
ohje, hast du dein Passwort für den Haumdaucher-Zugang verschwitzt? Keine Sorge, das passiert den Besten nach ein paar Bratwürsten und Knackern zu viel! 🍻
|
||||||
|
|
||||||
|
Damit du wieder vollen Zugriff auf unsere streng geheimen Wurst- und Spezialitäten-Archive in Regensburg bekommst, klick einfach auf den magischen Link unten:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Below Link):**
|
||||||
|
```text
|
||||||
|
Lass dir Zeit, der Link rennt nicht weg (anders als eine gute Wurst auf dem Grill 🔥). Falls du gar kein neues Passwort angefordert hast, ignoriere diese E-Mail einfach – dein Account ist sicher.
|
||||||
|
|
||||||
|
Mit fleischigen Grüßen,
|
||||||
|
Dein Vorstand des HAUMDAUCHER Wurst und Spezialitäten n.e.V.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. E-Mail-Adresse bestätigen (Email Address Verification)
|
||||||
|
|
||||||
|
**Subject:**
|
||||||
|
```text
|
||||||
|
🥓 Bestätige deine E-Mail für den Haumdaucher-Zirkel!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Above Link):**
|
||||||
|
```text
|
||||||
|
Servus %DISPLAY_NAME%,
|
||||||
|
|
||||||
|
willkommen bei den wahren Feinschmeckern! 🥩 Bevor wir dich in die tiefsten Geheimnisse unserer Wurst- und Spezialitätenkunde einweihen, müssen wir kurz checken, ob du auch ein echter Mensch bist (und kein veganer Spionage-Bot).
|
||||||
|
|
||||||
|
Bitte bestätige deine E-Mail-Adresse mit einem herzhaften Klick auf diesen Link:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Below Link):**
|
||||||
|
```text
|
||||||
|
Sobald das erledigt ist, bist du offiziell startklar. Wir freuen uns auf dich! 🍻
|
||||||
|
|
||||||
|
Mit fleischigen Grüßen,
|
||||||
|
Dein Vorstand des HAUMDAUCHER Wurst und Spezialitäten n.e.V.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. E-Mail-Adresse ändern (Email Address Change)
|
||||||
|
|
||||||
|
**Subject:**
|
||||||
|
```text
|
||||||
|
🚨 E-Mail-Änderung beim Haumdaucher n.e.V.!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Above Link):**
|
||||||
|
```text
|
||||||
|
Servus %DISPLAY_NAME%,
|
||||||
|
|
||||||
|
wir haben gehört, dass du eine neue E-Mail-Adresse für deinen Haumdaucher-Account verwenden möchtest. Hast du den Provider gewechselt oder war die alte Adresse zu vegetarisch? 🥦
|
||||||
|
|
||||||
|
Bitte bestätige deine neue Adresse hier:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message (Below Link):**
|
||||||
|
```text
|
||||||
|
Falls du diese Änderung NICHT angefordert hast, melde dich bitte sofort bei uns unter info@haumdaucher.de – vielleicht versucht jemand, an deine geheimen Regensburger Wurst-Rezepte zu kommen! 🕵️♂️
|
||||||
|
|
||||||
|
Mit fleischigen Grüßen,
|
||||||
|
Dein Vorstand des HAUMDAUCHER Wurst und Spezialitäten n.e.V.
|
||||||
|
```
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"timeZone": "Europe/Berlin",
|
||||||
|
"dependencies": {},
|
||||||
|
"exceptionLogging": "STACKDRIVER",
|
||||||
|
"oauthScopes": [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
|
"https://www.googleapis.com/auth/script.external_request",
|
||||||
|
"https://www.googleapis.com/auth/script.send_mail",
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/script.scriptapp"
|
||||||
|
],
|
||||||
|
"runtimeVersion": "V8"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user