feat(projects): migrate haumdaucherde website into monorepo and document in AGENTS.md

This commit is contained in:
Moritz Graf
2026-10-12 10:42:30 +02:00
parent f225d4fe4e
commit 3b190988be
76 changed files with 16748 additions and 0 deletions
@@ -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"
}