Compare commits

..

No commits in common. "master" and "implement_firebase" have entirely different histories.

34 changed files with 427 additions and 5307 deletions

3
.gitignore vendored
View File

@ -35,6 +35,3 @@ dist-ssr
# Deprecated (Inlined in Terraform)
firestore.rules
# Clasp / Google Apps Script
.clasprc.json

View File

@ -1,64 +0,0 @@
# AGENTS.md - Haumdaucher Project Handbook
## 🚨 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.
**2. Agental Protocol (Git & Deployment):**
- Never perform `git add` or `./deploy.sh` before the user has formally accepted the **Walkthrough** artifact.
- Once accepted, the agent is responsible for committing changes and triggering the deployment script.
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
## 🦢 Project Essence
**Haumdaucher** is a community project from Regensburg, Germany. The website represents the "HAUMDAUCHER Wurst und Spezialitäten n.e.V." (nicht eingetragener Verein). It is designed to be humorous, culturally rich, and technically "surprising."
## 🎨 Design Principles
- **Vibrant Aesthetics**: Each theme must feel like a completely different app.
- **Glassmorphism**: Use `backdrop-filter` and semi-transparent backgrounds for a premium feel.
- **Micro-interactions**: Subtle entrance animations and consistent hover states.
- **Accessibility**: Mobile-first design with safe-area support for PWA usage on iOS/Android.
## ⚖️ Legal & Compliance (n.e.V.)
Maintaining the legal section is critical for German compliance (§ 5 DDG).
- **Entity Type**: The club is a **nicht eingetragener Verein (n.e.V.)**. Do NOT add Registry data (Registergericht/Nummer).
- **Mandatory Impressum Fields**:
- Full Name: `HAUMDAUCHER Wurst und Spezialitäten n.e.V.`
- Vertretungsberechtigter: `Moritz Graf (1. Vorstand)`
- Ladungsfähige Anschrift: `Grabengasse 7, 93059 Regensburg`
- Kontakt: `Telefon: 094183065717, E-Mail: info@haumdaucher.de`
- **Privacy (DSGVO)**: The website uses **Google Firebase Authentication**. The privacy policy must disclose the data processing by Google Ireland Limited and the US data transfer details.
## 🛠 Technical Specifications
- **Framework**: Vue 3 (Composition API) + Vite + TypeScript.
- **State Management**: Centralized in `App.vue` using standard `ref` hooks.
- **Theming System**:
- Driven by `data-theme` attribute on `:root`.
- Defined in `src/assets/styles/global.css`.
- Themes: `Classic` (Light), `Dark` (Premium Charcoal/Gold), `NAT` (Boar Easter Egg).
- **Localization**:
- Centralized in `src/locales/i18n.ts`.
- Language: `de` (Standard German) only.
## 🚢 Deployment & DevOps
Deployment is automated via the `./deploy.sh` script.
**Workflow Details:**
1. **Cloud Sync**: Script ensures the `haumdaucher` namespace exists in Kubernetes.
2. **Config Extraction**: Fetches Firebase credentials directly from Terraform outputs (`terraform output -json firebase_config`).
3. **Build Pipeline**: Docker build passes Firebase credentials as `--build-arg` (VITE_FIREBASE_*).
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.
## 🤖 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
- **Engine**: HTML5 Canvas rendering. Game style changes dynamically based on the site's active theme.
- **Unlocking NAT Mode**:
- **Natural**: Reach Level 10.
- **Backdoor**: Single 1x1 pixel in the bottom-left corner. Type `nat mode` into the prompt.
## 📝 Ongoing Maintenance
- **Assets**: Static images should be placed in `public/images/`.
- **Style Overrides**: Mobile-first approach is mandatory. Always test with `max-width: 375px`.

61
GEMINI.md Normal file
View File

@ -0,0 +1,61 @@
# GEMINI.md - Haumdaucher Project Handbook
## 🚨 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.
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
## 🦢 Project Essence
**Haumdaucher** is a community project from Regensburg, Germany. The website is designed to be humorous, culturally rich (Bavarian), and technically "surprising."
## 🎨 Design Principles
- **Vibrant Aesthetics**: Each theme must feel like a completely different app.
- **Glassmorphism**: Use `backdrop-filter` and semi-transparent backgrounds for a premium feel.
- **Micro-interactions**: Subtle entrance animations (logo spin, hero text slide) and consistent hover states.
- **Accessibility**: Mobile-first design with safe-area support for PWA usage on iOS/Android.
## 🛠 Technical Specifications
- **Framework**: Vue 3 (Composition API) + Vite + TypeScript.
- **State Management**: Centralized in `App.vue` using standard `ref` hooks. Persisted in `localStorage`.
- **Theming System**:
- Driven by `data-theme` attribute on `:root`.
- Defined in `src/assets/styles/global.css`.
- Themes: `Classic`, `Unicorn`, `Luxury`, `Win95`, `NAT`.
- **Localization**:
- Centralized in `src/locales/i18n.ts`.
- Supports `de` (Standard German) and `bar` (Bavarian Dialect).
- **PWA**:
- Managed via `vite-plugin-pwa`.
- Custom icons and standalone manifest for "Add to Home Screen" support.
## 🧪 Testing
- **Framework**: Vitest + HappyDOM.
- **Scope**: Lightweight sanity checks (e.g., verifying App mount).
- **Commands**:
- `npm test`: Run tests in watch mode.
- `npm test -- --run`: Run tests once (CI mode).
## 🕹 The Haumdaucher Game
- **Engine**: HTML5 Canvas rendering.
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).
- **Thematization**: The game visual style (backgrounds, player, obstacles) changes dynamically based on the site's active theme.
- **Difficulty**: Balanced (10 levels). Level 10 triggers a "Boar Rain" supermode.
## 🔐 Progression & Gating
- **NAT Mode**: This theme is locked by default to maintain the "collectible" feel.
- **Unlocking**:
- **Natural**: Reach Level 10 in the game.
- **Backdoor**: Single 1x1 pixel in the bottom-left corner of the site. Clicking it triggers a prompt. Type `nat mode` to unlock.
## 🚢 Deployment & DevOps
- **Docker**: Dual-stage build (Node build -> Nginx serving).
- **Registry**: `registry.haumdaucher.de`.
- **Kubernetes**:
- Managed via `k8s-manifests.yaml`.
- Features `cert-manager` for SSL and `registry-haumdaucher-de` pull secret.
- **CI/CD Logic**: The `deploy.sh` script handles builds, pushes, and triggers a `kubectl rollout restart` to force deployment updates when utilizing the `latest` image tag.
## 📝 Ongoing Maintenance
- **Assets**: Static images should be placed in `public/images/` to avoid bundling issues in production containers.
- **Style Overrides**: Mobile-first approach is mandatory. Always test with `max-width: 375px`.

View File

@ -1,127 +0,0 @@
# **Satzung des „HAUMDAUCHER Wurst und Spezialitäten n.e.V.“**
**§ 1 Name, Sitz und Online-Präsenz**
(1) Der Verein führt den Namen „HAUMDAUCHER Wurst und Spezialitäten n.e.V.“. Er ist ein nicht eingetragener Verein.
(2) Der Sitz des Vereins ist Regensburg.
(3) Der Verein präsentiert sich online unter www.haumdaucher.de.
**§ 2 Vereinszweck und „Spirit“**
(1) Der Verein pflegt die Kultur der Kulinarik. Es liegt in der DNA der Gemeinschaft, andere Menschen mit Spezialitäten zu bewirten und sich gegenseitig mit Köstlichkeiten zu verwöhnen. Der Fokus liegt auf, aber nicht exklusiv bei, Wurstspezialitäten.
(2) Der Verein ist idealistisch und nicht auf wirtschaftlichen Gewinn ausgerichtet.
**§ 3 Der „Wursttober“**
Das Hauptevent des Vereinsjahres ist der „Wursttober“. Diese Veranstaltung findet jährlich, vorzugsweise im Oktober, statt. Fester Bestandteil und Tradition dieses Events ist es, gemeinschaftlich selbst frische Wurst herzustellen. Die Veranstaltung dient darüber hinaus der Präsentation der handwerklichen Ergebnisse und der Stärkung des Gemeinschaftsgeistes.
**§ 4 Der Vorstand und Vertretung**
(1) Der **geschäftsführende Vorstand** im Sinne des § 26 BGB besteht aus:
a) dem 1\. Vorsitzenden
b) dem stellvertretenden (2.) Vorsitzenden
c) dem Finanzvorstand (Kassenwart)
(2) Der **erweiterte Vorstand** besteht aus dem geschäftsführenden Vorstand sowie den jeweiligen Abteilungsvorsitzenden. Der erweiterte Vorstand berät über die strategische und kulinarische Ausrichtung des Vereins.
(3) **Vertretungsbefugnis:** Der Verein wird gerichtlich und außergerichtlich durch den 1\. Vorsitzenden oder den 2\. Vorsitzenden vertreten (Einzelvertretungsbefugnis).
(4) **Verfügungsbeschränkung:** Für Rechtsgeschäfte und Verträge, die einen Gegenwert von 500,00 Euro überschreiten, ist die gemeinsame Unterschrift von zwei Mitgliedern des geschäftsführenden Vorstands zwingend erforderlich.
**§ 5 Abteilungen des Vereins**
(1) Zur Erfüllung der vielfältigen kulinarischen Aufgaben können innerhalb des Vereins Abteilungen gebildet werden.
(2) Die Gründung einer Abteilung erfolgt durch Beschluss des geschäftsführenden Vorstands.
(3) Jede Abteilung wird durch einen Abteilungsvorsitzenden geleitet. Dieser wird durch seine Ernennung automatisch Mitglied des erweiterten Vorstands.
(4) Die Einzelheiten der Abteilungsführung werden in einem separaten Abteilungsgründungsdokument festgelegt.
**§ 6 Mitgliedschaft, Beendigung und Ausschluss**
(1) Mitglied kann jeder werden, der den „HAUMDAUCHER-Spirit“ teilt. Über die Aufnahme entscheidet der Vorstand.
(2) Die Mitgliedschaft endet durch Austritt, Tod oder Ausschluss.
(3) **Ausschluss:** Mitglieder, die gegen den Vereinszweck oder den „HAUMDAUCHER-Spirit“ verstoßen, können durch einen mehrheitlichen Beschluss des geschäftsführenden Vorstands mit sofortiger Wirkung aus dem Verein ausgeschlossen werden.
**§ 7 Die Mitgliederversammlung und Satzungsänderungen**
(1) Die Mitgliederversammlung ist das oberste Organ des Vereins. Sie ist insbesondere zuständig für die Wahl des Vorstands, die Entlastung des Vorstands sowie für Satzungsänderungen.
(2) Eine ordentliche Mitgliederversammlung findet mindestens einmal jährlich statt (vorzugsweise im Rahmen des „Wursttobers“).
(3) Die Einberufung erfolgt formlos, in der Regel per E-Mail oder über die offizielle Vereins-WhatsApp-Gruppe, durch den Vorstand mit einer Frist von mindestens 7 Tagen unter Angabe der Tagesordnung.
(4) **Satzungsänderungen:** Änderungen der Satzung bedürfen einer Mehrheit von zwei Dritteln (2/3) der anwesenden Mitglieder in der Mitgliederversammlung.
**§ 8 Kassenprüfung**
(1) Die Mitgliederversammlung wählt für die Dauer von zwei Jahren einen Kassenprüfer, der nicht dem Vorstand angehören darf.
(2) Der Kassenprüfer prüft mindestens einmal jährlich die Vereinskasse und erstattet der Mitgliederversammlung Bericht, um die Entlastung des Finanzvorstands zu empfehlen.
(3) Haben im abgelaufenen Geschäftsjahr keine finanziellen Transaktionen (Einnahmen oder Ausgaben) über die Vereinskasse stattgefunden, kann durch Beschluss des Vorstands auf die jährliche Kassenprüfung verzichtet werden.
**§ 9 Datenschutz**
(1) Zur Erfüllung der Zwecke und Aufgaben des Vereins werden unter Beachtung der Vorgaben der EU-Datenschutz-Grundverordnung (DSGVO) und des Bundesdatenschutzgesetzes (BDSG) personenbezogene Daten der Mitglieder (Name, Mobilfunknummer, E-Mail-Adresse) verarbeitet und vereinsintern gespeichert.
(2) Die Kommunikation im Verein erfolgt maßgeblich über E-Mail und Messenger-Dienste (z. B. WhatsApp). Jedes Mitglied stimmt der Aufnahme in entsprechende Verteiler und Gruppen mit seinem Beitritt zu.
**§ 10 Haftungsbeschränkung**
(1) Die Haftung der Mitglieder des Vorstands (geschäftsführend und erweitert) gegenüber dem Verein und seinen Mitgliedern ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.
(2) Werden Vorstandsmitglieder durch Dritte auf Ersatz eines Schadens in Anspruch genommen, der bei der Wahrnehmung der Vorstandspflichten entstanden ist, so stellt der Verein sie im Innenverhältnis von der Haftung frei, sofern sie nicht vorsätzlich oder grob fahrlässig gehandelt haben.
**§ 11 Auflösung**
Bei Auflösung des Vereins, welche nur mit einer 3/4-Mehrheit der Mitgliederversammlung beschlossen werden kann, fällt das verbleibende Vermögen (sowie Ausrüstung und Vorräte) zu gleichen Teilen an die zum Zeitpunkt der Auflösung bestehenden Mitglieder.
*Beschlossen und unterschrieben in der Gründungsversammlung am \_\_\_\_\_\_\_\_\_ in Etterzhausen.*
*Liste der Gründungsmitglieder*
*Name Unterschrift*
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_

View File

@ -9,14 +9,6 @@ TAG="latest"
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
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -

View File

@ -1,12 +0,0 @@
{
"scriptId": "1pTpLk3kblp9xByrZ0rM-5gBU-5pZkbdKR25bzI89apFdU1RHPzBvzIl5",
"rootDir": "src",
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

View File

@ -1,23 +0,0 @@
# 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.

View File

@ -1,96 +0,0 @@
# 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.

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +0,0 @@
{
"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"
}
}

View File

@ -1,385 +0,0 @@
/**
* 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
});
}

View File

@ -1,20 +0,0 @@
{
"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"
}

16
package-lock.json generated
View File

@ -74,7 +74,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -2119,7 +2118,6 @@
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.6.tgz",
"integrity": "sha512-4uyt8BOrBsSq6i4yiOV/gG6BnnrvTeyymlNcaN/dKvyU1GoolxAafvIvaNP1RCGPlNab3OuE4MKUQuv2lH+PLQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/component": "0.7.0",
"@firebase/logger": "0.5.0",
@ -2186,7 +2184,6 @@
"resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.6.tgz",
"integrity": "sha512-YYGARbutghQY4zZUWMYia0ib0Y/rb52y72/N0z3vglRHL7ii/AaK9SA7S/dzScVOlCdnbHXz+sc5Dq+r8fwFAg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/app": "0.14.6",
"@firebase/component": "0.7.0",
@ -2202,8 +2199,7 @@
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz",
"integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==",
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/@firebase/auth": {
"version": "1.12.0",
@ -2654,7 +2650,6 @@
"integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
},
@ -3685,7 +3680,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -3914,7 +3908,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4748,7 +4741,6 @@
"resolved": "https://registry.npmjs.org/firebase/-/firebase-12.7.0.tgz",
"integrity": "sha512-ZBZg9jFo8uH4Emd7caOqtalKJfDGHnHQSrCPiqRAdTFQd0wL3ERilUBfhnhBLnlernugkN/o7nJa0p+sE71Izg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@firebase/ai": "2.6.1",
"@firebase/analytics": "0.10.19",
@ -5061,7 +5053,6 @@
"integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "^20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
@ -6378,7 +6369,6 @@
"integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -7177,7 +7167,6 @@
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7326,7 +7315,6 @@
"integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
@ -8024,7 +8012,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
@ -8496,7 +8483,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},

View File

@ -1,25 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from './App.vue'
vi.mock('./firebase', () => ({
auth: {},
db: {}
}))
vi.mock('./composables/useAuth', () => {
const { ref } = require('vue')
return {
useAuth: () => ({
isAllowed: true,
user: ref(null),
login: vi.fn(),
logout: vi.fn(),
error: null
})
}
})
describe('Smoke Test', () => {
it('mounts properly', () => {
const wrapper = mount(App)

View File

@ -2,22 +2,21 @@
import { ref, onMounted } from 'vue'
import Header from './components/layout/Header.vue'
import Hero from './components/sections/Hero.vue'
import ClubSpirit from './components/sections/ClubSpirit.vue'
import Impressum from './components/sections/Legal/Impressum.vue'
import Datenschutz from './components/sections/Legal/Datenschutz.vue'
import About from './components/sections/About.vue'
import History from './components/sections/History.vue'
import Beer from './components/sections/Beer.vue'
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
import { messages } from './locales/i18n'
import { useAuth } from './composables/useAuth'
import PasswordReset from './components/auth/PasswordReset.vue'
const { isAllowed } = useAuth()
const theme = ref('classic')
const lang = ref<'de' | 'bar'>('de')
const showGame = ref(false)
const showBSOD = ref(false)
const isNatUnlocked = ref(false)
const boars = ref<{id: number, top: number}[]>([])
const isPasswordResetMode = ref(false)
const resetOobCode = ref('')
const toggleTheme = (newTheme: string) => {
theme.value = newTheme
@ -35,7 +34,7 @@ const unlockNat = () => {
}
const handleBackdoor = () => {
const secret = prompt('Bitte Geheimcode eingeben (Unlock NAT):')
const secret = prompt('Bitte Geheimcode eigem (Unlock NAT):')
if (secret?.toLowerCase() === 'nat mode') {
unlockNat()
alert('🐗 NAT-Modus freigschaltet! Wiedaschaun, reinghaun!')
@ -54,24 +53,32 @@ const startBoarRun = () => {
}, 8000)
}
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('mode') === 'resetPassword') {
isPasswordResetMode.value = true
resetOobCode.value = urlParams.get('oobCode') || ''
const triggerBSOD = () => {
if (theme.value === 'win95') {
showBSOD.value = true
setTimeout(() => showBSOD.value = false, 3000)
}
}
const toggleLang = (newLang: 'de' | 'bar') => {
lang.value = newLang
localStorage.setItem('haumdaucher-lang', newLang)
}
onMounted(() => {
const savedTheme = localStorage.getItem('haumdaucher-theme')
if (savedTheme) toggleTheme(savedTheme)
const savedLang = localStorage.getItem('haumdaucher-lang') as 'de' | 'bar'
if (savedLang) lang.value = savedLang
const savedNat = localStorage.getItem('haumdaucher-nat-unlocked')
if (savedNat === 'true') isNatUnlocked.value = true
})
// Simplified translation: German only
const t = (key: string) => {
const keys = key.split('.')
let result: any = messages.de
let result: any = messages[lang.value]
for (const k of keys) {
if (result[k]) result = result[k]
else return key
@ -81,34 +88,25 @@ const t = (key: string) => {
</script>
<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
:currentTheme="theme"
:currentLang="lang"
:isNatUnlocked="isNatUnlocked"
@update:theme="toggleTheme"
@update:lang="toggleLang"
@open:game="showGame = true"
:t="t"
/>
<main>
<main @click="triggerBSOD">
<!-- Member Banner -->
<Hero :theme="theme" :t="t" />
<ClubSpirit :t="t" id="spirit" />
<div class="legal-separator"></div>
<section id="impressum" class="legal-section">
<Impressum :t="t" />
</section>
<section id="datenschutz" class="legal-section">
<Datenschutz :t="t" />
</section>
<About :t="t" />
<History :t="t" />
<Beer :t="t" />
<!-- Hidden Backdoor Pixel -->
<div class="backdoor" @click.stop="handleBackdoor"></div>
</main>
@ -120,27 +118,35 @@ const t = (key: string) => {
@unlock-nat="unlockNat"
/>
<div v-if="showBSOD" class="bsod">
<h1>:(</h1>
<p>A problem has been detected and Windows has been shut down to prevent damage to your Haumdaucher.</p>
<p>DRIVER_IRQL_NOT_LESS_OR_EQUAL</p>
<p>Press any key to continue... just kidding, it's a website.</p>
</div>
<div v-for="boar in boars" :key="boar.id" class="running-boar" :style="{ top: boar.top + '%' }">🐗💨</div>
<footer class="site-footer">
<div class="container footer-content">
<div class="footer-info">
© {{ new Date().getFullYear() }} HAUMDAUCHER Wurst und Spezialitäten n.e.V.
</div>
<nav class="footer-links">
<a href="#impressum">Impressum</a>
<span class="sep"></span>
<a href="#datenschutz">Datenschutz</a>
</nav>
</div>
<footer class="container" style="padding: 40px 0; opacity: 0.6; font-size: 0.9em;">
© {{ new Date().getFullYear() }} Haumdaucher Regensburg. Alles für den Vogel.
</footer>
</div>
</template>
<style>
@import './assets/styles/global.css';
.bsod {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #0000aa;
color: white;
z-index: 3000;
padding: 50px;
font-family: 'Courier New', monospace;
}
.running-boar {
position: fixed;
left: -100px;
@ -165,63 +171,4 @@ const t = (key: string) => {
opacity: 0;
}
.legal-section {
min-height: auto !important;
padding: 60px 20px !important;
opacity: 0.8;
}
.legal-separator {
height: 1px;
background: var(--glass-border);
margin: 40px 10% 0;
opacity: 0.3;
}
.site-footer {
padding: 60px 0 100px; /* Extra bottom padding for mobile drawer */
border-top: 1px solid var(--glass-border);
background: var(--bg-color);
opacity: 0.8;
font-size: 0.85rem;
}
.footer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.footer-links {
display: flex;
gap: 15px;
align-items: center;
}
.footer-links a {
color: inherit;
text-decoration: none;
font-weight: 500;
transition: opacity 0.2s;
}
.footer-links a:hover {
opacity: 1;
text-decoration: underline;
}
.sep {
opacity: 0.3;
}
@media (min-width: 768px) {
.site-footer {
padding: 60px 0;
}
.footer-content {
flex-direction: row;
justify-content: space-between;
}
}
</style>

View File

@ -42,9 +42,10 @@ body {
.container {
width: 100%;
max-width: 1000px; /* Centered content on desktop */
margin: 0 auto;
padding: 0 20px;
max-width: 100%;
margin: 0;
padding: 0 10px;
/* Reduced from 15px to save space */
box-sizing: border-box;
}
@ -93,14 +94,43 @@ p {
max-width: 100%;
}
/* Theme Overrides */
[data-theme='dark'] {
--bg-color: #0f0f0f;
--text-color: #f0f0f0;
/* Theme Overrides (Remain same but refined) */
[data-theme='unicorn'] {
--bg-color: #fff0fb;
--text-color: #4a148c;
--primary-color: #f06292;
--accent-color: #ba68c8;
--header-bg: rgba(255, 240, 251, 0.85);
--glass-border: rgba(240, 98, 146, 0.2);
}
[data-theme='luxury'] {
--bg-color: #0a0a0a;
--text-color: #e0e0e0;
--primary-color: #d4af37;
--accent-color: #ffffff;
--header-bg: rgba(15, 15, 15, 0.85);
--glass-border: rgba(212, 175, 55, 0.2);
--accent-color: #e5e4e2;
--header-bg: rgba(10, 10, 10, 0.85);
--glass-border: rgba(212, 175, 55, 0.3);
}
[data-theme='win95'] {
--bg-color: #008080;
--text-color: #000000;
--primary-color: #c0c0c0;
--accent-color: #808080;
--header-bg: #c0c0c0;
--glass-border: #ffffff;
--font-family: 'MS Sans Serif', Tahoma, sans-serif;
cursor: url('https://cur.cursors-4u.net/games/gam-4/gam373.cur'), auto;
}
[data-theme='win95'] * {
border-radius: 0 !important;
}
[data-theme='win95'] .fancy-glass {
border: 2px solid;
border-color: #ffffff #808080 #808080 #ffffff;
}
[data-theme='nat'] {

View File

@ -1,202 +0,0 @@
<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>

View File

@ -1,17 +1,17 @@
<script setup lang="ts">
import { useAuth } from '../../composables/useAuth'
import { watch, ref } from 'vue'
const props = defineProps<{
currentTheme: string
currentLang: string
isNatUnlocked: boolean
t: (key: string) => string
}>()
const emit = defineEmits(['update:theme', 'open:game'])
const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
const themes = ['classic', 'dark', 'nat']
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
import { useAuth } from '../../composables/useAuth'
import { watch, ref } from 'vue'
const { user, login, logout, error } = useAuth()
const showSettings = ref(false)
@ -29,8 +29,8 @@ watch(user, (u) => {
<div class="branding-area">
<div class="logo-text">HAUMDAUCHER</div>
<div v-if="user" class="status-message">
<span class="desktop-msg">{{ isNatUnlocked ? 'Du bist a Haumdaucher 🫵 🍻' : 'Vielleicht... bist du a Haumdaucher' }}</span>
<span class="mobile-msg">{{ isNatUnlocked ? 'Haumdaucher! 🫵' : 'Vielleicht... 🤔' }}</span>
<span class="desktop-msg">{{ isNatUnlocked || isAllowed ? 'Du bist a Haumdaucher 🫵 🍻' : 'Vielleicht... bist du a Haumdaucher' }}</span>
<span class="mobile-msg">{{ isNatUnlocked || isAllowed ? 'Haumdaucher! 🫵' : 'Vielleicht... 🤔' }}</span>
</div>
</div>
@ -53,8 +53,19 @@ watch(user, (u) => {
</div>
</div>
<!-- Theme switch -->
<!-- Combined switch for better mobile spacing -->
<div class="control-wrapper" :class="{ 'show-mobile': showSettings }">
<div class="switch-group">
<button
v-for="l in ['de', 'bar']"
:key="l"
:class="{ active: currentLang === l }"
@click="emit('update:lang', l)"
>
{{ l.toUpperCase() }}
</button>
</div>
<div class="switch-group">
<template v-for="th in themes">
<button
@ -63,10 +74,12 @@ watch(user, (u) => {
:class="{ active: currentTheme === th }"
@click="emit('update:theme', th)"
class="theme-btn"
:title="t('themes.' + th)"
:title="th"
>
<span v-if="th === 'classic'"></span>
<span v-if="th === 'dark'">🌙</span>
<span v-if="th === 'classic'"></span>
<span v-if="th === 'unicorn'">🦄</span>
<span v-if="th === 'luxury'">👑</span>
<span v-if="th === 'win95'">💾</span>
<span v-if="th === 'nat'">🐗</span>
</button>
</template>
@ -79,17 +92,18 @@ watch(user, (u) => {
<!-- Mobile Bottom Nav -->
<nav class="fancy-glass mobile-nav">
<a href="#home" class="nav-item">🏠<span>{{ t('nav.home') }}</span></a>
<a href="#spirit" class="nav-item"><span>{{ t('nav.spirit') }}</span></a>
<a href="#about" class="nav-item">👥<span>{{ t('nav.about') }}</span></a>
<button class="nav-item game-btn" @click="emit('open:game')">🕹<span>{{ t('nav.game') }}</span></button>
<a href="#impressum" class="nav-item"><span>Impressum</span></a>
<a href="#history" class="nav-item">📜<span>{{ t('nav.history') }}</span></a>
<a href="#beer" class="nav-item">🍺<span>{{ t('nav.beer') }}</span></a>
</nav>
<!-- Desktop Side Nav / Links -->
<nav class="desktop-links">
<div class="container">
<a href="#spirit">{{ t('nav.spirit') }}</a>
<a href="#impressum">Impressum</a>
<a href="#datenschutz">Datenschutz</a>
<a href="#about">{{ t('nav.about') }}</a>
<a href="#history">{{ t('nav.history') }}</a>
<a href="#beer">{{ t('nav.beer') }}</a>
<button class="game-nav-btn" @click="emit('open:game')">{{ t('nav.game') }}</button>
</div>
</nav>
@ -136,6 +150,8 @@ watch(user, (u) => {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.status-message {
@ -170,14 +186,12 @@ watch(user, (u) => {
position: absolute;
top: 60px;
right: 10px;
background: var(--header-bg);
background: rgba(255, 255, 255, 0.95);
padding: 10px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
flex-direction: column;
align-items: flex-end;
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px);
}
.control-wrapper.show-mobile {
@ -214,7 +228,7 @@ button {
button.active {
background: var(--primary-color);
color: #fff;
color: white;
opacity: 1;
}

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="about" class="about">
<div class="container fancy-glass alternate-bg">
<h2 class="section-title">{{ t('about.title') }}</h2>
<p class="section-text">{{ t('about.content') }}</p>
<div class="friends-grid">
<div v-for="i in 5" :key="i" class="friend-avatar">🍻</div>
</div>
</div>
</section>
</template>
<style scoped>
.about {
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.02) 100%);
}
.alternate-bg {
padding: 60px;
border-radius: 40px;
}
.section-title {
font-size: clamp(1.8rem, 8vw, 3rem);
margin-bottom: 20px;
}
.section-text {
font-size: clamp(1rem, 4vw, 1.3rem);
max-width: 800px;
margin: 0 auto 30px;
}
.friends-grid {
display: flex;
justify-content: center;
gap: 20px;
font-size: 2rem;
}
.friend-avatar {
background: rgba(var(--primary-color), 0.1);
padding: 20px;
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="beer" class="beer">
<div class="container fancy-glass alternate-bg">
<h2 class="section-title">{{ t('beer.title') }}</h2>
<p class="section-text">{{ t('beer.content') }}</p>
<div class="beer-animation">🍺 🍺 🍺</div>
</div>
</section>
</template>
<style scoped>
.history {
background-attachment: fixed;
}
.section-title {
font-size: clamp(1.8rem, 8vw, 3rem);
margin-bottom: 20px;
}
.section-text {
font-size: clamp(1rem, 4vw, 1.3rem);
max-width: 800px;
margin: 0 auto 30px;
}
.bird-accent {
font-size: 5rem;
margin-top: 40px;
opacity: 0.3;
}
</style>

View File

@ -1,55 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="spirit" class="glass-section container">
<h2>{{ t('spirit.title') }}</h2>
<div class="content-text">
<p>{{ t('spirit.content') }}</p>
</div>
</section>
</template>
<style scoped>
.glass-section {
background: var(--glass-bg);
border: var(--glass-border);
box-shadow: var(--glass-shadow);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-radius: 20px;
padding: 40px;
margin-bottom: 40px;
color: var(--text-color);
transition: all 0.3s ease;
}
h2 {
font-size: 2.5rem;
margin-bottom: 20px;
color: var(--primary-color);
text-transform: uppercase;
letter-spacing: 2px;
}
.content-text {
font-size: 1.2rem;
line-height: 1.8;
white-space: pre-line;
}
@media (max-width: 480px) {
.glass-section {
padding: 20px;
}
h2 {
font-size: 2rem;
}
.content-text {
font-size: 1.1rem;
}
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="history" class="history">
<div class="container fancy-glass alternate-bg">
<h2 class="section-title">{{ t('history.title') }}</h2>
<p class="section-text">{{ t('history.content') }}</p>
<div class="bird-accent">🦢</div>
</div>
</section>
</template>
<style scoped>
.beer {
padding-bottom: 150px;
}
.alternate-bg {
padding: 60px;
border-radius: 40px;
}
.section-title {
font-size: clamp(1.8rem, 8vw, 3rem);
margin-bottom: 20px;
}
.section-text {
font-size: clamp(1rem, 4vw, 1.3rem);
max-width: 800px;
margin: 0 auto 30px;
}
.beer-animation {
font-size: 4rem;
animation: clink 2s infinite ease-in-out;
}
@keyframes clink {
0%, 100% { transform: rotate(0); }
50% { transform: rotate(10deg) scale(1.1); }
}
</style>

View File

@ -1,39 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="datenschutz" class="glass-section container legal-section">
<h2>{{ t('datenschutz.title') }}</h2>
<div class="content-text">
<p>{{ t('datenschutz.content') }}</p>
</div>
</section>
</template>
<style scoped>
.legal-section {
background: rgba(0, 0, 0, 0.02);
border: var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
}
h2 {
font-size: 1.5rem;
margin-bottom: 15px;
text-transform: uppercase;
}
.content-text {
font-size: 1rem;
line-height: 1.6;
white-space: pre-line;
opacity: 0.8;
}
</style>

View File

@ -1,40 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
t: (key: string) => string
}>()
</script>
<template>
<section id="impressum" class="glass-section container legal-section">
<h2>{{ t('impressum.title') }}</h2>
<div class="content-text">
<p>{{ t('impressum.content') }}</p>
</div>
</section>
</template>
<style scoped>
.legal-section {
margin-top: 60px;
background: rgba(0, 0, 0, 0.02);
border: var(--glass-border);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
}
h2 {
font-size: 1.5rem;
margin-bottom: 15px;
text-transform: uppercase;
}
.content-text {
font-size: 1rem;
line-height: 1.6;
white-space: pre-line;
opacity: 0.8;
}
</style>

View File

@ -10,22 +10,18 @@ const isLoading = ref(true)
const error = ref<string | null>(null)
// Watch auth state
if (auth) {
auth.onAuthStateChanged((u) => {
auth.onAuthStateChanged((u) => {
console.log("🔥 Auth State Changed:", u ? u.email : "Logged Out")
user.value = u
if (!u) {
isAllowed.value = false
isLoading.value = false
}
})
} else {
isLoading.value = false
}
})
// Check allowlist
watchEffect((onCleanup) => {
if (!user.value || !db) return
if (!user.value) return
// Subscribe to the config/allowlist document
const allowlistRef = doc(db, 'config', 'allowlist')
@ -50,10 +46,6 @@ watchEffect((onCleanup) => {
export function useAuth() {
const login = async () => {
error.value = null
if (!auth) {
error.value = "Firebase is not configured!"
return
}
try {
const provider = new GoogleAuthProvider()
await signInWithPopup(auth, provider)
@ -66,7 +58,6 @@ export function useAuth() {
const logout = async () => {
error.value = null
if (!auth) return
try {
await signOut(auth)
user.value = null

View File

@ -13,14 +13,9 @@ const firebaseConfig = {
appId: import.meta.env.VITE_FIREBASE_APP_ID
}
// Initialize Firebase safely
export let auth: ReturnType<typeof getAuth> | null = null;
export let db: ReturnType<typeof getFirestore> | null = null;
// Initialize Firebase
const app = initializeApp(firebaseConfig)
if (firebaseConfig.apiKey) {
const app = initializeApp(firebaseConfig)
auth = getAuth(app)
db = getFirestore(app)
} else {
console.warn("⚠️ Firebase API Key missing. Firebase features are disabled.")
}
// Initialize services
export const auth = getAuth(app)
export const db = getFirestore(app)

View File

@ -2,28 +2,32 @@ export const messages = {
de: {
nav: {
home: 'Start',
spirit: 'Spirit',
about: 'Über uns',
history: 'Geschichte',
beer: 'Bier & Gaudi',
game: 'Haumdaucher Game'
},
hero: {
title: 'Haumdaucher',
subtitle: 'Wurst und Spezialitäten n.e.V. in Gründung'
title: 'D\'Haumdaucher',
subtitle: 'Eine Gemeinschaft aus Regensburg offen für alle.'
},
spirit: {
title: 'Unser Spirit',
content: 'Wir pflegen die Kultur der Kulinarik. Es liegt in unserer DNA, andere Menschen mit Spezialitäten zu bewirten und uns gegenseitig mit Köstlichkeiten zu verwöhnen. Der Fokus liegt bei uns auf aber nicht exklusiv bei echtem Wurst-Handwerk.'
about: {
title: 'Wer wir sind',
content: 'Wir sind eine Truppe von etwa 15 Freunden aus Regensburg, die sich schon ewig kennen. Doch wir sind kein geschlossener Kreis: Wir freuen uns über jede neue Bekanntschaft und sind offen für die Welt.'
},
impressum: {
title: 'Impressum',
content: 'HAUMDAUCHER Wurst und Spezialitäten n.e.V. (in Gründung)\n\nVertreten durch:\nMoritz Graf (1. Vorstand)\nGrabengasse 7\n93059 Regensburg\n\nKontakt:\nTelefon: 094183065717\nE-Mail: info@haumdaucher.de'
history: {
title: 'Die Geschichte der Haumdaucher',
content: 'Der "Haubentaucher" ist ein edler Vogel. Wir haben daraus den "Haumdaucher" gemacht bayerisch, bodenständig, aber mit dem Blick über den Tellerrand.'
},
datenschutz: {
title: 'Datenschutzerklärung',
content: 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Als reine Repräsentationsseite erheben wir keine personenbezogenen Daten von Besuchern, es sei denn, dies ist technisch notwendig.\n\n**Google Firebase Authentication**\nZur Bereitstellung unseres Mitglieder-Logins nutzen wir Firebase Authentication (Google Ireland Limited). Dabei werden Daten (z.B. E-Mail) verarbeitet. Dies erfolgt auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Daten können in die USA übertragen werden, abgesichert durch das EU-U.S. Data Privacy Framework und Standardvertragsklauseln.'
beer: {
title: 'Bier & Gaudi',
content: 'Geselligkeit steht bei uns an erster Stelle. Ob beim bayerischen Hellen oder beim Austausch mit neuen Gesichtern bei uns ist jeder willkommen.'
},
themes: {
classic: 'Klassisch',
dark: 'Dunkel',
unicorn: 'Einhorn',
luxury: 'Luxus',
win95: 'Windows 95',
nat: 'NAT-Mode'
},
game: {
@ -33,5 +37,44 @@ export const messages = {
level: 'Level',
win: 'NATinator bezwungen! Du bist a echter Haumdaucher.'
}
},
bar: {
nav: {
home: 'Dahoam',
about: 'Wer mir san',
history: 'Wia ois ogfanga hod',
beer: 'Bia & Gaudi',
game: 'Haumdaucher Spui'
},
hero: {
title: 'D\'Haumdaucher',
subtitle: 'A echte Rengschbuaga Gschicht offn fia olle.'
},
about: {
title: 'Wer mir san',
content: 'Mir san a Hauffa vo ca. 15 Spezln aus Rengschbuag. Mia hoidn zam, oba mir san fia jedn offn, dea a bisserl a Gmiatlichkeit midbringt.'
},
history: {
title: 'Vom Haubndaucha zum Haumdaucha',
content: 'Da "Haubentaucher" is a scheena Vogl. Mia hom an "Haumdaucher" draus gmocht boarisch, ehrlich und offn fia de ganze Welt.'
},
beer: {
title: 'Bia & Gaudi',
content: 'Gmiatlickheit is des Wichtigste. Egal ob mid am gscheidn Hellen oda mid neie Leit bei uns ghead jeda dazua.'
},
themes: {
classic: 'Klassisch',
unicorn: 'Einhorn',
luxury: 'Luxus',
win95: 'Windows 95',
nat: 'NAT-Mode'
},
game: {
title: 'Haumdaucher Spui',
start: 'Af geht\'s!',
gameOver: 'Schluss is! Game Over.',
level: 'Level',
win: 'NATinator gschlong! Du bist a Pfundskerl.'
}
}
}

View File

@ -94,7 +94,7 @@ resource "google_identity_platform_config" "default" {
}
email {
enabled = true
enabled = false # We only want Google Sign-In
}
}
@ -105,7 +105,7 @@ resource "google_identity_platform_config" "default" {
resource "google_identity_platform_default_supported_idp_config" "google" {
provider = google-beta
project = var.project_id
enabled = false
enabled = true
idp_id = "google.com"
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
@ -130,6 +130,28 @@ resource "google_firestore_database" "database" {
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
resource "google_firebaserules_ruleset" "firestore" {
provider = google
@ -141,8 +163,8 @@ resource "google_firebaserules_ruleset" "firestore" {
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false;
match /config/allowlist {
allow read: if request.auth != null;
}
}
}

View File

@ -1 +0,0 @@
{"scriptId":"1rKL7JeNXhVPh6uX8DZxQOx7I4QJDUPHL4YH4_4qLSELcOgj_mm6XmaZA","rootDir":"./src"}

View File

@ -1,23 +0,0 @@
# 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.

View File

@ -1,46 +0,0 @@
# 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.

View File

@ -1,88 +0,0 @@
# 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.
```

View File

@ -1,321 +0,0 @@
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
});
}

View File

@ -1,13 +0,0 @@
{
"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"
}