Compare commits
8 Commits
master
...
implement_
| Author | SHA1 | Date |
|---|---|---|
|
|
36c3bcc98b | |
|
|
edbb90e5e2 | |
|
|
6c1dbe9681 | |
|
|
2c250f601b | |
|
|
754e495607 | |
|
|
636e194ea6 | |
|
|
a0a39b55d9 | |
|
|
dc733d8567 |
|
|
@ -10,7 +10,17 @@ lerna-debug.log*
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
|
||||||
|
# Local Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
*.tfvars
|
||||||
|
*.tfplan
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|
@ -22,3 +32,6 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Deprecated (Inlined in Terraform)
|
||||||
|
firestore.rules
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ FROM node:lts-alpine as build-stage
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
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 . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
11
GEMINI.md
11
GEMINI.md
|
|
@ -1,5 +1,8 @@
|
||||||
# GEMINI.md - Haumdaucher Project Handbook
|
# GEMINI.md - Haumdaucher Project Handbook
|
||||||
|
|
||||||
|
## 🚨 Rules
|
||||||
|
**1. Infrastructure as Code is rule #1.** Manual creation of resources (e.g., via `gcloud` or Console) is forbidden. The use of Terraform/Tofu is mandatory.
|
||||||
|
|
||||||
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
|
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
|
||||||
|
|
||||||
## 🦢 Project Essence
|
## 🦢 Project Essence
|
||||||
|
|
@ -25,6 +28,14 @@ This document serves as the "Source of Truth" for the Haumdaucher website. It ou
|
||||||
- Managed via `vite-plugin-pwa`.
|
- Managed via `vite-plugin-pwa`.
|
||||||
- Custom icons and standalone manifest for "Add to Home Screen" support.
|
- Custom icons and standalone manifest for "Add to Home Screen" support.
|
||||||
|
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
- **Framework**: Vitest + HappyDOM.
|
||||||
|
- **Scope**: Lightweight sanity checks (e.g., verifying App mount).
|
||||||
|
- **Commands**:
|
||||||
|
- `npm test`: Run tests in watch mode.
|
||||||
|
- `npm test -- --run`: Run tests once (CI mode).
|
||||||
|
|
||||||
## 🕹 The Haumdaucher Game
|
## 🕹 The Haumdaucher Game
|
||||||
- **Engine**: HTML5 Canvas rendering.
|
- **Engine**: HTML5 Canvas rendering.
|
||||||
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).
|
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).
|
||||||
|
|
|
||||||
70
README.md
70
README.md
|
|
@ -21,3 +21,73 @@ Use the provided deployment script to push to your Kubernetes cluster:
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
Check [k8s-manifests.yaml](k8s-manifests.yaml) for resource definitions.
|
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`.
|
||||||
|
|
||||||
|
|
|
||||||
25
deploy.sh
25
deploy.sh
|
|
@ -14,7 +14,30 @@ kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f
|
||||||
|
|
||||||
# Build the docker image
|
# Build the docker image
|
||||||
echo "📦 Building Docker image..."
|
echo "📦 Building Docker image..."
|
||||||
docker build -t $IMAGE_NAME:$TAG .
|
|
||||||
|
# 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 -t $IMAGE_NAME:$TAG "${FIREBASE_ARGS[@]}" .
|
||||||
|
|
||||||
# Push the docker image
|
# Push the docker image
|
||||||
echo "📤 Pushing Docker image to $REGISTRY..."
|
echo "📤 Pushing Docker image to $REGISTRY..."
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<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>
|
<title>Haumdaucher Regensburg</title>
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
|
|
@ -5,19 +5,26 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"setup:env": "./scripts/setup-local-env.sh",
|
||||||
|
"test": "vitest",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.3.4"
|
"firebase": "^12.7.0",
|
||||||
|
"vue": "^3.3.4",
|
||||||
|
"vuefire": "^3.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^16.11.1",
|
"@types/node": "^20.19.27",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.4.0",
|
"@vue/tsconfig": "^0.4.0",
|
||||||
|
"happy-dom": "^20.0.11",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.5.3",
|
"vite": "^4.5.3",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vitest": "^4.0.16",
|
||||||
"vue-tsc": "^1.8.8"
|
"vue-tsc": "^1.8.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,15 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import Header from './components/layout/Header.vue'
|
import Header from './components/layout/Header.vue'
|
||||||
import Hero from './components/sections/Hero.vue'
|
import Hero from './components/sections/Hero.vue'
|
||||||
import About from './components/sections/About.vue'
|
import About from './components/sections/About.vue'
|
||||||
|
|
@ -7,6 +7,9 @@ import History from './components/sections/History.vue'
|
||||||
import Beer from './components/sections/Beer.vue'
|
import Beer from './components/sections/Beer.vue'
|
||||||
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
|
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
|
||||||
import { messages } from './locales/i18n'
|
import { messages } from './locales/i18n'
|
||||||
|
import { useAuth } from './composables/useAuth'
|
||||||
|
|
||||||
|
const { isAllowed } = useAuth()
|
||||||
|
|
||||||
const theme = ref('classic')
|
const theme = ref('classic')
|
||||||
const lang = ref<'de' | 'bar'>('de')
|
const lang = ref<'de' | 'bar'>('de')
|
||||||
|
|
@ -95,6 +98,9 @@ const t = (key: string) => {
|
||||||
:t="t"
|
:t="t"
|
||||||
/>
|
/>
|
||||||
<main @click="triggerBSOD">
|
<main @click="triggerBSOD">
|
||||||
|
|
||||||
|
<!-- Member Banner -->
|
||||||
|
|
||||||
<Hero :theme="theme" :t="t" />
|
<Hero :theme="theme" :t="t" />
|
||||||
<About :t="t" />
|
<About :t="t" />
|
||||||
<History :t="t" />
|
<History :t="t" />
|
||||||
|
|
@ -164,4 +170,5 @@ const t = (key: string) => {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -9,15 +9,53 @@ const props = defineProps<{
|
||||||
const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
|
const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
|
||||||
|
|
||||||
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
|
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
|
||||||
|
|
||||||
|
import { useAuth } from '../../composables/useAuth'
|
||||||
|
import { watch, ref } from 'vue'
|
||||||
|
const { user, login, logout, error } = useAuth()
|
||||||
|
const showSettings = ref(false)
|
||||||
|
|
||||||
|
watch(user, (u) => {
|
||||||
|
console.log("👤 Header: User changed:", u ? u.email : "null")
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="fancy-glass header-top">
|
<header class="fancy-glass header-top">
|
||||||
<div class="container top-content">
|
<div class="container top-content">
|
||||||
|
|
||||||
|
<!-- Logo & Status Area -->
|
||||||
|
<div class="branding-area">
|
||||||
<div class="logo-text">HAUMDAUCHER</div>
|
<div class="logo-text">HAUMDAUCHER</div>
|
||||||
|
<div v-if="user" class="status-message">
|
||||||
|
<span class="desktop-msg">{{ isNatUnlocked || isAllowed ? 'Du bist a Haumdaucher 🫵 🍻' : 'Vielleicht... bist du a Haumdaucher' }}</span>
|
||||||
|
<span class="mobile-msg">{{ isNatUnlocked || isAllowed ? 'Haumdaucher! 🫵' : 'Vielleicht... 🤔' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<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>
|
||||||
|
|
||||||
<!-- Combined switch for better mobile spacing -->
|
<!-- Combined switch for better mobile spacing -->
|
||||||
<div class="control-wrapper">
|
<div class="control-wrapper" :class="{ 'show-mobile': showSettings }">
|
||||||
|
|
||||||
<div class="switch-group">
|
<div class="switch-group">
|
||||||
<button
|
<button
|
||||||
v-for="l in ['de', 'bar']"
|
v-for="l in ['de', 'bar']"
|
||||||
|
|
@ -95,6 +133,12 @@ const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Base Layout */
|
||||||
|
.branding-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -110,6 +154,58 @@ const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-wrapper.show-mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.switch-group {
|
.switch-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: rgba(0,0,0,0.05);
|
background: rgba(0,0,0,0.05);
|
||||||
|
|
@ -213,4 +309,44 @@ button.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
transition: transform 0.2s;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
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
|
||||||
|
auth.onAuthStateChanged((u) => {
|
||||||
|
console.log("🔥 Auth State Changed:", u ? u.email : "Logged Out")
|
||||||
|
user.value = u
|
||||||
|
if (!u) {
|
||||||
|
isAllowed.value = false
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check allowlist
|
||||||
|
watchEffect((onCleanup) => {
|
||||||
|
if (!user.value) 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
|
||||||
|
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
|
||||||
|
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,21 @@
|
||||||
|
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
|
||||||
|
const app = initializeApp(firebaseConfig)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
export const auth = getAuth(app)
|
||||||
|
export const db = getFirestore(app)
|
||||||
|
|
@ -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,188 @@
|
||||||
|
# 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 = false # We only want Google Sign-In
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = true
|
||||||
|
idp_id = "google.com"
|
||||||
|
client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data
|
||||||
|
client_secret = data.google_secret_manager_secret_version.oauth_client_secret.secret_data
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allowlist Configuration Document
|
||||||
|
resource "google_firestore_document" "allowlist" {
|
||||||
|
provider = google-beta
|
||||||
|
project = var.project_id
|
||||||
|
database = google_firestore_database.database.name
|
||||||
|
collection = "config"
|
||||||
|
document_id = "allowlist"
|
||||||
|
|
||||||
|
# Serialize the list of emails into a JSON string map for the fields
|
||||||
|
fields = jsonencode({
|
||||||
|
emails = {
|
||||||
|
arrayValue = {
|
||||||
|
values = [
|
||||||
|
for email in var.allowed_users : {
|
||||||
|
stringValue = email
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Firestore Security Rules
|
||||||
|
resource "google_firebaserules_ruleset" "firestore" {
|
||||||
|
provider = google
|
||||||
|
|
||||||
|
source {
|
||||||
|
files {
|
||||||
|
name = "firestore.rules"
|
||||||
|
content = <<-EOT
|
||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
match /config/allowlist {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"]
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
@ -36,5 +37,9 @@ export default defineConfig({
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue