feat(projects): migrate haumdaucherde website into monorepo and document in AGENTS.md
@@ -0,0 +1,40 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
# Local Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Terraform
|
||||
.terraform/
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfvars
|
||||
*.tfplan
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Deprecated (Inlined in Terraform)
|
||||
firestore.rules
|
||||
|
||||
# Clasp / Google Apps Script
|
||||
.clasprc.json
|
||||
@@ -0,0 +1,119 @@
|
||||
# AGENTS.md — Haumdaucher Official Website (`haumdaucherde`)
|
||||
|
||||
> [!NOTE]
|
||||
> **Domain**: Official website and community platform for Haumdaucher Regensburg (`HAUMDAUCHER Wurst und Spezialitäten n.e.V.`).
|
||||
> **Live Production URL**: `https://haumdaucher.de`
|
||||
> **Kubernetes Manifest**: `k8s/haumdaucher/haumdaucher.yaml` (Namespace: `haumdaucher`)
|
||||
> **Container Image**: `registry.haumdaucher.de/haumdaucher-website:latest`
|
||||
> **Source Location**: `projects/haumdaucherde/`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Rules & Principles
|
||||
**1. Infrastructure as Code is rule #1.** Manual creation of cloud resources (e.g., via `gcloud` or Console) is forbidden. Use Terraform/OpenTofu located in `terraform/`.
|
||||
|
||||
**2. Agental Protocol (Git & Deployment):**
|
||||
- Never deploy to production before changes are verified with tests (`npm test -- --run`) and static build (`npm run build`).
|
||||
- Standard deployment is driven via `make deploy` or `./deploy.sh`.
|
||||
|
||||
---
|
||||
|
||||
## 🦢 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 feels like a distinct experience.
|
||||
- **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 (`max-width: 375px`).
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ 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 and composables (`useAuth.ts`).
|
||||
- **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), `Win95`, `Unicorn`, `Luxury`.
|
||||
- **Localization**:
|
||||
- Centralized in `src/locales/i18n.ts`.
|
||||
- Language: `de` (Standard German) only.
|
||||
|
||||
---
|
||||
|
||||
## 🚢 Deployment & DevOps
|
||||
|
||||
Standard commands:
|
||||
```bash
|
||||
cd projects/haumdaucherde
|
||||
make test # Run Vitest unit tests (npm test -- --run)
|
||||
make compile # Static build to dist/ (npm run build)
|
||||
make build # Docker build using ./deploy.sh (extracts Firebase config from terraform)
|
||||
make deploy # Apply k8s/haumdaucher/haumdaucher.yaml and rollout restart
|
||||
```
|
||||
|
||||
**Workflow Details:**
|
||||
1. **Cluster Sync**: Ensures the `haumdaucher` namespace exists in Kubernetes.
|
||||
2. **Config Extraction**: Fetches Firebase credentials directly from Terraform outputs (`terraform output -json firebase_config` in `terraform/`).
|
||||
3. **Build Pipeline**: Docker multi-stage build (`node:lts-alpine` builder → `nginx:stable-alpine` runtime) targeting `linux/amd64`.
|
||||
4. **Distribution**: Pushes the image to `registry.haumdaucher.de/haumdaucher-website:latest`.
|
||||
5. **Kubernetes Rollout**: Applies `k8s/haumdaucher/haumdaucher.yaml` and executes `kubectl rollout restart deployment/haumdaucher-website -n haumdaucher`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Repository Structure
|
||||
|
||||
```
|
||||
projects/haumdaucherde/
|
||||
├── Dockerfile # Multi-stage container build (Node builder -> Nginx runtime)
|
||||
├── Makefile # Standard make targets (compile, dev, test, build, deploy)
|
||||
├── deploy.sh # Deployment script with Terraform config extraction
|
||||
├── package.json # Dependencies and scripts (Vue 3, Vite, Vitest, VueFire)
|
||||
├── vite.config.ts # Vite configuration with PWA plugin
|
||||
├── tsconfig.json # TypeScript base configuration
|
||||
├── index.html # HTML entrypoint
|
||||
├── k8s-manifests.yaml # Local copy of Deployment, Service & Ingress manifests
|
||||
├── public/ # Static assets (PWA icons, theme logos in images/)
|
||||
├── src/ # Vue 3 source code
|
||||
│ ├── App.vue # Top-level shell and theme state
|
||||
│ ├── firebase.ts # Firebase client SDK initialization
|
||||
│ ├── main.ts # Vue entrypoint
|
||||
│ ├── components/ # Reusable UI components & sections
|
||||
│ ├── composables/ # useAuth authentication composable
|
||||
│ ├── locales/ # Centralized i18n German translations
|
||||
│ └── assets/ # Styles (global.css) and logos
|
||||
├── terraform/ # Terraform IaC for Firebase project & auth config
|
||||
├── user_creation/ # Declarative Google Apps Script for user provisioning
|
||||
├── mail_forwarding/ # Google Apps Script for haumdaucher.de email routing
|
||||
└── scripts/ # Helper scripts (setup-local-env.sh, manage_secrets.py)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤖 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.
|
||||
- **`mail_forwarding/AGENTS.md`**: Outlines mail forwarding rules and clasp deployment.
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Developer Documentation: Haumdaucher Website
|
||||
|
||||
Welcome to the technical documentation for the "Haumdaucher" website. This guide provides an overview of the architecture, file structure, and how to extend the project.
|
||||
|
||||
## 🏗 Project Architecture
|
||||
|
||||
The website is a modern **Vue 3** application built with **Vite** and **TypeScript**. It's designed as a high-performance, themed one-pager.
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: [Vue.js 3](https://vuejs.org/) (Composition API)
|
||||
- **Build Tool**: [Vite](https://vitejs.dev/)
|
||||
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||
- **Styling**: Vanilla CSS with CSS Variables for theming.
|
||||
- **Deployment**: Docker & Kubernetes.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```text
|
||||
haumdaucher_de/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ │ ├── images/ # Themed logos and other graphic assets
|
||||
│ │ └── styles/
|
||||
│ │ └── global.css # Core CSS variables and design tokens
|
||||
│ ├── components/
|
||||
│ │ ├── layout/ # Global layout components (Header, Footer)
|
||||
│ │ └── sections/ # Main content sections (Hero, About, etc.)
|
||||
│ ├── locales/
|
||||
│ │ └── i18n.ts # Translation files (DE and BAR)
|
||||
│ ├── App.vue # Root component & state management
|
||||
│ └── main.ts # Entry point
|
||||
├── Dockerfile # Containerization configuration
|
||||
├── k8s-manifests.yaml # Kubernetes resources
|
||||
├── deploy.sh # Deployment automation script
|
||||
└── DEVELOPMENT.md # This documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 How to Modify
|
||||
|
||||
### 1. Adding/Changing Styles (Themes)
|
||||
Theming is handled via `[data-theme]` attributes on the `<html>` element. All colors and transition speeds are defined as CSS variables in [src/assets/styles/global.css](file:///Users/moritz/src/haumdaucher_de/src/assets/styles/global.css).
|
||||
|
||||
- **To add a new theme**: Define a new `[data-theme='your-theme']` block in `global.css` with the required variables.
|
||||
- **To update existing themes**: Modify the variables within the respective blocks (`:root`, `[data-theme='unicorn']`, `[data-theme='luxury']`).
|
||||
|
||||
### 2. Updating Content (i18n)
|
||||
All text content is externalized in [src/locales/i18n.ts](file:///Users/moritz/src/haumdaucher_de/src/locales/i18n.ts).
|
||||
|
||||
- **To change text**: Locate the key in the `messages` object for both `de` (German) and `bar` (Bavarian) and update the string.
|
||||
- **To add a new language**: Add a new top-level key to the `messages` object and implement all required fields.
|
||||
|
||||
### 3. Modifying Sections
|
||||
Each section of the one-pager is a standalone Vue component in `src/components/sections/`.
|
||||
- [Hero.vue](file:///Users/moritz/src/haumdaucher_de/src/components/sections/Hero.vue): Handles the dynamic logo and splash screen.
|
||||
- [About.vue](file:///Users/moritz/src/haumdaucher_de/src/components/sections/About.vue): "Wer mir san" section.
|
||||
- [History.vue](file:///Users/moritz/src/haumdaucher_de/src/components/sections/History.vue): Origin of the name.
|
||||
- [Beer.vue](file:///Users/moritz/src/haumdaucher_de/src/components/sections/Beer.vue): The beer and culture section.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Local Development
|
||||
|
||||
To run the project locally for development or testing:
|
||||
|
||||
1. **Check Node Version**:
|
||||
This project is optimized for **Node.js 16.11.1+** (using Vite 4 for compatibility). If you have a newer Node version (20+), it will also work perfectly.
|
||||
|
||||
2. **Install Dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start Dev Server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
The site will be available at [http://localhost:5173/](http://localhost:5173/).
|
||||
|
||||
3. **Build for Production**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
The optimized assets will be generated in the `dist/` directory.
|
||||
|
||||
---
|
||||
|
||||
## 🎡 Kubernetes Deployment
|
||||
|
||||
For information on deploying to a Kubernetes cluster, refer to the [deploy.sh](file:///Users/moritz/src/haumdaucher_de/deploy.sh) script and [k8s-manifests.yaml](file:///Users/moritz/src/haumdaucher_de/k8s-manifests.yaml).
|
||||
@@ -0,0 +1,22 @@
|
||||
# Build stage
|
||||
FROM node:lts-alpine as build-stage
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Firebase Config Build Args
|
||||
ARG VITE_FIREBASE_API_KEY
|
||||
ARG VITE_FIREBASE_AUTH_DOMAIN
|
||||
ARG VITE_FIREBASE_PROJECT_ID
|
||||
ARG VITE_FIREBASE_STORAGE_BUCKET
|
||||
ARG VITE_FIREBASE_MESSAGING_SENDER_ID
|
||||
ARG VITE_FIREBASE_APP_ID
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,34 @@
|
||||
REGISTRY ?= registry.haumdaucher.de
|
||||
IMAGE_NAME ?= haumdaucher-website
|
||||
TAG ?= latest
|
||||
PLATFORM ?= linux/amd64
|
||||
NAMESPACE ?= haumdaucher
|
||||
|
||||
IMAGE ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG)
|
||||
|
||||
.PHONY: compile dev test build tag push deploy all
|
||||
|
||||
all: compile build tag push deploy
|
||||
|
||||
compile:
|
||||
npm run build
|
||||
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
test:
|
||||
npm test -- --run
|
||||
|
||||
build:
|
||||
./deploy.sh
|
||||
|
||||
tag:
|
||||
docker tag $(IMAGE_NAME):$(TAG) $(IMAGE)
|
||||
|
||||
push:
|
||||
docker push $(IMAGE)
|
||||
|
||||
deploy:
|
||||
kubectl apply -f ../../k8s/haumdaucher/haumdaucher.yaml
|
||||
kubectl rollout restart deployment/haumdaucher-website -n $(NAMESPACE)
|
||||
kubectl rollout status deployment/haumdaucher-website -n $(NAMESPACE)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Haumdaucher Website 🦢🍻
|
||||
|
||||
D'Offizielle Website der Haumdaucher aus Regensburg.
|
||||
|
||||
## 🚀 Quick Start (Local)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Visit [http://localhost:5173/](http://localhost:5173/) to see the site.
|
||||
|
||||
## 🛠 Documentation
|
||||
|
||||
For detailed information on the project structure, how to modify themes, update content, or deploy to Kubernetes, please see the **[Development Guide](DEVELOPMENT.md)**.
|
||||
|
||||
## 🎡 Deployment
|
||||
|
||||
Use the provided deployment script to push to your Kubernetes cluster:
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
Check [k8s-manifests.yaml](k8s-manifests.yaml) for resource definitions.
|
||||
|
||||
## 🚀 Bootstrap & Authentication (Infrastructure)
|
||||
|
||||
We use Terraform to manage Firebase Authentication and Firestore. To set this up for a new environment:
|
||||
|
||||
### 1. Manual Prerequisites (One-time)
|
||||
1. **Create a Project**: Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project.
|
||||
2. **Enable Billing**: Link a Billing Account to this project (Required for Terraform to enable Identity Platform).
|
||||
3. **Local Auth**:
|
||||
```bash
|
||||
gcloud auth login
|
||||
gcloud auth application-default login
|
||||
```
|
||||
4. **Configure OAuth (Crucial Step)**:
|
||||
* Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent).
|
||||
* Select **External** -> Create.
|
||||
* Fill in: App Name ("Haumdaucher"), Support Email, and Developer Contact Email. Click **Save and Continue** (You can skip scopes/test users for now).
|
||||
* Go to [Credentials](https://console.cloud.google.com/apis/credentials).
|
||||
* Click **+ Create Credentials** > **OAuth client ID**.
|
||||
* Type: **Web application**.
|
||||
* Name: "Haumdaucher Web".
|
||||
* **Authorized JavaScript origins**:
|
||||
* `http://localhost:5173`
|
||||
* `https://haumdaucher.de`
|
||||
* `https://haumdaucher.web.app` (Default Firebase Hosting URL)
|
||||
* **Authorized redirect URIs**:
|
||||
* `https://haumdaucher.firebaseapp.com/__/auth/handler`
|
||||
* Click **Create**.
|
||||
5. **Configure Secrets**:
|
||||
Run the helper script and paste the **Client ID** and **Client Secret** you just created:
|
||||
```bash
|
||||
./scripts/manage_secrets.py
|
||||
```
|
||||
6. **Verify**: Log in to the application. You should see the member banner.
|
||||
|
||||
## 🛠 Local Development Requirements
|
||||
|
||||
This project uses **Firebase** for authentication. To run the app locally, you need the environment variables (API keys) that are normally injected during deployment.
|
||||
|
||||
### 1. Generate Local Environment Config
|
||||
We typically manage infrastructure via Terraform. Use the included helper script to fetch the configuration from your active Terraform state and create a local `.env` file:
|
||||
|
||||
```bash
|
||||
npm run setup:env
|
||||
```
|
||||
|
||||
*Prerequisites: You must have `terraform` and `jq` installed, and `terraform apply` must have been run at least once.*
|
||||
|
||||
### 2. Start Dev Server
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 2. Infrastructure Deployment
|
||||
Navigate to the `terraform` directory and apply the configuration:
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
terraform init
|
||||
terraform apply -var="project_id=YOUR_PROJECT_ID" -var='allowed_users=["your_email@gmail.com"]'
|
||||
```
|
||||
|
||||
This will:
|
||||
- Enable Firebase, Firestore, and Identity APIs.
|
||||
- Create the Firestore Database.
|
||||
- Create the **Allowlist** in Firestore (`config/allowlist`).
|
||||
|
||||
### 3. Adding Friends
|
||||
To approve new users, simply update the `allowed_users` list in your `terraform.tfvars` (or CLI argument) and re-run `terraform apply`.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# **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*
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
|
||||
\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_ \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
NAMESPACE="haumdaucher"
|
||||
REGISTRY="registry.haumdaucher.de"
|
||||
IMAGE_BASE_NAME="haumdaucher-website"
|
||||
IMAGE_NAME="$REGISTRY/$IMAGE_BASE_NAME"
|
||||
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 -
|
||||
|
||||
# Build the docker image
|
||||
echo "📦 Building Docker image..."
|
||||
|
||||
# Try to fetch Firebase config from Terraform
|
||||
if [ -d "terraform" ]; then
|
||||
echo "🔍 Detected Terraform directory. Fetching Firebase config..."
|
||||
cd terraform
|
||||
TF_OUT=$(terraform output -json firebase_config 2>/dev/null)
|
||||
cd ..
|
||||
|
||||
if [ ! -z "$TF_OUT" ]; then
|
||||
echo "✅ Firebase config found."
|
||||
FIREBASE_ARGS=(
|
||||
--build-arg VITE_FIREBASE_API_KEY=$(echo $TF_OUT | jq -r .apiKey)
|
||||
--build-arg VITE_FIREBASE_AUTH_DOMAIN=$(echo $TF_OUT | jq -r .authDomain)
|
||||
--build-arg VITE_FIREBASE_PROJECT_ID=$(echo $TF_OUT | jq -r .projectId)
|
||||
--build-arg VITE_FIREBASE_STORAGE_BUCKET=$(echo $TF_OUT | jq -r .storageBucket)
|
||||
--build-arg VITE_FIREBASE_MESSAGING_SENDER_ID=$(echo $TF_OUT | jq -r .messagingSenderId)
|
||||
--build-arg VITE_FIREBASE_APP_ID=$(echo $TF_OUT | jq -r .appId)
|
||||
)
|
||||
else
|
||||
echo "⚠️ Terraform output 'firebase_config' not found. Ensure required env vars are set."
|
||||
fi
|
||||
fi
|
||||
|
||||
docker build --platform linux/amd64 -t $IMAGE_NAME:$TAG "${FIREBASE_ARGS[@]}" .
|
||||
|
||||
# Push the docker image
|
||||
echo "📤 Pushing Docker image to $REGISTRY..."
|
||||
docker push $IMAGE_NAME:$TAG
|
||||
|
||||
# Apply manifests
|
||||
echo "🎡 Applying Kubernetes manifests..."
|
||||
if [ -f "../../k8s/haumdaucher/haumdaucher.yaml" ]; then
|
||||
kubectl apply -f ../../k8s/haumdaucher/haumdaucher.yaml
|
||||
elif [ -f "k8s-manifests.yaml" ]; then
|
||||
kubectl apply -f k8s-manifests.yaml
|
||||
fi
|
||||
|
||||
# Force rollout restart to pick up the new 'latest' image
|
||||
echo "🔄 Restarting deployment to pull latest image..."
|
||||
kubectl rollout restart deployment/haumdaucher-website -n $NAMESPACE
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo "Check status with: kubectl get pods -n $NAMESPACE"
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Haumdaucher Regensburg</title>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: haumdaucher-website
|
||||
namespace: haumdaucher
|
||||
labels:
|
||||
app: haumdaucher
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: haumdaucher
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: haumdaucher
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-haumdaucher-de
|
||||
containers:
|
||||
- name: haumdaucher
|
||||
image: registry.haumdaucher.de/haumdaucher-website:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: haumdaucher-service
|
||||
namespace: haumdaucher
|
||||
spec:
|
||||
selector:
|
||||
app: haumdaucher
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: haumdaucher-ingress
|
||||
namespace: haumdaucher
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
kubernetes.io/ingress.class: nginx
|
||||
kubernetes.io/tls-acme: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||
nginx.ingress.kubernetes.io/proxy-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: haumdaucher.de
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: haumdaucher-service
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- hosts:
|
||||
- haumdaucher.de
|
||||
secretName: haumdaucher-de-tls
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"scriptId": "1pTpLk3kblp9xByrZ0rM-5gBU-5pZkbdKR25bzI89apFdU1RHPzBvzIl5",
|
||||
"rootDir": "src",
|
||||
"htmlExtensions": [
|
||||
".html"
|
||||
],
|
||||
"jsonExtensions": [
|
||||
".json"
|
||||
],
|
||||
"filePushOrder": [],
|
||||
"skipSubdirectories": false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document provides context for AI agents operating on the `mail_forwarding` infrastructure in this repository.
|
||||
|
||||
## Architecture
|
||||
This directory contains Google Apps Script code configured as Infrastructure as Code (IaC).
|
||||
It automates the creation of email forwarding in Google Workspace by reading from a Google Sheet and dynamically creating/managing Workspace Groups.
|
||||
|
||||
## Tooling
|
||||
- We use `@google/clasp` to manage the deployment of the `.js` files to Google Apps Script. Note: do NOT write TypeScript as we bypass the local transpilation step.
|
||||
- The entrypoint is `src/Code.js`.
|
||||
- The manifest is `src/appsscript.json`.
|
||||
|
||||
## Status
|
||||
- **Current Mode:** Active Production (`DRY_RUN: false`).
|
||||
- **Target:** All groups managed by this script are tagged with `[Auto-Forwarder] Managed by Google Sheets`.
|
||||
|
||||
## Rules & Safeguards (CRITICAL)
|
||||
1. **Never alter the `GROUP_DESCRIPTION_TAG` logic.**
|
||||
The script achieves declarative state management by finding and deleting Workspace Groups that are NOT present in the Google Sheet. To prevent the catastrophic deletion of real, human-managed groups (e.g., `board@haumdaucher.de`), the script relies on the `[Auto-Forwarder] Managed by Google Sheets` string in the group's description. The script must ALWAYS filter for this exact string before issuing any deletion API calls.
|
||||
2. **Always deploy via `clasp`.** Do not instruct the user to copy-paste code manually if `clasp` is available.
|
||||
3. **Trigger:** We use a combination of `onFormSubmit` (for Google Forms) and `onChange` (for manual sheet edits) triggers. This ensures reconciliation happens regardless of how the data was updated.
|
||||
4. **Validation Logic**: `forwardAddress` must always be internal (`@haumdaucher.de`), while `forwardToAddress` must always be external (non-haumdaucher) to prevent routing loops.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Mail Forwarding Automation
|
||||
|
||||
This directory contains an Infrastructure-as-Code (IaC) deployment for Google Apps Script.
|
||||
It automates the creation and synchronization of Google Workspace mail forwarding by reading from a Google Sheet (typically populated by Google Forms) and managing Workspace Groups.
|
||||
|
||||
## Prerequisites
|
||||
To deploy this code to your Google Workspace, you need the following installed on your machine:
|
||||
- [Node.js & npm](https://nodejs.org/)
|
||||
- Google Clasp CLI: Run `npm install -g @google/clasp`
|
||||
|
||||
## Step 1: Authentication & Setup
|
||||
Before you can deploy, you must authenticate your local machine with your Google Workspace Admin account and enable the Apps Script API.
|
||||
|
||||
1. **Enable the API:** Go to [https://script.google.com/home/usersettings](https://script.google.com/home/usersettings) and turn **ON** the "Google Apps Script API".
|
||||
2. **Login:** In your terminal, run:
|
||||
```bash
|
||||
clasp login
|
||||
```
|
||||
This will open a browser window. Sign in with your Workspace Admin account (`@haumdaucher.de`) and grant the necessary permissions.
|
||||
|
||||
## Step 2: Configuration
|
||||
You must configure the script to point to your specific Google Sheet.
|
||||
Open `src/Code.js` and modify the `CONFIG` block at the top of the file:
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
// 1. The ID of the Google Sheet (found in the URL: https://docs.google.com/spreadsheets/d/<THIS_ID>/edit)
|
||||
SPREADSHEET_ID: "YOUR_SHEET_ID_HERE",
|
||||
|
||||
// 2. The name of the tab at the bottom of the screen
|
||||
SHEET_NAME: "Form Responses 1",
|
||||
|
||||
// 3. The column numbers containing the data (1 = A, 2 = B, 3 = C, etc.)
|
||||
COL_FORWARD_ADDRESS: 6,
|
||||
COL_FORWARD_TO_ADDRESS: 4,
|
||||
|
||||
// 4. Your admin email for receiving reports
|
||||
ADMIN_EMAIL: "admin@haumdaucher.de",
|
||||
|
||||
// 5. Dry run mode. If true, script logs intended changes without modifying Workspace.
|
||||
DRY_RUN: true,
|
||||
|
||||
// ... leave the GROUP_DESCRIPTION_TAG untouched!
|
||||
};
|
||||
```
|
||||
|
||||
## Step 3: Deployment
|
||||
Once configured, you need to create an Apps Script project in your Google Account and push this code to it.
|
||||
|
||||
1. Navigate to this `mail_forwarding` directory in your terminal.
|
||||
2. Initialize the project as a standalone script:
|
||||
```bash
|
||||
clasp create --type standalone --title "Haumdaucher Mail Forwarding" --rootDir ./src
|
||||
```
|
||||
*(This creates a hidden `.clasp.json` file linking this directory to the cloud project).*
|
||||
3. Push the code:
|
||||
```bash
|
||||
clasp push
|
||||
```
|
||||
|
||||
> **⚠️ Important:** `clasp push` only uploads the code. It does **not** install or update the background triggers. You must always re-run the `setup` function in the Apps Script IDE after a push to ensure the triggers are active and up to date.
|
||||
|
||||
## Step 4: Initialization
|
||||
The code is now in the cloud, but the background triggers need to be activated and the Admin SDK authorized.
|
||||
|
||||
1. Open the project in your browser:
|
||||
```bash
|
||||
clasp open-script
|
||||
```
|
||||
2. **Ignore the large blue "Deploy" button.** You do *not* need to create a deployment. This script runs via background triggers, not as a web app.
|
||||
3. In the toolbar directly above the code editor, look for a dropdown menu showing function names (it might currently say `syncForwardings`). Click the dropdown and select `setup`.
|
||||
4. Click the **Run** button (the play icon) right next to the dropdown.
|
||||
5. **Authorization Required:** Google will prompt you to review permissions. *(Note: This interactive browser consent screen is a strict Google Workspace security requirement for scripts accessing the Admin API, which is why this specific step cannot be automated via CLI).*
|
||||
- Click "Review Permissions"
|
||||
- Choose your Admin account.
|
||||
- Click "Advanced" -> "Go to Haumdaucher Mail Forwarding (unsafe)"
|
||||
- Click "Allow" to grant access to the Admin Directory API, Gmail API, and Google Sheets.
|
||||
5. Once authorized, the `setup` function will finish executing. It installs the background `onChange` trigger.
|
||||
|
||||
## System Status: LIVE
|
||||
The system is currently configured for active production (`DRY_RUN: false`). Any changes to the Google Sheet will result in real-time creation, modification, or deletion of Google Workspace Groups.
|
||||
|
||||
To revert to dry-run mode for testing:
|
||||
1. Open `src/Code.js` and change `DRY_RUN: false` to `DRY_RUN: true`.
|
||||
2. Run `clasp push`.
|
||||
|
||||
**You are done!** Whenever a new response is submitted to the configured Google Sheet via Forms, the script will automatically run in the background, reconcile the forwarding groups, and send you an email report.
|
||||
|
||||
## Troubleshooting & Analysis
|
||||
If you find that the script is not triggering when a new form is submitted:
|
||||
|
||||
1. **Check Executions:** Run `clasp open-script` and click on the **Executions** tab (the list/bullet icon) in the left sidebar.
|
||||
- **No entries?** The trigger never fired. You may need to run the `setup()` function again to ensure the triggers are installed.
|
||||
- **Failed entries?** Click on the failed execution to see the exact error message and stack trace.
|
||||
2. **Re-Run Setup:** If you have pushed new code or changed your Google Account permissions, always re-run the `setup` function in the Apps Script UI to refresh the background triggers and authorization.
|
||||
3. **Trigger Source:** Ensure the Google Sheet you are using is the same one linked to your Google Form. The `onFormSubmit` trigger only fires on the specific sheet receiving the form responses.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "haumdaucher-mail-forwarding",
|
||||
"version": "1.0.0",
|
||||
"description": "Google Apps Script for automating mail forwarding via Workspace Groups",
|
||||
"scripts": {
|
||||
"login": "clasp login",
|
||||
"push": "clasp push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/clasp": "^2.4.2",
|
||||
"@types/google-apps-script": "^1.0.83"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* CONFIGURATION BLOCK
|
||||
* -------------------
|
||||
* Update these values before running `npm run push` (clasp push) to deploy
|
||||
* the script to your Google Workspace environment.
|
||||
*/
|
||||
const CONFIG = {
|
||||
// If the script is bound to a sheet (using `clasp clone <id>`), you can leave this empty.
|
||||
// If deployed as a standalone script, YOU MUST provide the exact Spreadsheet ID.
|
||||
SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q",
|
||||
|
||||
// The exact name of the tab containing the data (e.g., from Google Forms)
|
||||
SHEET_NAME: "Form Responses 1",
|
||||
|
||||
// Column indices (1-indexed).
|
||||
// Example: If Forward Address is Column D, index is 4. If Forward To is Column F, index is 6.
|
||||
COL_FORWARD_ADDRESS: 6,
|
||||
COL_FORWARD_TO_ADDRESS: 4,
|
||||
|
||||
// The admin email that should receive the execution reports
|
||||
ADMIN_EMAIL: "moritz@haumdaucher.de",
|
||||
|
||||
// The primary Workspace domain for validation and auto-appending
|
||||
WORKSPACE_DOMAIN: "haumdaucher.de",
|
||||
|
||||
// Dry run mode. If true, the script will only log what it would do and send the email,
|
||||
// but will NOT actually create, update, or delete any groups in Workspace.
|
||||
DRY_RUN: false,
|
||||
|
||||
// DO NOT CHANGE THIS VALUE.
|
||||
// This tag is added to the description of groups created by this script.
|
||||
// It acts as a safety guard to ensure the script NEVER deletes manually created groups.
|
||||
GROUP_DESCRIPTION_TAG: "[Auto-Forwarder] Managed by Google Sheets"
|
||||
};
|
||||
|
||||
/**
|
||||
* INSTALLATION:
|
||||
* Run this function ONCE manually from the Apps Script IDE after pushing.
|
||||
* It authorizes the script and installs the background trigger.
|
||||
*/
|
||||
function setup() {
|
||||
const ss = CONFIG.SPREADSHEET_ID
|
||||
? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
|
||||
: SpreadsheetApp.getActiveSpreadsheet();
|
||||
|
||||
if (!ss) {
|
||||
throw new Error("Could not find spreadsheet. Ensure you are bound to a sheet or have set CONFIG.SPREADSHEET_ID.");
|
||||
}
|
||||
|
||||
// Clean up any existing triggers to prevent duplicates
|
||||
const triggers = ScriptApp.getProjectTriggers();
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
if (triggers[i].getHandlerFunction() === 'syncForwardings') {
|
||||
ScriptApp.deleteTrigger(triggers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Install the onFormSubmit trigger (specifically for Google Forms submissions)
|
||||
ScriptApp.newTrigger('syncForwardings')
|
||||
.forSpreadsheet(ss)
|
||||
.onFormSubmit()
|
||||
.create();
|
||||
|
||||
// Also install an onChange trigger as a fallback (for manual sheet edits)
|
||||
ScriptApp.newTrigger('syncForwardings')
|
||||
.forSpreadsheet(ss)
|
||||
.onChange()
|
||||
.create();
|
||||
|
||||
// Verify and log the installed triggers so we can confirm the setup is correct
|
||||
const installedTriggers = ScriptApp.getProjectTriggers();
|
||||
console.log(`Setup complete. ${installedTriggers.length} trigger(s) are now active:`);
|
||||
for (const trigger of installedTriggers) {
|
||||
console.log(` - Function: ${trigger.getHandlerFunction()}, Event: ${trigger.getEventType()}, Source: ${trigger.getTriggerSource()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MAIN ENTRY POINT
|
||||
* This is triggered automatically whenever the spreadsheet changes (e.g., new form submission).
|
||||
*/
|
||||
function syncForwardings() {
|
||||
try {
|
||||
console.log("Starting syncForwardings...");
|
||||
if (CONFIG.DRY_RUN) {
|
||||
console.log("dry_run enabled, no changes will be applied.");
|
||||
} else {
|
||||
console.log("dry_run is false, actively performing changes.");
|
||||
}
|
||||
|
||||
const desiredState = readDesiredStateFromSheet();
|
||||
const currentState = readCurrentStateFromWorkspace();
|
||||
|
||||
const changelog = [];
|
||||
|
||||
// 1. Process Creations and Updates
|
||||
for (const forwardAddress in desiredState) {
|
||||
const forwardToAddresses = desiredState[forwardAddress];
|
||||
|
||||
if (!currentState[forwardAddress]) {
|
||||
createForwardingGroup(forwardAddress, forwardToAddresses, changelog);
|
||||
} else {
|
||||
updateForwardingGroup(forwardAddress, currentState[forwardAddress], forwardToAddresses, changelog);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process Deletions
|
||||
for (const groupEmail in currentState) {
|
||||
if (!desiredState[groupEmail]) {
|
||||
deleteForwardingGroup(groupEmail, changelog);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send Admin Report
|
||||
sendReport(desiredState, changelog);
|
||||
|
||||
console.log("Sync complete.");
|
||||
} catch (err) {
|
||||
console.error("Fatal error during syncForwardings:", err);
|
||||
MailApp.sendEmail({
|
||||
to: CONFIG.ADMIN_EMAIL,
|
||||
subject: "[ERROR] Mail Forwarding Sync Failed",
|
||||
body: "The mail forwarding automation encountered a fatal error:\n\n" + err.stack + "\n\nMessage: " + err.message
|
||||
});
|
||||
// Re-throw the error so Apps Script dashboard shows "Failed"
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Google Sheet and builds a map of Source -> [Destinations]
|
||||
*/
|
||||
function readDesiredStateFromSheet() {
|
||||
const ss = CONFIG.SPREADSHEET_ID
|
||||
? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
|
||||
: SpreadsheetApp.getActiveSpreadsheet();
|
||||
|
||||
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
|
||||
if (!sheet) {
|
||||
throw new Error(`Sheet '${CONFIG.SHEET_NAME}' not found.`);
|
||||
}
|
||||
|
||||
const data = sheet.getDataRange().getValues();
|
||||
const desiredState = {};
|
||||
|
||||
// Regex for basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// Start at i=1 to skip the header row
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
let forwardAddress = (row[CONFIG.COL_FORWARD_ADDRESS - 1] || "").toString().trim().toLowerCase();
|
||||
let forwardToAddress = (row[CONFIG.COL_FORWARD_TO_ADDRESS - 1] || "").toString().trim().toLowerCase();
|
||||
|
||||
// Ignore completely empty rows
|
||||
if (!forwardAddress && !forwardToAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Auto-append domain to forwardAddress if they just typed a name (e.g. "frederic")
|
||||
if (forwardAddress && !forwardAddress.includes("@")) {
|
||||
forwardAddress += `@${CONFIG.WORKSPACE_DOMAIN}`;
|
||||
}
|
||||
|
||||
// 2. Validate forwardAddress domain (MUST be internal)
|
||||
if (forwardAddress && !forwardAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
|
||||
console.warn(`Row ${i + 1}: Skipped. Forward address must belong to @${CONFIG.WORKSPACE_DOMAIN} domain. Found: '${forwardAddress}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Validate forwardAddress is a valid email
|
||||
if (forwardAddress && !emailRegex.test(forwardAddress)) {
|
||||
console.warn(`Row ${i + 1}: Skipped. Invalid forward address format: '${forwardAddress}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Validate forwardToAddress domain (MUST NOT be internal)
|
||||
if (forwardToAddress && forwardToAddress.endsWith(`@${CONFIG.WORKSPACE_DOMAIN}`)) {
|
||||
console.warn(`Row ${i + 1}: Skipped. Destination address cannot be an internal @${CONFIG.WORKSPACE_DOMAIN} address. Found: '${forwardToAddress}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Validate forwardToAddress is a valid email
|
||||
if (forwardToAddress && !emailRegex.test(forwardToAddress)) {
|
||||
console.warn(`Row ${i + 1}: Skipped. Invalid destination email format: '${forwardToAddress}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (forwardAddress && forwardToAddress) {
|
||||
console.log(`Parsed Row ${i + 1}: Valid mapping [${forwardAddress} -> ${forwardToAddress}]`);
|
||||
if (!desiredState[forwardAddress]) {
|
||||
desiredState[forwardAddress] = [];
|
||||
}
|
||||
// Ensure we don't add duplicate destinations for the same source
|
||||
if (desiredState[forwardAddress].indexOf(forwardToAddress) === -1) {
|
||||
desiredState[forwardAddress].push(forwardToAddress);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Row ${i + 1}: Skipped. Missing either forward address or destination (Forward: '${forwardAddress}', To: '${forwardToAddress}')`);
|
||||
}
|
||||
}
|
||||
|
||||
return desiredState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the Admin Directory API for existing Groups managed by this script
|
||||
*/
|
||||
function readCurrentStateFromWorkspace() {
|
||||
const currentState = {};
|
||||
|
||||
// customer: 'my_customer' is a special alias for the primary domain
|
||||
let pageToken;
|
||||
do {
|
||||
const response = AdminDirectory.Groups.list({
|
||||
customer: 'my_customer',
|
||||
pageToken: pageToken
|
||||
});
|
||||
|
||||
const groups = response.groups || [];
|
||||
for (const group of groups) {
|
||||
// ONLY process groups that have our specific safety tag
|
||||
if (group.description && group.description.indexOf(CONFIG.GROUP_DESCRIPTION_TAG) !== -1) {
|
||||
const email = group.email.toLowerCase();
|
||||
currentState[email] = getGroupMembers(email);
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all members for a specific group
|
||||
*/
|
||||
function getGroupMembers(groupEmail) {
|
||||
const members = [];
|
||||
let pageToken;
|
||||
|
||||
do {
|
||||
const response = AdminDirectory.Members.list(groupEmail, {
|
||||
pageToken: pageToken
|
||||
});
|
||||
|
||||
const memberList = response.members || [];
|
||||
for (const member of memberList) {
|
||||
if (member.email) {
|
||||
members.push(member.email.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = response.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
function logAction(changelog, message) {
|
||||
console.log(message);
|
||||
changelog.push(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Workspace Group to act as a forwarder
|
||||
*/
|
||||
function createForwardingGroup(forwardAddress, forwardToAddresses, changelog) {
|
||||
if (CONFIG.DRY_RUN) {
|
||||
logAction(changelog, `[DRY RUN - CREATED] Group: ${forwardAddress}`);
|
||||
for (const forwardTo of forwardToAddresses) {
|
||||
logAction(changelog, ` + [DRY RUN] Added member: ${forwardTo}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newGroup = {
|
||||
email: forwardAddress,
|
||||
name: `Auto-Forwarder: ${forwardAddress}`,
|
||||
description: CONFIG.GROUP_DESCRIPTION_TAG
|
||||
};
|
||||
|
||||
AdminDirectory.Groups.insert(newGroup);
|
||||
logAction(changelog, `[CREATED] Group: ${forwardAddress}`);
|
||||
|
||||
// Propagation delay: Google Workspace APIs are eventually consistent.
|
||||
// We must wait a few seconds after creation before we can add members to the new group.
|
||||
Utilities.sleep(3000);
|
||||
|
||||
for (const forwardTo of forwardToAddresses) {
|
||||
AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
|
||||
logAction(changelog, ` + Added member: ${forwardTo}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logAction(changelog, `[ERROR] Failed to create group ${forwardAddress}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles members of an existing forwarding group
|
||||
*/
|
||||
function updateForwardingGroup(forwardAddress, currentMembers, desiredMembers, changelog) {
|
||||
// 1. Add missing members
|
||||
for (const forwardTo of desiredMembers) {
|
||||
if (currentMembers.indexOf(forwardTo) === -1) {
|
||||
if (CONFIG.DRY_RUN) {
|
||||
logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`);
|
||||
} else {
|
||||
try {
|
||||
AdminDirectory.Members.insert({ email: forwardTo, role: 'MEMBER' }, forwardAddress);
|
||||
logAction(changelog, `[UPDATED] Group ${forwardAddress}: Added member ${forwardTo}`);
|
||||
} catch (e) {
|
||||
logAction(changelog, `[ERROR] Failed to add member ${forwardTo} to ${forwardAddress}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove obsolete members
|
||||
for (const current of currentMembers) {
|
||||
if (desiredMembers.indexOf(current) === -1) {
|
||||
if (CONFIG.DRY_RUN) {
|
||||
logAction(changelog, `[DRY RUN - UPDATED] Group ${forwardAddress}: Removed member ${current}`);
|
||||
} else {
|
||||
try {
|
||||
AdminDirectory.Members.remove(forwardAddress, current);
|
||||
logAction(changelog, `[UPDATED] Group ${forwardAddress}: Removed member ${current}`);
|
||||
} catch (e) {
|
||||
logAction(changelog, `[ERROR] Failed to remove member ${current} from ${forwardAddress}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a forwarding group (Requires the safety tag check performed during read)
|
||||
*/
|
||||
function deleteForwardingGroup(groupEmail, changelog) {
|
||||
if (CONFIG.DRY_RUN) {
|
||||
logAction(changelog, `[DRY RUN - DELETED] Group: ${groupEmail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AdminDirectory.Groups.remove(groupEmail);
|
||||
logAction(changelog, `[DELETED] Group: ${groupEmail}`);
|
||||
} catch (e) {
|
||||
logAction(changelog, `[ERROR] Failed to delete group ${groupEmail}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a summary report to the configured Admin Email
|
||||
*/
|
||||
function sendReport(desiredState, changelog) {
|
||||
if (changelog.length === 0) {
|
||||
console.log("No changes detected. Skipping email report.");
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = "[Haumdaucher] Mail Forwarding Sync Report";
|
||||
|
||||
let body = "The mail forwarding automation has executed successfully.\n\n";
|
||||
|
||||
body += "=== CHANGELOG ===\n";
|
||||
body += changelog.join("\n") + "\n\n";
|
||||
|
||||
body += "=== ACTIVE FORWARDINGS ===\n";
|
||||
const sources = Object.keys(desiredState).sort();
|
||||
if (sources.length === 0) {
|
||||
body += "No active forwardings configured in the sheet.\n";
|
||||
} else {
|
||||
for (const source of sources) {
|
||||
body += `${source} -> ${desiredState[source].join(", ")}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
MailApp.sendEmail({
|
||||
to: CONFIG.ADMIN_EMAIL,
|
||||
subject: subject,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"timeZone": "Europe/Berlin",
|
||||
"dependencies": {
|
||||
"enabledAdvancedServices": [
|
||||
{
|
||||
"userSymbol": "AdminDirectory",
|
||||
"serviceId": "admin",
|
||||
"version": "directory_v1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"oauthScopes": [
|
||||
"https://www.googleapis.com/auth/admin.directory.group",
|
||||
"https://www.googleapis.com/auth/script.send_mail",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/script.scriptapp"
|
||||
],
|
||||
"runtimeVersion": "V8"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "haumdaucher_de",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"setup:env": "./scripts/setup-local-env.sh",
|
||||
"test": "vitest",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase": "^12.7.0",
|
||||
"vue": "^3.3.4",
|
||||
"vuefire": "^3.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.27",
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"happy-dom": "^20.0.11",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^4.5.3",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.0.16",
|
||||
"vue-tsc": "^1.8.8"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 695 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 451 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
|
||||
# --- Configuration ---
|
||||
PROJECT_ID = "haumdaucher"
|
||||
SECRETS = {
|
||||
"haumdaucher-oauth-client-id": {
|
||||
"desc": "OAuth 2.0 Client ID",
|
||||
"instructions": (
|
||||
"1. Go to https://console.cloud.google.com/apis/credentials?project=haumdaucher\n"
|
||||
"2. Look for 'OAuth 2.0 Client IDs' -> 'Haumdaucher Web' (or create one if missing).\n"
|
||||
"3. Copy the 'Client ID' (looks like: 12345...apps.googleusercontent.com)"
|
||||
),
|
||||
"validation_hint": "Must end with .apps.googleusercontent.com"
|
||||
},
|
||||
"haumdaucher-oauth-client-secret": {
|
||||
"desc": "OAuth 2.0 Client Secret",
|
||||
"instructions": (
|
||||
"1. On the same credentials page, click the edit icon (pencil) or the name of the client.\n"
|
||||
"2. On the right side, find 'Client secret'.\n"
|
||||
"3. Copy the string (it is hidden by default)."
|
||||
),
|
||||
"validation_hint": "Usually a random string of characters."
|
||||
}
|
||||
}
|
||||
|
||||
# --- Helpers ---
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
OKBLUE = '\033[94m'
|
||||
OKGREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
ENDC = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
def print_header(msg):
|
||||
print(f"\n{Colors.HEADER}{Colors.BOLD}=== {msg} ==={Colors.ENDC}")
|
||||
|
||||
def print_step(msg):
|
||||
print(f"\n{Colors.OKBLUE}👉 {msg}{Colors.ENDC}")
|
||||
|
||||
def print_success(msg):
|
||||
print(f"{Colors.OKGREEN}✅ {msg}{Colors.ENDC}")
|
||||
|
||||
def print_error(msg):
|
||||
print(f"{Colors.FAIL}❌ {msg}{Colors.ENDC}")
|
||||
|
||||
def run_command(command, check=True):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
check=check,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
if check:
|
||||
print_error(f"Command failed: {command}")
|
||||
print(e.stderr)
|
||||
sys.exit(1)
|
||||
return None
|
||||
|
||||
def verify_gcloud_login():
|
||||
"""Ensures user is logged in to gcloud."""
|
||||
account = run_command("gcloud config get-value account", check=False)
|
||||
if not account or account == "(unset)":
|
||||
print_error("You are not logged in to gcloud.")
|
||||
print(" Run: gcloud auth login")
|
||||
sys.exit(1)
|
||||
|
||||
project = run_command("gcloud config get-value project", check=False)
|
||||
if project != PROJECT_ID:
|
||||
print(f"{Colors.WARNING}⚠️ Current gcloud project is '{project}', but this script targets '{PROJECT_ID}'.{Colors.ENDC}")
|
||||
confirm = input(" Continue anyway? (y/n): ")
|
||||
if confirm.lower() != 'y':
|
||||
sys.exit(1)
|
||||
|
||||
def enable_api():
|
||||
"""Ensures Secret Manager API is enabled."""
|
||||
print("🔍 Checking API status...")
|
||||
run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
|
||||
|
||||
def get_secret_status(secret_id):
|
||||
"""Returns 'MISSING', 'EMPTY' (no versions), or 'READY'."""
|
||||
exists_cmd = f"gcloud secrets describe {secret_id} --project={PROJECT_ID} --format=json"
|
||||
meta = run_command(exists_cmd, check=False)
|
||||
|
||||
if not meta:
|
||||
return "MISSING"
|
||||
|
||||
# Check for versions
|
||||
versions_cmd = f"gcloud secrets versions list {secret_id} --project={PROJECT_ID} --limit=1 --filter='state=ENABLED' --format=json"
|
||||
versions = run_command(versions_cmd, check=False)
|
||||
|
||||
if not versions or versions == "[]":
|
||||
return "EMPTY"
|
||||
|
||||
return "READY"
|
||||
|
||||
def create_secret_resource(secret_id):
|
||||
print(f" creating secret container '{secret_id}'...")
|
||||
run_command(f"gcloud secrets create {secret_id} --replication-policy=automatic --project={PROJECT_ID}")
|
||||
|
||||
def add_secret_version(secret_name, value):
|
||||
process = subprocess.Popen(
|
||||
f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}",
|
||||
shell=True,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
stdout, stderr = process.communicate(input=value)
|
||||
if process.returncode != 0:
|
||||
print_error(f"Failed to add version: {stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
def cleanup_old_versions(secret_name):
|
||||
cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --filter='state!=DESTROYED' --format=json"
|
||||
output = run_command(cmd, check=False)
|
||||
if not output:
|
||||
return
|
||||
|
||||
versions = json.loads(output)
|
||||
if len(versions) <= 1:
|
||||
return
|
||||
|
||||
# Keep latest enabled
|
||||
# The API returns sorted list usually, but let's be safe: assume index 0 is latest
|
||||
# Actually, we should just keep the one we just made.
|
||||
|
||||
print(f" 🧹 Cleaning up old versions for clean state...")
|
||||
# Skip the first one
|
||||
for v in versions[1:]:
|
||||
version_id = v['name'].split('/')[-1]
|
||||
run_command(f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet", check=False)
|
||||
|
||||
|
||||
# --- Main Logic ---
|
||||
|
||||
def main():
|
||||
print_header("Haumdaucher Secret Manager Setup")
|
||||
verify_gcloud_login()
|
||||
# enable_api() # Managed via Terraform now
|
||||
|
||||
# Main Loop ensuring state
|
||||
while True:
|
||||
all_ready = True
|
||||
|
||||
# 1. Audit State
|
||||
print_header("Current Status Audit")
|
||||
status_map = {}
|
||||
for secret_id in SECRETS.keys():
|
||||
status = get_secret_status(secret_id)
|
||||
status_map[secret_id] = status
|
||||
|
||||
icon = "✅" if status == "READY" else "❌"
|
||||
msg = f"{secret_id}: {status}"
|
||||
if status == "READY":
|
||||
print(f"{Colors.OKGREEN}{icon} {msg}{Colors.ENDC}")
|
||||
else:
|
||||
print(f"{Colors.FAIL}{icon} {msg}{Colors.ENDC}")
|
||||
all_ready = False
|
||||
|
||||
if all_ready:
|
||||
print_success("All secrets are configured correctly!")
|
||||
break
|
||||
|
||||
# 2. Fix Missing Items
|
||||
print_header("Action Required")
|
||||
|
||||
for secret_id, config in SECRETS.items():
|
||||
status = status_map[secret_id]
|
||||
if status == "READY":
|
||||
continue
|
||||
|
||||
print_step(f"Configuring {config['desc']} ({secret_id})")
|
||||
|
||||
if status == "MISSING":
|
||||
create_secret_resource(secret_id)
|
||||
|
||||
print(f"{Colors.WARNING}INSTRUCTIONS:{Colors.ENDC}")
|
||||
print(config['instructions'])
|
||||
print("")
|
||||
|
||||
import getpass
|
||||
value = getpass.getpass(prompt=f"{Colors.BOLD}Paste {config['desc']} here (hidden): {Colors.ENDC}")
|
||||
|
||||
if not value.strip():
|
||||
print_error("Empty value provided. Retrying audit...")
|
||||
continue
|
||||
|
||||
add_secret_version(secret_id, value)
|
||||
cleanup_old_versions(secret_id)
|
||||
print_success("Secret updated.")
|
||||
|
||||
print("\n🔄 Re-verifying state...")
|
||||
# Loop continues to verify
|
||||
|
||||
print_header("Next Steps")
|
||||
print("1. Infrastructure is ready.")
|
||||
print("2. Run Terraform to apply the changes:")
|
||||
print(f"\n {Colors.OKBLUE}cd terraform && terraform apply{Colors.ENDC}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Aborted by user.")
|
||||
sys.exit(130)
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
|
||||
# setup-local-env.sh
|
||||
# Fetches Firebase configuration from Terraform outputs and creates a local .env file.
|
||||
|
||||
# Check if terraform is installed
|
||||
if ! command -v terraform &> /dev/null; then
|
||||
echo "❌ Error: terraform is not installed or not in PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ Error: jq is not installed. Please install jq to parse JSON output."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TERRAFORM_DIR="terraform"
|
||||
ENV_FILE=".env"
|
||||
|
||||
echo "🔍 Fetching Firebase configuration from Terraform..."
|
||||
|
||||
if [ ! -d "$TERRAFORM_DIR" ]; then
|
||||
echo "❌ Error: Terraform directory '$TERRAFORM_DIR' not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$TERRAFORM_DIR" || exit
|
||||
|
||||
# Check if Terraform is initialized
|
||||
if [ ! -d ".terraform" ]; then
|
||||
echo "⚠️ Terraform not initialized. Initializing..."
|
||||
terraform init
|
||||
fi
|
||||
|
||||
# Fetch output in JSON format
|
||||
TF_OUT=$(terraform output -json firebase_config 2>/dev/null)
|
||||
cd ..
|
||||
|
||||
if [ -z "$TF_OUT" ]; then
|
||||
echo "❌ Error: Could not fetch 'firebase_config' from Terraform output."
|
||||
echo " Ensure 'terraform apply' has been run successfully."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📝 Writing to $ENV_FILE..."
|
||||
|
||||
# Parse JSON and write to .env
|
||||
# We use a heredoc to write the file content
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
VITE_FIREBASE_API_KEY=$(echo $TF_OUT | jq -r .apiKey)
|
||||
VITE_FIREBASE_AUTH_DOMAIN=$(echo $TF_OUT | jq -r .authDomain)
|
||||
VITE_FIREBASE_PROJECT_ID=$(echo $TF_OUT | jq -r .projectId)
|
||||
VITE_FIREBASE_STORAGE_BUCKET=$(echo $TF_OUT | jq -r .storageBucket)
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=$(echo $TF_OUT | jq -r .messagingSenderId)
|
||||
VITE_FIREBASE_APP_ID=$(echo $TF_OUT | jq -r .appId)
|
||||
EOF
|
||||
|
||||
echo "✅ Success! .env file created."
|
||||
cat "$ENV_FILE"
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } 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)
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders header text', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.text()).toContain('HAUMDAUCHER')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
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 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 showGame = 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
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
localStorage.setItem('haumdaucher-theme', newTheme)
|
||||
|
||||
if (newTheme === 'nat') {
|
||||
startBoarRun()
|
||||
}
|
||||
}
|
||||
|
||||
const unlockNat = () => {
|
||||
isNatUnlocked.value = true
|
||||
localStorage.setItem('haumdaucher-nat-unlocked', 'true')
|
||||
}
|
||||
|
||||
const handleBackdoor = () => {
|
||||
const secret = prompt('Bitte Geheimcode eingeben (Unlock NAT):')
|
||||
if (secret?.toLowerCase() === 'nat mode') {
|
||||
unlockNat()
|
||||
alert('🐗 NAT-Modus freigschaltet! Wiedaschaun, reinghaun!')
|
||||
}
|
||||
}
|
||||
|
||||
const startBoarRun = () => {
|
||||
setInterval(() => {
|
||||
if (theme.value === 'nat') {
|
||||
const id = Date.now()
|
||||
boars.value.push({ id, top: Math.random() * 80 + 10 })
|
||||
setTimeout(() => {
|
||||
boars.value = boars.value.filter(b => b.id !== id)
|
||||
}, 5000)
|
||||
}
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
if (urlParams.get('mode') === 'resetPassword') {
|
||||
isPasswordResetMode.value = true
|
||||
resetOobCode.value = urlParams.get('oobCode') || ''
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('haumdaucher-theme')
|
||||
if (savedTheme) toggleTheme(savedTheme)
|
||||
|
||||
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
|
||||
for (const k of keys) {
|
||||
if (result[k]) result = result[k]
|
||||
else return key
|
||||
}
|
||||
return result
|
||||
}
|
||||
</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"
|
||||
:isNatUnlocked="isNatUnlocked"
|
||||
@update:theme="toggleTheme"
|
||||
@open:game="showGame = true"
|
||||
:t="t"
|
||||
/>
|
||||
<main>
|
||||
<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>
|
||||
|
||||
<div class="backdoor" @click.stop="handleBackdoor"></div>
|
||||
</main>
|
||||
|
||||
<HaumdaucherGame
|
||||
v-if="showGame"
|
||||
:t="t"
|
||||
:theme="theme"
|
||||
@close="showGame = false"
|
||||
@unlock-nat="unlockNat"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style>
|
||||
@import './assets/styles/global.css';
|
||||
|
||||
.running-boar {
|
||||
position: fixed;
|
||||
left: -100px;
|
||||
font-size: 3rem;
|
||||
z-index: 1500;
|
||||
animation: run-across 5s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes run-across {
|
||||
0% { left: -100px; }
|
||||
100% { left: 100vw; }
|
||||
}
|
||||
|
||||
.backdoor {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
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>
|
||||
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 695 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 451 KiB |
@@ -0,0 +1,126 @@
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #000000;
|
||||
--primary-color: #000000;
|
||||
--accent-color: #333333;
|
||||
--header-bg: rgba(255, 255, 255, 0.85);
|
||||
--glass-border: rgba(0, 0, 0, 0.1);
|
||||
--font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--transition-speed: 0.3s;
|
||||
--safe-area-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Mobile-first: Base styles are for mobile */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden !important;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-family);
|
||||
transition: background-color var(--transition-speed), color var(--transition-speed);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1000px; /* Centered content on desktop */
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* App-like Sections */
|
||||
section {
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
/* Dynamic viewport height for mobile */
|
||||
padding: 80px 0 calc(80px + var(--safe-area-bottom));
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fancy-glass {
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.alternate-bg {
|
||||
padding: 30px 10px;
|
||||
margin: 0 10px;
|
||||
border-radius: 20px;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Theme Overrides */
|
||||
[data-theme='dark'] {
|
||||
--bg-color: #0f0f0f;
|
||||
--text-color: #f0f0f0;
|
||||
--primary-color: #d4af37;
|
||||
--accent-color: #ffffff;
|
||||
--header-bg: rgba(15, 15, 15, 0.85);
|
||||
--glass-border: rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
[data-theme='nat'] {
|
||||
--bg-color: #1a2f1a;
|
||||
--text-color: #d4c4a8;
|
||||
--primary-color: #4a3728;
|
||||
--accent-color: #8b5a2b;
|
||||
--header-bg: rgba(26, 47, 26, 0.9);
|
||||
--glass-border: #2e4d2e;
|
||||
--font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
[data-theme='nat'] .fancy-glass {
|
||||
border: 3px solid #4a3728;
|
||||
background-image: url('https://www.transparenttextures.com/patterns/dark-matter.png');
|
||||
}
|
||||
|
||||
/* Desktop Polish */
|
||||
@media (min-width: 768px) {
|
||||
section {
|
||||
padding: 100px 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="gatekeeper">
|
||||
<div v-if="isLoading">
|
||||
<p class="animate-pulse">Loading permissions...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!user" class="text-center p-8 glass-panel rounded-xl">
|
||||
<h2 class="text-2xl font-bold mb-4">🔐 Locked Area</h2>
|
||||
<p class="mb-6">Sign in with your Google account to access this content.</p>
|
||||
<button @click="login" class="btn btn-primary">
|
||||
Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isAllowed" class="text-center p-8 glass-panel rounded-xl border border-red-500/30">
|
||||
<h2 class="text-2xl font-bold mb-4 text-red-400">🚫 Access Pending</h2>
|
||||
<p class="mb-6">
|
||||
Hi <strong>{{ user.displayName }}</strong>. Your account ({{ user.email }}) is not yet on the guest list.
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
Please ask the admin to add "<strong>{{ user.email }}</strong>" to the allowlist.
|
||||
</p>
|
||||
<button @click="logout" class="btn btn-sm btn-ghost mt-4">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
const { user, isAllowed, isLoading, login, logout } = useAuth()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { confirmPasswordReset, verifyPasswordResetCode } from 'firebase/auth'
|
||||
import { auth } from '../../firebase'
|
||||
|
||||
const props = defineProps<{
|
||||
oobCode: string
|
||||
t: (key: string) => any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const handleReset = async () => {
|
||||
error.value = ''
|
||||
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'Die Passwörter stimmen nicht überein.'
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.value.length < 6) {
|
||||
error.value = 'Das Passwort muss mindestens 6 Zeichen lang sein.'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Optional: First verify the code is valid
|
||||
const email = await verifyPasswordResetCode(auth, props.oobCode)
|
||||
|
||||
// Then confirm the reset
|
||||
await confirmPasswordReset(auth, props.oobCode, newPassword.value)
|
||||
success.value = true
|
||||
} catch (err: any) {
|
||||
console.error('Password reset error:', err)
|
||||
if (err.code === 'auth/invalid-action-code') {
|
||||
error.value = 'Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.'
|
||||
} else {
|
||||
error.value = 'Ein Fehler ist aufgetreten: ' + err.message
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reset-container">
|
||||
<div class="glass-panel auth-card">
|
||||
<h2 class="section-title">Neues Passwort 🌭</h2>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
<h3>Sauber!</h3>
|
||||
<p>Dein Passwort wurde erfolgreich geändert.</p>
|
||||
<button class="club-btn" @click="emit('close')">Zurück zur Startseite</button>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="handleReset" class="auth-form">
|
||||
<p class="description">Bitte gib dein neues geheimes Haumdaucher-Passwort ein.</p>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="newPassword"
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
required
|
||||
class="club-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="confirmPassword"
|
||||
placeholder="Nochmal zur Sicherheit"
|
||||
required
|
||||
class="club-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-alert">{{ error }}</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="club-btn text-btn" @click="emit('close')">Abbrechen</button>
|
||||
<button type="submit" class="club-btn" :disabled="isLoading">
|
||||
{{ isLoading ? 'Speichern...' : 'Passwort ändern' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reset-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.club-input {
|
||||
padding: 12px 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.club-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actions .club-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
}
|
||||
|
||||
.text-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
background: rgba(255, 75, 75, 0.1);
|
||||
color: #ff4b4b;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(255, 75, 75, 0.3);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.success-message h3 {
|
||||
color: #4caf50;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
t: (key: string) => string
|
||||
theme: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['close', 'unlock-nat'])
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const gameState = ref<'start' | 'playing' | 'gameOver' | 'win'>('start')
|
||||
const score = ref(0)
|
||||
const level = ref(1)
|
||||
const TOTAL_LEVELS = 10
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let animationId: number
|
||||
let playerX = 180
|
||||
const playerY = 500
|
||||
const playerWidth = 40
|
||||
const playerHeight = 40
|
||||
|
||||
// Theme-based configurations
|
||||
const themeConfig = computed(() => {
|
||||
switch (props.theme) {
|
||||
case 'unicorn':
|
||||
return {
|
||||
bg: '#fff0fb',
|
||||
player: '🦄',
|
||||
obstacles: { trash: '🍬', boat: '🍭', boar: '🧁' },
|
||||
waves: 'rgba(255, 105, 180, 0.3)'
|
||||
}
|
||||
case 'luxury':
|
||||
return {
|
||||
bg: '#0a0a0a',
|
||||
player: '👑',
|
||||
obstacles: { trash: '💎', boat: '💍', boar: '💰' },
|
||||
waves: 'rgba(212, 175, 55, 0.4)'
|
||||
}
|
||||
case 'win95':
|
||||
return {
|
||||
bg: '#c0c0c0',
|
||||
player: '🖱️',
|
||||
obstacles: { trash: '🗑️', boat: '📁', boar: '💣' },
|
||||
waves: 'rgba(0,0,0,0.1)'
|
||||
}
|
||||
case 'nat':
|
||||
return {
|
||||
bg: '#1a2f1a',
|
||||
player: '🐗',
|
||||
obstacles: { trash: '🪵', boat: '🛶', boar: '🦢' },
|
||||
waves: 'rgba(255,255,255,0.1)'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bg: '#1e3a8a',
|
||||
player: '🦢',
|
||||
obstacles: { trash: '🗑️', boat: '🚤', boar: '🐗' },
|
||||
waves: 'rgba(255,255,255,0.2)'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
interface Obstacle {
|
||||
x: number
|
||||
y: number
|
||||
type: 'trash' | 'boat' | 'boar'
|
||||
speed: number
|
||||
}
|
||||
|
||||
const obstacles = ref<Obstacle[]>([])
|
||||
const keys = ref<Record<string, boolean>>({})
|
||||
|
||||
// Touch handling
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (gameState.value !== 'playing') return
|
||||
const touchX = e.touches[0].clientX
|
||||
const canvasRect = canvas.value?.getBoundingClientRect()
|
||||
if (canvasRect) {
|
||||
playerX = Math.max(0, Math.min(360, (touchX - canvasRect.left) * (400 / canvasRect.width) - (playerWidth / 2)))
|
||||
}
|
||||
}
|
||||
|
||||
const initGame = () => {
|
||||
gameState.value = 'playing'
|
||||
score.value = 0
|
||||
level.value = 1
|
||||
obstacles.value = []
|
||||
playerX = 180
|
||||
gameLoop()
|
||||
}
|
||||
|
||||
const gameLoop = () => {
|
||||
if (gameState.value !== 'playing') return
|
||||
update()
|
||||
draw()
|
||||
animationId = requestAnimationFrame(gameLoop)
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
// Move player (Keyboard)
|
||||
const speed = 7 // Made slightly faster for easier control
|
||||
if (keys.value['ArrowLeft'] && playerX > 0) playerX -= speed
|
||||
if (keys.value['ArrowRight'] && playerX < 360) playerX += speed
|
||||
|
||||
// Spawn obstacles - Adjusted frequency for better balance
|
||||
const spawnChance = 0.01 + level.value * 0.002
|
||||
if (Math.random() < spawnChance) {
|
||||
const type = level.value === TOTAL_LEVELS ? 'boar' : (Math.random() > 0.6 ? 'trash' : 'boat')
|
||||
obstacles.value.push({
|
||||
x: Math.random() * 370,
|
||||
y: -50,
|
||||
type: type,
|
||||
speed: 1.5 + level.value * 0.45 // Slightly slower for level 1 but scales up
|
||||
})
|
||||
}
|
||||
|
||||
// Move and filter obstacles
|
||||
obstacles.value.forEach(obs => {
|
||||
obs.y += obs.speed
|
||||
|
||||
// Collision detection (with a bit of buffer for better feel)
|
||||
const buffer = 10
|
||||
if (
|
||||
playerX + buffer < obs.x + 30 &&
|
||||
playerX + playerWidth - buffer > obs.x &&
|
||||
playerY + buffer < obs.y + 30 &&
|
||||
playerY + playerHeight - buffer > obs.y
|
||||
) {
|
||||
gameState.value = 'gameOver'
|
||||
}
|
||||
})
|
||||
|
||||
obstacles.value = obstacles.value.filter(obs => obs.y < 650)
|
||||
|
||||
// Score and level up
|
||||
score.value += 1
|
||||
if (score.value % 500 === 0 && level.value < TOTAL_LEVELS) {
|
||||
level.value += 1
|
||||
}
|
||||
|
||||
// Win condition
|
||||
if (score.value >= TOTAL_LEVELS * 500 + 500) {
|
||||
gameState.value = 'win'
|
||||
emit('unlock-nat')
|
||||
}
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
if (!ctx) return
|
||||
ctx.clearRect(0, 0, 400, 600)
|
||||
|
||||
// Draw Background
|
||||
ctx.fillStyle = themeConfig.value.bg
|
||||
ctx.fillRect(0, 0, 400, 600)
|
||||
|
||||
// Draw Waves
|
||||
ctx.strokeStyle = themeConfig.value.waves
|
||||
ctx.lineWidth = 2
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = (Date.now() / 20 + i * 120) % 650
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.bezierCurveTo(100, y - 10, 300, y + 10, 400, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw Player
|
||||
ctx.font = '40px Arial'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(themeConfig.value.player, playerX, playerY)
|
||||
|
||||
// Draw Obstacles
|
||||
obstacles.value.forEach(obs => {
|
||||
const emoji = themeConfig.value.obstacles[obs.type]
|
||||
ctx.fillText(emoji, obs.x, obs.y)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => keys.value[e.key] = true
|
||||
const handleKeyup = (e: KeyboardEvent) => keys.value[e.key] = false
|
||||
|
||||
onMounted(() => {
|
||||
if (canvas.value) {
|
||||
ctx = canvas.value.getContext('2d')
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('keyup', handleKeyup)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId)
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('keyup', handleKeyup)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="game-overlay fancy-glass">
|
||||
<div class="game-window">
|
||||
<div class="game-header">
|
||||
<span>{{ t('game.title') }}</span>
|
||||
<button @click="emit('close')">X</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
width="400"
|
||||
height="600"
|
||||
@touchmove.prevent="handleTouchMove"
|
||||
></canvas>
|
||||
|
||||
<div v-if="gameState === 'start'" class="overlay-content">
|
||||
<h2>{{ t('game.title') }}</h2>
|
||||
<p>Weich am Schmarrn in da Donau aus!</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 10px; opacity: 0.8;">Desktop: Pfeile | Mobile: Wischen</p>
|
||||
<button @click="initGame">{{ t('game.start') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gameState === 'gameOver'" class="overlay-content">
|
||||
<h2 style="color: #ff4444; margin-bottom: 5px;">{{ t('game.gameOver') }}</h2>
|
||||
<p>Level: {{ level }} / {{ TOTAL_LEVELS }}</p>
|
||||
<p>Score: {{ score }}</p>
|
||||
<button @click="initGame">No amoi!</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gameState === 'win'" class="overlay-content">
|
||||
<h2 style="color: #44ff44; margin-bottom: 10px;">🏆 {{ t('game.win') }}</h2>
|
||||
<p>Du hosd an NATinator bsiegt!</p>
|
||||
<p style="color: #ffd700; font-weight: bold; margin-top: 10px;">🐗 NAT-Modus freigschaltet!</p>
|
||||
<button @click="emit('close')">Zruck zur Website</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gameState === 'playing'" class="game-stats">
|
||||
<div class="stats-left">
|
||||
<span class="level-badge">{{ t('game.level') }} {{ level }}/{{ TOTAL_LEVELS }}</span>
|
||||
<span v-if="level === TOTAL_LEVELS" class="boar-alert">🐗 BOAR RAIN! 🐗</span>
|
||||
</div>
|
||||
<span class="score-display">Score: {{ score }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div v-if="gameState === 'playing'" class="progress-container">
|
||||
<div class="progress-bar" :style="{ width: (score / (TOTAL_LEVELS * 500 + 500)) * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.game-window {
|
||||
background: #c0c0c0;
|
||||
border: 3px solid;
|
||||
border-color: #ffffff #808080 #808080 #ffffff;
|
||||
padding: 4px;
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
background: #000080;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.game-header button {
|
||||
background: #c0c0c0;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #808080 #808080 #ffffff;
|
||||
padding: 0 6px;
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.overlay-content h2 { margin-bottom: 20px; }
|
||||
|
||||
.overlay-content button {
|
||||
background: #c0c0c0;
|
||||
border: 3px solid;
|
||||
border-color: #ffffff #808080 #808080 #ffffff;
|
||||
padding: 10px 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.game-stats {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
background: rgba(0,0,0,0.6);
|
||||
padding: 8px 12px;
|
||||
pointer-events: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.boar-alert {
|
||||
color: #ff4444;
|
||||
font-size: 0.7rem;
|
||||
animation: blink 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #4ade80;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
.game-window {
|
||||
width: 95vw;
|
||||
}
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
currentTheme: string
|
||||
isNatUnlocked: boolean
|
||||
t: (key: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:theme', 'open:game'])
|
||||
|
||||
const themes = ['classic', 'dark', 'nat']
|
||||
|
||||
const { user, login, logout, error } = useAuth()
|
||||
const showSettings = ref(false)
|
||||
|
||||
watch(user, (u) => {
|
||||
console.log("👤 Header: User changed:", u ? u.email : "null")
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<header class="fancy-glass header-top">
|
||||
<div class="container top-content">
|
||||
|
||||
<!-- Logo & Status Area -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
|
||||
<!-- Mobile Settings Toggle -->
|
||||
<button class="settings-toggle" @click="showSettings = !showSettings">
|
||||
⚙️
|
||||
</button>
|
||||
|
||||
<!-- Auth Control (Always Visible) -->
|
||||
<div class="auth-control">
|
||||
<div v-if="error" class="auth-error" :title="error">⚠️</div>
|
||||
<button v-if="!user" @click="login" class="login-btn">
|
||||
Login
|
||||
</button>
|
||||
<div v-else class="user-menu">
|
||||
<button @click="logout" class="logout-btn">Exit</button>
|
||||
<img :src="user.photoURL || ''" class="avatar" :title="user.displayName || ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme switch -->
|
||||
<div class="control-wrapper" :class="{ 'show-mobile': showSettings }">
|
||||
<div class="switch-group">
|
||||
<template v-for="th in themes">
|
||||
<button
|
||||
v-if="th !== 'nat' || isNatUnlocked"
|
||||
:key="th"
|
||||
:class="{ active: currentTheme === th }"
|
||||
@click="emit('update:theme', th)"
|
||||
class="theme-btn"
|
||||
:title="t('themes.' + th)"
|
||||
>
|
||||
<span v-if="th === 'classic'">☀️</span>
|
||||
<span v-if="th === 'dark'">🌙</span>
|
||||
<span v-if="th === 'nat'">🐗</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</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>
|
||||
<button class="game-nav-btn" @click="emit('open:game')">{{ t('nav.game') }}</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-top {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.top-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-weight: 900;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Base Layout */
|
||||
.branding-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.control-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
margin-left: 15px;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-msg { display: none; }
|
||||
.settings-toggle { display: none; } /* Hidden on desktop */
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.branding-area {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-left: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.desktop-msg { display: none; }
|
||||
.mobile-msg { display: inline; }
|
||||
|
||||
/* Settings Toggle Logic */
|
||||
.control-wrapper {
|
||||
display: none; /* Hidden by default on mobile */
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 10px;
|
||||
background: var(--header-bg);
|
||||
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 {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
padding: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-group {
|
||||
display: flex;
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 0.7rem;
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile Nav Styles */
|
||||
.mobile-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 65px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.desktop-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Desktop Overrides */
|
||||
@media (min-width: 768px) {
|
||||
.logo-text { font-size: 1.2rem; }
|
||||
.mobile-nav { display: none; }
|
||||
.desktop-links {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
background: transparent;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.desktop-links div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
.desktop-links a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.desktop-links a:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.game-nav-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.auth-control {
|
||||
margin-right: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
color: #ff4444;
|
||||
margin-right: 8px;
|
||||
cursor: help;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
theme: string
|
||||
t: (key: string) => string
|
||||
}>()
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
if (props.theme === 'unicorn') return '/images/logo_unicorn.png'
|
||||
if (props.theme === 'luxury') return '/images/logo_luxury.png'
|
||||
if (props.theme === 'win95') return '/images/logo_win95.png'
|
||||
if (props.theme === 'nat') return '/images/logo_nat.png'
|
||||
return '/images/logo_classic.png'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="home" class="hero">
|
||||
<div class="container hero-content">
|
||||
<div class="logo-container">
|
||||
<img :src="logoSrc" :key="theme" alt="Haumdaucher Logo" class="hero-logo" />
|
||||
</div>
|
||||
<h1 class="hero-title">{{ t('hero.title') }}</h1>
|
||||
<p class="hero-subtitle">{{ t('hero.subtitle') }}</p>
|
||||
<div class="scroll-indicator">↓</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 40px;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.hero-logo {
|
||||
width: 300px;
|
||||
height: auto;
|
||||
animation: logo-entry 1s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes logo-entry {
|
||||
0% { transform: scale(0.5) rotateY(90deg); opacity: 0; }
|
||||
100% { transform: scale(1) rotateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(1.8rem, 8vw, 4rem); /* Reduced base from 2rem to 1.8rem */
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.1;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: clamp(1rem, 5vw, 1.5rem);
|
||||
font-style: italic;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.scroll-indicator {
|
||||
margin-top: 60px;
|
||||
font-size: 2rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {transform: translateY(0);}
|
||||
40% {transform: translateY(-10px);}
|
||||
60% {transform: translateY(-5px);}
|
||||
}
|
||||
|
||||
[data-theme='luxury'] .hero-title {
|
||||
background: linear-gradient(45deg, #d4af37, #f7ef8a, #d4af37);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 10px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
[data-theme='unicorn'] .hero-title {
|
||||
background: linear-gradient(45deg, #f06292, #ba68c8, #64b5f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ref, watchEffect, computed } from 'vue'
|
||||
import { auth, db } from '../firebase'
|
||||
import { GoogleAuthProvider, signInWithPopup, signOut, type User } from 'firebase/auth'
|
||||
import { doc, onSnapshot } from 'firebase/firestore'
|
||||
|
||||
// Global state
|
||||
const user = ref<User | null>(null)
|
||||
const isAllowed = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Watch auth state
|
||||
if (auth) {
|
||||
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
|
||||
|
||||
// Subscribe to the config/allowlist document
|
||||
const allowlistRef = doc(db, 'config', 'allowlist')
|
||||
const unsubscribe = onSnapshot(allowlistRef, (docSnap) => {
|
||||
isLoading.value = false
|
||||
if (docSnap.exists()) {
|
||||
const data = docSnap.data()
|
||||
const emails = data.emails || []
|
||||
isAllowed.value = emails.includes(user.value?.email)
|
||||
} else {
|
||||
isAllowed.value = false // Config doc missing -> deny all
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Failed to check allowlist", err)
|
||||
error.value = "Failed to check allowlist: " + err.message
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
onCleanup(() => unsubscribe())
|
||||
})
|
||||
|
||||
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)
|
||||
} catch (e: any) {
|
||||
console.error("Login failed", e)
|
||||
error.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
error.value = null
|
||||
if (!auth) return
|
||||
try {
|
||||
await signOut(auth)
|
||||
user.value = null
|
||||
isAllowed.value = false
|
||||
} catch (e: any) {
|
||||
console.error("Logout failed", e)
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isAllowed: computed(() => isAllowed.value),
|
||||
isLoading: computed(() => isLoading.value),
|
||||
error: computed(() => error.value),
|
||||
login,
|
||||
logout
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { getFirestore } from 'firebase/firestore'
|
||||
|
||||
// Use environment variables or fallback for dev (though env variables are preferred)
|
||||
// In a real setup, these should be injected via VITE_FIREBASE_CONFIG or individual keys.
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
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;
|
||||
|
||||
if (firebaseConfig.apiKey) {
|
||||
const app = initializeApp(firebaseConfig)
|
||||
auth = getAuth(app)
|
||||
db = getFirestore(app)
|
||||
} else {
|
||||
console.warn("⚠️ Firebase API Key missing. Firebase features are disabled.")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const messages = {
|
||||
de: {
|
||||
nav: {
|
||||
home: 'Start',
|
||||
spirit: 'Spirit',
|
||||
game: 'Haumdaucher Game'
|
||||
},
|
||||
hero: {
|
||||
title: 'Haumdaucher',
|
||||
subtitle: 'Wurst und Spezialitäten n.e.V. – in Gründung'
|
||||
},
|
||||
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.'
|
||||
},
|
||||
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'
|
||||
},
|
||||
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.'
|
||||
},
|
||||
themes: {
|
||||
classic: 'Klassisch',
|
||||
dark: 'Dunkel',
|
||||
nat: 'NAT-Mode'
|
||||
},
|
||||
game: {
|
||||
title: 'Haumdaucher Scroller',
|
||||
start: 'Spiel starten',
|
||||
gameOver: 'Aus’gmacht! Game Over.',
|
||||
level: 'Level',
|
||||
win: 'NATinator bezwungen! Du bist a echter Haumdaucher.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Technical Details: Authentication & Infrastructure
|
||||
|
||||
This document explains the "Hybrid" approach we use for managing Authentication infrastructure on Google Cloud Platform (GCP).
|
||||
|
||||
## 🏗 The Architecture
|
||||
|
||||
We use **Terraform** to manage our "Infrastructure as Code". This gives us reproducibility and version control. However, Authentication involves sensitive credentials that have security restrictions preventing full automation.
|
||||
|
||||
### Components
|
||||
1. **Firebase Auth (Identity Platform)**: Handles user login (Google Sign-In).
|
||||
2. **Firestore**: database storing the "Allowlist" of approved emails.
|
||||
3. **Secret Manager**: securely stores the OAuth credentials used by Identity Platform.
|
||||
|
||||
## 🔐 The "Client Secret" Dilemma
|
||||
|
||||
### 1. Why is there a Manual Step?
|
||||
When you enable Firebase Authentication, Google creates an **OAuth 2.0 Client ID** for your web application. This Client ID comes with a **Client Secret**.
|
||||
|
||||
**The Restriction**: Google Cloud's security model **does not allow** retrieving an existing Client Secret via the API or CLI (`gcloud`). It is only visible:
|
||||
1. At the moment of creation.
|
||||
2. In the GCP Console UI (where you can "download JSON").
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **"Browser key" vs "OAuth Client"**:
|
||||
> You might see a "Browser key (auto created by Firebase)" in the console. This is an **API Key**, which is public and safe to share.
|
||||
> **However**, for the Identity Platform backend configuration (`google_identity_platform_default_supported_idp_config`), we strictly need the **OAuth 2.0 Client Secret**, which acts as a password for the server. Do not confuse the two!
|
||||
|
||||
Because Terraform interacts via the API, it **cannot** fetch this secret on its own. If we tried to create a *new* Client ID via Terraform, we would hit another blocker: the **OAuth Consent Screen** requires manual configuration in the Console.
|
||||
|
||||
### 2. The Solution: "Bootstrap" Script
|
||||
To bridge this gap, we use a "Bootstrap" workflow:
|
||||
|
||||
1. **Creation**: Firebase auto-creates the credentials.
|
||||
2. **Retrieval**: You manually copy them from the Console (the only place they are visible).
|
||||
3. **Storage**: You run `scripts/manage_secrets.py` to save them into **Google Secret Manager**.
|
||||
4. **Consumption**: Terraform reads the secure values from Secret Manager to configure the Identity Provider.
|
||||
|
||||
## 🔄 Authentication Flow
|
||||
|
||||
1. **User Visits Site**: The Vue.js app initializes Firebase using public config (API Key, Auth Domain).
|
||||
2. **Login**: User clicks "Login". The app invokes `signInWithPopup(googleProvider)`.
|
||||
* Google checks the allow-listed domains (`localhost`, `*.firebaseapp.com`).
|
||||
* The "Client ID" is used to identify the app to Google.
|
||||
3. **Token Exchange**: Identity Platform validates the user's Google credentials using the **Client Secret** (configured via Terraform).
|
||||
4. **Success**: If valid, the user is signed in.
|
||||
5. **Authorization**: The app watches `config/allowlist` in Firestore.
|
||||
* If `user.email` is in the list -> **Banner: "Do bist a haumdaucher"**.
|
||||
* If not -> Access is restricted.
|
||||
|
||||
## 📝 Terraform Resources involved
|
||||
* `google_identity_platform_default_supported_idp_config`: Configures the "Google" provider.
|
||||
* `data "google_secret_manager_secret_version"`: Fetches the stored credentials.
|
||||
* `google_firestore_document`: Manages the allowlist.
|
||||
@@ -0,0 +1,42 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/google" {
|
||||
version = "5.45.2"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:y2hf6zus1eKA5vpAfoyYkNBKDBQTVqmx4OVh3iRBaRo=",
|
||||
"zh:0d09c8f20b556305192cdbe0efa6d333ceebba963a8ba91f9f1714b5a20c4b7a",
|
||||
"zh:117143fc91be407874568df416b938a6896f94cb873f26bba279cedab646a804",
|
||||
"zh:16ccf77d18dd2c5ef9c0625f9cf546ebdf3213c0a452f432204c69feed55081e",
|
||||
"zh:3e555cf22a570a4bd247964671f421ed7517970cd9765ceb46f335edc2c6f392",
|
||||
"zh:688bd5b05a75124da7ae6e885b2b92bd29f4261808b2b78bd5f51f525c1052ca",
|
||||
"zh:6db3ef37a05010d82900bfffb3261c59a0c247e0692049cb3eb8c2ef16c9d7bf",
|
||||
"zh:70316fde75f6a15d72749f66d994ccbdde5f5ed4311b6d06b99850f698c9bbf9",
|
||||
"zh:84b8e583771a4f2bd514e519d98ed7fd28dce5efe0634e973170e1cfb5556fb4",
|
||||
"zh:9d4b8ef0a9b6677935c604d94495042e68ff5489932cfd1ec41052e094a279d3",
|
||||
"zh:a2089dd9bd825c107b148dd12d6b286f71aa37dfd4ca9c35157f2dcba7bc19d8",
|
||||
"zh:f03d795c0fd9721e59839255ee7ba7414173017dc530b4ce566daf3802a0d6dd",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/google-beta" {
|
||||
version = "5.45.2"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:r9Tpv9w6j6hTI7MR7zeaUveGsyt/yNXjCmuO80asz98=",
|
||||
"zh:16b77bac5d1555b7f066ba8014f4fc8a6d0de64e252a1988d3fbb400984a4b19",
|
||||
"zh:1b13f515c4809343840aed8265915cc4191f138bdab5a8c5e1f542fdfc69989f",
|
||||
"zh:1dcce4309aeab7c88fd36aea664d57e620d8a413b967ce513a5a866e8de901f2",
|
||||
"zh:24db65d7929f2a731e9cac1750c569cb4528b312ef182a5e2e8c0cf008d8a71b",
|
||||
"zh:28c0b9e68d97570f03b2c4770607701580055bcba50069efd145954aa13b23e4",
|
||||
"zh:3a898a1ad1569f6486a2bc20014087284c8cab919bc8f155833de5128ccd12eb",
|
||||
"zh:4eed99cfb9daada70f813f2cedcf490d3097de1ccb9b391fc451ecc46509c067",
|
||||
"zh:888c4cb1f13b23674ba1091835dd3f1bff5d8e7729ef302183d8d01233819e54",
|
||||
"zh:8baae3b949f6e9505425f5fa4785de786e9cedc4c3f3ad906d8ed560bd2e39c6",
|
||||
"zh:cf2c8928b764592fa2cd14a9f109d01cd0a92049a4fca9d0a74cf2fe588364e2",
|
||||
"zh:edff09394f5bd0b278a4adc800a31b7f150249a1ea92ca273ccf4acd25be3f63",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
# Enable foundational APIs required for Terraform to manage other services
|
||||
resource "google_project_service" "cloudresourcemanager" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "cloudresourcemanager.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
resource "google_project_service" "serviceusage" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "serviceusage.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
# Enable required APIs
|
||||
resource "google_project_service" "firebase" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "firebase.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
|
||||
depends_on = [
|
||||
google_project_service.cloudresourcemanager,
|
||||
google_project_service.serviceusage
|
||||
]
|
||||
}
|
||||
|
||||
resource "google_project_service" "identitytoolkit" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "identitytoolkit.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
resource "google_project_service" "firestore" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "firestore.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
resource "google_project_service" "firebaserules" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
service = "firebaserules.googleapis.com"
|
||||
disable_on_destroy = false
|
||||
}
|
||||
|
||||
# Firebase Project
|
||||
resource "google_firebase_project" "default" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
|
||||
depends_on = [
|
||||
google_project_service.firebase,
|
||||
]
|
||||
}
|
||||
|
||||
# Firebase Web App
|
||||
resource "google_firebase_web_app" "default" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
display_name = "Haumdaucher Web"
|
||||
|
||||
depends_on = [google_firebase_project.default]
|
||||
}
|
||||
|
||||
data "google_firebase_web_app_config" "default" {
|
||||
provider = google-beta
|
||||
web_app_id = google_firebase_web_app.default.app_id
|
||||
project = var.project_id
|
||||
}
|
||||
|
||||
# Identity Platform (Auth)
|
||||
resource "google_identity_platform_config" "default" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
|
||||
# Authorized Domains for OAuth
|
||||
authorized_domains = [
|
||||
"localhost",
|
||||
"${var.project_id}.firebaseapp.com",
|
||||
"${var.project_id}.web.app",
|
||||
"haumdaucher.de",
|
||||
]
|
||||
|
||||
# Enable Google Sign-In (and others if needed, but keeping it simple)
|
||||
sign_in {
|
||||
allow_duplicate_emails = false
|
||||
|
||||
anonymous {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
email {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [google_project_service.identitytoolkit]
|
||||
}
|
||||
|
||||
# Enable Google Default Identity Provider
|
||||
resource "google_identity_platform_default_supported_idp_config" "google" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
enabled = false
|
||||
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
|
||||
|
||||
depends_on = [google_project_service.identitytoolkit]
|
||||
}
|
||||
|
||||
# NOTE: OAuth Client ID usually needs to be configured in console for Identity Platform
|
||||
# or imported. Terraform support for *creating* the OAuth client for IAP/Identity is limited/complex.
|
||||
# We will assume the default one created by Firebase is used or documented.
|
||||
|
||||
# Firestore Database (Native)
|
||||
resource "google_firestore_database" "database" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
name = "(default)"
|
||||
location_id = var.region
|
||||
type = "FIRESTORE_NATIVE"
|
||||
concurrency_mode = "OPTIMISTIC"
|
||||
app_engine_integration_mode = "DISABLED"
|
||||
|
||||
depends_on = [google_project_service.firestore]
|
||||
}
|
||||
|
||||
# Firestore Security Rules
|
||||
resource "google_firebaserules_ruleset" "firestore" {
|
||||
provider = google
|
||||
|
||||
source {
|
||||
files {
|
||||
name = "firestore.rules"
|
||||
content = <<-EOT
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow read, write: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
EOT
|
||||
}
|
||||
}
|
||||
|
||||
depends_on = [
|
||||
google_project_service.firestore,
|
||||
google_project_service.firebaserules
|
||||
]
|
||||
}
|
||||
|
||||
resource "google_firebaserules_release" "firestore" {
|
||||
provider = google
|
||||
project = var.project_id
|
||||
name = "cloud.firestore" # This specific name targets the default Firestore database
|
||||
ruleset_name = google_firebaserules_ruleset.firestore.name
|
||||
|
||||
depends_on = [google_firebaserules_ruleset.firestore]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
google-beta = {
|
||||
source = "hashicorp/google-beta"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
backend "gcs" {
|
||||
bucket = "haumdaucher-tf-state"
|
||||
prefix = "terraform/state"
|
||||
}
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
billing_project = var.project_id
|
||||
user_project_override = true
|
||||
}
|
||||
|
||||
provider "google-beta" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
billing_project = var.project_id
|
||||
user_project_override = true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
output "firebase_config" {
|
||||
value = {
|
||||
apiKey = data.google_firebase_web_app_config.default.api_key
|
||||
authDomain = data.google_firebase_web_app_config.default.auth_domain
|
||||
projectId = var.project_id
|
||||
storageBucket = lookup(data.google_firebase_web_app_config.default, "storage_bucket", "${var.project_id}.appspot.com")
|
||||
messagingSenderId = data.google_firebase_web_app_config.default.messaging_sender_id
|
||||
appId = google_firebase_web_app.default.app_id
|
||||
}
|
||||
description = "Firebase Configuration Object for Frontend"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Fetch secrets from Secret Manager
|
||||
# Expects these secrets to be created via scripts/manage_secrets.py
|
||||
|
||||
data "google_secret_manager_secret_version" "oauth_client_id" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
secret = "haumdaucher-oauth-client-id"
|
||||
}
|
||||
|
||||
data "google_secret_manager_secret_version" "oauth_client_secret" {
|
||||
provider = google-beta
|
||||
project = var.project_id
|
||||
secret = "haumdaucher-oauth-client-secret"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
variable "project_id" {
|
||||
description = "The GCP Project ID"
|
||||
type = string
|
||||
default = "haumdaucher"
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "The GCP region for resources"
|
||||
type = string
|
||||
default = "europe-west3"
|
||||
}
|
||||
|
||||
|
||||
variable "allowed_users" {
|
||||
description = "List of email addresses allowed to access restricted features"
|
||||
type = list(string)
|
||||
default = ["moritz@haumdaucher.de"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'icon-512.png', 'icon-192.png'],
|
||||
manifest: {
|
||||
name: 'Haumdaucher Regensburg',
|
||||
short_name: 'Haumdaucher',
|
||||
description: 'D\'Offizielle Website der Haumdaucher aus Regensburg.',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
icons: [
|
||||
{
|
||||
src: 'icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true
|
||||
}
|
||||
})
|
||||