docs: refactor GEMINI.md -> AGENTS.md, update user_creation docs and fix code quality issues

This commit is contained in:
Moritz Graf
2026-04-26 18:36:59 +02:00
parent 08c7ef4660
commit a16ebd641e
4 changed files with 95 additions and 12 deletions
+2 -1
View File
@@ -19,4 +19,5 @@ This document provides context for AI agents operating on the `user_creation` in
- 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**: The user requested that we do NOT send out official welcome/password reset emails during the initial implementation to avoid spamming end users. We will generate a highly secure random password locally during the `accounts:signUp` request. The admin will manually trigger password resets later when they are ready.
- **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.
+8 -6
View File
@@ -7,7 +7,7 @@ It is designed to run silently and declaratively alongside the `mail_forwarding`
## 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:batchGet`, `accounts:signUp`, `accounts:update`).
- **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
@@ -35,10 +35,12 @@ If you modify the code or push a new version, you must reinstall the background
*(Note: `clasp push` only updates code, it does not update running triggers).*
### 3. Dry Run Mode
By default, the code is set to `DRY_RUN: true`. It will read states and print its intended actions to the Execution Logs, but will NOT mutate Firebase data.
To activate:
1. Change `DRY_RUN: false` in `src/Code.js`.
2. Run `clasp push`.
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 `moritz@haumdaucher.de` **only if** state changes occurred (creating, disabling, or re-enabling a user). If no changes are needed, it remains completely silent.
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.
+21 -5
View File
@@ -148,6 +148,9 @@ 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];
@@ -164,6 +167,8 @@ function reconcileUsers(desiredEmails, currentState) {
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}`);
@@ -173,7 +178,11 @@ function reconcileUsers(desiredEmails, currentState) {
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}`);
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}`);
}
@@ -182,12 +191,16 @@ function reconcileUsers(desiredEmails, currentState) {
// 2. Check for Disablements
for (const [email, fbUser] of Object.entries(currentState)) {
if (!desiredEmails.includes(email) && !fbUser.disabled) {
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}`);
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}`);
}
@@ -278,13 +291,16 @@ function sendPasswordReset(email, token) {
}
/**
* Generates a 24-character secure random password.
* 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++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
// Mask to avoid modulo bias: chars.length (88) fits safely in a byte (0-255)
pwd += chars.charAt(bytes[i] % chars.length);
}
return pwd;
}