feat: implement firebase authentication with terraform infrastructure

This commit is contained in:
Moritz Graf 2026-01-02 21:56:41 +01:00
parent b4dcdc3667
commit dc733d8567
16 changed files with 1577 additions and 12 deletions

8
.gitignore vendored
View File

@ -10,7 +10,13 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Terraform
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
*.tfplan
# Editor directories and files
.vscode/*

View File

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

@ -21,3 +21,34 @@ 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
```
### 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,7 +14,30 @@ kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f
# Build the docker image
echo "📦 Building Docker image..."
docker build -t $IMAGE_NAME:$TAG .
# Try to fetch Firebase config from Terraform
if [ -d "terraform" ] && [ -f "terraform/terraform.tfstate" ]; then
echo "🔍 Detected Terraform state. 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
echo "📤 Pushing Docker image to $REGISTRY..."

1078
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4"
"firebase": "^12.7.0",
"vue": "^3.3.4",
"vuefire": "^3.2.2"
},
"devDependencies": {
"@types/node": "^16.11.1",

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted } from 'vue'
import Header from './components/layout/Header.vue'
import Hero from './components/sections/Hero.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 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')
@ -95,6 +98,12 @@ const t = (key: string) => {
:t="t"
/>
<main @click="triggerBSOD">
<!-- Member Banner -->
<div v-if="isAllowed" class="member-banner">
Do bist a haumdaucher 🫵 🍻
</div>
<Hero :theme="theme" :t="t" />
<About :t="t" />
<History :t="t" />
@ -164,4 +173,21 @@ const t = (key: string) => {
z-index: 9999;
opacity: 0;
}
.member-banner {
background: linear-gradient(90deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%);
color: #555;
text-align: center;
padding: 10px;
font-weight: bold;
font-size: 1.2rem;
animation: slide-down 0.5s ease-out;
margin-top: 60px; /* Offset for fixed header */
border-bottom: 2px solid rgba(255,255,255,0.5);
}
@keyframes slide-down {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>

View File

@ -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>

View File

@ -9,6 +9,9 @@ const props = defineProps<{
const emit = defineEmits(['update:theme', 'update:lang', 'open:game'])
const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
import { useAuth } from '../../composables/useAuth'
const { user, login, logout } = useAuth()
</script>
<template>
@ -16,8 +19,20 @@ const themes = ['classic', 'unicorn', 'luxury', 'win95', 'nat']
<div class="container top-content">
<div class="logo-text">HAUMDAUCHER</div>
<div class="controls">
<!-- Auth Control -->
<div class="auth-control">
<button v-if="!user" @click="login" class="login-btn">
Login
</button>
<div v-else class="user-menu">
<img :src="user.photoURL || ''" class="avatar" :title="user.displayName || ''" />
<button @click="logout" class="logout-btn">Exit</button>
</div>
</div>
<!-- Combined switch for better mobile spacing -->
<div class="control-wrapper">
<div class="switch-group">
<button
v-for="l in ['de', 'bar']"
@ -213,4 +228,37 @@ 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;
}
</style>

View File

@ -0,0 +1,72 @@
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)
// Watch auth state
auth.onAuthStateChanged((u) => {
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)
// Don't set isAllowed to false here automatically, maybe retain retry logic? for now default to false
isLoading.value = false
})
onCleanup(() => unsubscribe())
})
export function useAuth() {
const login = async () => {
try {
const provider = new GoogleAuthProvider()
await signInWithPopup(auth, provider)
} catch (e) {
console.error("Login failed", e)
throw e
}
}
const logout = async () => {
try {
await signOut(auth)
user.value = null
isAllowed.value = false
} catch (e) {
console.error("Logout failed", e)
}
}
return {
user,
isAllowed: computed(() => isAllowed.value),
isLoading: computed(() => isLoading.value),
login,
logout
}
}

21
src/firebase.ts Normal file
View File

@ -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)

View File

@ -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:iy2Q9VcnMu4z/bH3v/NmI/nEpgYY7bXgJmT/hVTAUS4=",
"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:ME/cVZGNln4h166gyo9r7CuunzZ3FEqlIaNyQ0e9yjE=",
"zh:16b77bac5d1555b7f066ba8014f4fc8a6d0de64e252a1988d3fbb400984a4b19",
"zh:1b13f515c4809343840aed8265915cc4191f138bdab5a8c5e1f542fdfc69989f",
"zh:1dcce4309aeab7c88fd36aea664d57e620d8a413b967ce513a5a866e8de901f2",
"zh:24db65d7929f2a731e9cac1750c569cb4528b312ef182a5e2e8c0cf008d8a71b",
"zh:28c0b9e68d97570f03b2c4770607701580055bcba50069efd145954aa13b23e4",
"zh:3a898a1ad1569f6486a2bc20014087284c8cab919bc8f155833de5128ccd12eb",
"zh:4eed99cfb9daada70f813f2cedcf490d3097de1ccb9b391fc451ecc46509c067",
"zh:888c4cb1f13b23674ba1091835dd3f1bff5d8e7729ef302183d8d01233819e54",
"zh:8baae3b949f6e9505425f5fa4785de786e9cedc4c3f3ad906d8ed560bd2e39c6",
"zh:cf2c8928b764592fa2cd14a9f109d01cd0a92049a4fca9d0a74cf2fe588364e2",
"zh:edff09394f5bd0b278a4adc800a31b7f150249a1ea92ca273ccf4acd25be3f63",
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
]
}

126
terraform/firebase.tf Normal file
View File

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

23
terraform/main.tf Normal file
View File

@ -0,0 +1,23 @@
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
google-beta = {
source = "hashicorp/google-beta"
version = "~> 5.0"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
provider "google-beta" {
project = var.project_id
region = var.region
user_project_override = true
}

11
terraform/outputs.tf Normal file
View File

@ -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"
}

17
terraform/variables.tf Normal file
View File

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