Compare commits

..

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

24 changed files with 21 additions and 3682 deletions

15
.gitignore vendored
View File

@ -10,17 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
# Local Environment
.env
.env.local
# Terraform
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
*.tfplan
*.local
# Editor directories and files
.vscode/*
@ -32,6 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Deprecated (Inlined in Terraform)
firestore.rules

View File

@ -3,15 +3,6 @@ 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

View File

@ -1,8 +1,5 @@
# GEMINI.md - Haumdaucher Project Handbook
## 🚨 Rules
**1. Infrastructure as Code is rule #1.** Manual creation of resources (e.g., via `gcloud` or Console) is forbidden. The use of Terraform/Tofu is mandatory.
This document serves as the "Source of Truth" for the Haumdaucher website. It outlines the design principles, technical architecture, and specialized features to ensure consistent future development.
## 🦢 Project Essence
@ -28,14 +25,6 @@ This document serves as the "Source of Truth" for the Haumdaucher website. It ou
- Managed via `vite-plugin-pwa`.
- Custom icons and standalone manifest for "Add to Home Screen" support.
## 🧪 Testing
- **Framework**: Vitest + HappyDOM.
- **Scope**: Lightweight sanity checks (e.g., verifying App mount).
- **Commands**:
- `npm test`: Run tests in watch mode.
- `npm test -- --run`: Run tests once (CI mode).
## 🕹 The Haumdaucher Game
- **Engine**: HTML5 Canvas rendering.
- **Controls**: Touch-responsive (horizontal drag) and Keyboard (Arrow Keys).

View File

@ -21,73 +21,3 @@ Use the provided deployment script to push to your Kubernetes cluster:
./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`.

View File

@ -14,30 +14,7 @@ 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 -t $IMAGE_NAME:$TAG "${FIREBASE_ARGS[@]}" .
docker build -t $IMAGE_NAME:$TAG .
# Push the docker image
echo "📤 Pushing Docker image to $REGISTRY..."

View File

@ -6,8 +6,7 @@
<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-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icon-192.png" />
</head>

2608
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,26 +5,19 @@
"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"
"vue": "^3.3.4"
},
"devDependencies": {
"@types/node": "^20.19.27",
"@types/node": "^16.11.1",
"@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"
}
}

View File

@ -1,217 +0,0 @@
#!/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)

View File

@ -1,60 +0,0 @@
#!/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"

View File

@ -1,15 +0,0 @@
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')
})
})

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import Header from './components/layout/Header.vue'
import Hero from './components/sections/Hero.vue'
import About from './components/sections/About.vue'
@ -7,9 +7,6 @@ import History from './components/sections/History.vue'
import Beer from './components/sections/Beer.vue'
import HaumdaucherGame from './components/layout/HaumdaucherGame.vue'
import { messages } from './locales/i18n'
import { useAuth } from './composables/useAuth'
const { isAllowed } = useAuth()
const theme = ref('classic')
const lang = ref<'de' | 'bar'>('de')
@ -98,9 +95,6 @@ const t = (key: string) => {
:t="t"
/>
<main @click="triggerBSOD">
<!-- Member Banner -->
<Hero :theme="theme" :t="t" />
<About :t="t" />
<History :t="t" />
@ -170,5 +164,4 @@ const t = (key: string) => {
z-index: 9999;
opacity: 0;
}
</style>

View File

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

View File

@ -9,53 +9,15 @@ const props = defineProps<{
const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
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>
<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 || isAllowed ? 'Du bist a Haumdaucher 🫵 🍻' : 'Vielleicht... bist du a Haumdaucher' }}</span>
<span class="mobile-msg">{{ isNatUnlocked || isAllowed ? 'Haumdaucher! 🫵' : 'Vielleicht... 🤔' }}</span>
</div>
</div>
<div class="logo-text">HAUMDAUCHER</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>
<!-- Combined switch for better mobile spacing -->
<div class="control-wrapper" :class="{ 'show-mobile': showSettings }">
<div class="control-wrapper">
<div class="switch-group">
<button
v-for="l in ['de', 'bar']"
@ -133,12 +95,6 @@ watch(user, (u) => {
white-space: nowrap;
}
/* Base Layout */
.branding-area {
display: flex;
align-items: center;
}
.controls {
display: flex;
align-items: center;
@ -154,58 +110,6 @@ watch(user, (u) => {
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 {
display: flex;
background: rgba(0,0,0,0.05);
@ -309,44 +213,4 @@ button.active {
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>

View File

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

View File

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

View File

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

View File

@ -1,42 +0,0 @@
# 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",
]
}

View File

@ -1,188 +0,0 @@
# 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]
}

View File

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

View File

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

View File

@ -1,14 +0,0 @@
# 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"
}

View File

@ -1,18 +0,0 @@
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"]
}

View File

@ -1,4 +1,3 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
@ -37,9 +36,5 @@ export default defineConfig({
]
}
})
],
test: {
environment: 'happy-dom',
globals: true
}
]
})