diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index b2c229fb..9c29a7f5 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -11,12 +11,13 @@ import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; import { cn } from "@/lib/utils"; -import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react"; +import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -const APP_VERSION = "1.4.11"; +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; +const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; const THEME_OPTIONS = [ { value: "light" as const, icon: Sun, label: "Light" }, @@ -24,6 +25,41 @@ const THEME_OPTIONS = [ { value: "system" as const, icon: Monitor, label: "System" }, ]; +function VersionBadge() { + const [copied, setCopied] = useState(false); + const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}`; + + const handleCopy = () => { + navigator.clipboard.writeText(versionInfo).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( +
+

+ v{APP_VERSION} +

+
+
+
+

Version: {APP_VERSION}

+

Build: {GIT_COMMIT}

+
+ +
+
+
+ ); +} + export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); @@ -579,9 +615,7 @@ export default function LoginPage() { )} )} -

- v{APP_VERSION} -

+ @@ -1075,9 +1109,7 @@ export default function LoginPage() { )} )} -

- v{APP_VERSION} -

+ diff --git a/components/settings/advanced-settings.tsx b/components/settings/advanced-settings.tsx index aad81b22..67d1dae0 100644 --- a/components/settings/advanced-settings.tsx +++ b/components/settings/advanced-settings.tsx @@ -8,6 +8,11 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { Button } from '@/components/ui/button'; import { usePolicyStore } from '@/stores/policy-store'; import { ALL_DEBUG_CATEGORIES } from '@/stores/settings-store'; +import { ExternalLink } from 'lucide-react'; +import { SpamSiegeGame } from './spam-siege-game'; + +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; +const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; export function AdvancedSettings() { const t = useTranslations('settings.advanced'); @@ -18,6 +23,20 @@ export function AdvancedSettings() { const [showResetConfirm, setShowResetConfirm] = useState(false); const fileInputRef = useRef(null); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); + const [showGame, setShowGame] = useState(false); + const logoClickCount = useRef(0); + const logoClickTimer = useRef | null>(null); + + const handleLogoClick = () => { + logoClickCount.current++; + if (logoClickTimer.current) clearTimeout(logoClickTimer.current); + if (logoClickCount.current >= 3) { + logoClickCount.current = 0; + setShowGame(true); + } else { + logoClickTimer.current = setTimeout(() => { logoClickCount.current = 0; }, 2000); + } + }; const handleExport = () => { const settingsJson = exportSettings(); @@ -65,6 +84,44 @@ export function AdvancedSettings() { }; return ( + <> + {showGame && setShowGame(false)} />} + {/* About */} +
+
+ + + GitHub + +
+
+ {/* Debug Mode */} {!isSettingHidden('debugMode') && isFeatureEnabled('debugModeEnabled') && ( @@ -147,5 +204,6 @@ export function AdvancedSettings() { + ); } diff --git a/components/settings/spam-siege-game.tsx b/components/settings/spam-siege-game.tsx new file mode 100644 index 00000000..0ee53fdc --- /dev/null +++ b/components/settings/spam-siege-game.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Shield, Mail, X, AlertTriangle, Trophy, RotateCcw, Inbox, MailCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +const GAME_WIDTH = 400; +const GAME_HEIGHT = 520; +const FORTRESS_Y = GAME_HEIGHT - 48; +const SPAWN_INTERVAL_START = 850; +const SPAWN_INTERVAL_MIN = 320; +const GAME_DURATION = 30; +const ENEMY_SPEED_START = 1.2; +const ENEMY_SPEED_INCREASE = 0.04; + +interface Enemy { + id: number; + x: number; + y: number; + speed: number; + type: "spam" | "phishing" | "legit"; +} + +type GameState = "idle" | "playing" | "won" | "lost"; + +export function SpamSiegeGame({ onClose }: { onClose: () => void }) { + const [gameState, setGameState] = useState("idle"); + const [enemies, setEnemies] = useState([]); + const [score, setScore] = useState(0); + const [timeLeft, setTimeLeft] = useState(GAME_DURATION); + const [shieldHealth, setShieldHealth] = useState(3); + const [hitEffects, setHitEffects] = useState<{ id: number; x: number; y: number; color: string }[]>([]); + const [destroyEffects, setDestroyEffects] = useState<{ id: number; x: number; y: number }[]>([]); + const [deliverEffects, setDeliverEffects] = useState<{ id: number; x: number; y: number }[]>([]); + const nextId = useRef(0); + const animFrameRef = useRef(0); + const lastTimeRef = useRef(0); + const spawnTimerRef = useRef(0); + const gameStateRef = useRef("idle"); + const elapsedRef = useRef(0); + const destroyedRef = useRef(new Set()); + + useEffect(() => { + gameStateRef.current = gameState; + }, [gameState]); + + const startGame = useCallback(() => { + setGameState("playing"); + setEnemies([]); + setScore(0); + setTimeLeft(GAME_DURATION); + setShieldHealth(3); + setHitEffects([]); + setDestroyEffects([]); + setDeliverEffects([]); + nextId.current = 0; + spawnTimerRef.current = 0; + elapsedRef.current = 0; + destroyedRef.current = new Set(); + lastTimeRef.current = performance.now(); + }, []); + + const spawnEnemy = useCallback(() => { + const id = nextId.current++; + const rand = Math.random(); + const type = rand > 0.7 ? "legit" : rand > 0.45 ? "phishing" : "spam"; + const x = 20 + Math.random() * (GAME_WIDTH - 60); + const elapsed = elapsedRef.current; + const speed = ENEMY_SPEED_START + (elapsed / 1000) * ENEMY_SPEED_INCREASE; + setEnemies((prev) => [...prev, { id, x, y: -30, speed, type }]); + }, []); + + const handleHover = useCallback((enemy: Enemy) => { + if (destroyedRef.current.has(enemy.id)) return; + destroyedRef.current.add(enemy.id); + + if (enemy.type === "legit") { + // Penalty for blocking legit mail + setShieldHealth((prev) => { + const nh = prev - 1; + if (nh <= 0) setGameState("lost"); + return Math.max(0, nh); + }); + setScore((prev) => Math.max(0, prev - 15)); + const effectId = nextId.current++; + setHitEffects((p) => [...p, { id: effectId, x: enemy.x, y: enemy.y, color: "rgba(34, 197, 94, 0.5)" }]); + setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500); + } else { + setScore((prev) => prev + 10); + const effectId = nextId.current++; + setDestroyEffects((prev) => [...prev, { id: effectId, x: enemy.x, y: enemy.y }]); + setTimeout(() => setDestroyEffects((prev) => prev.filter((e) => e.id !== effectId)), 400); + } + + setEnemies((prev) => prev.filter((e) => e.id !== enemy.id)); + }, []); + + // Game loop + useEffect(() => { + if (gameState !== "playing") return; + + const tick = (now: number) => { + if (gameStateRef.current !== "playing") return; + + const dt = now - lastTimeRef.current; + lastTimeRef.current = now; + elapsedRef.current += dt; + + // Timer + const newTimeLeft = GAME_DURATION - Math.floor(elapsedRef.current / 1000); + setTimeLeft(Math.max(0, newTimeLeft)); + if (newTimeLeft <= 0) { + setGameState("won"); + return; + } + + // Spawn + spawnTimerRef.current += dt; + const spawnInterval = Math.max( + SPAWN_INTERVAL_MIN, + SPAWN_INTERVAL_START - (elapsedRef.current / 1000) * 35 + ); + if (spawnTimerRef.current >= spawnInterval) { + spawnTimerRef.current = 0; + spawnEnemy(); + } + + // Move enemies + setEnemies((prev) => { + const next: Enemy[] = []; + let spamBreached = false; + for (const e of prev) { + const ny = e.y + e.speed * (dt / 16); + if (ny >= FORTRESS_Y) { + if (e.type === "legit") { + // Legit mail delivered — bonus + setScore((s) => s + 5); + const effectId = nextId.current++; + setDeliverEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y }]); + setTimeout(() => setDeliverEffects((p) => p.filter((d) => d.id !== effectId)), 500); + } else { + spamBreached = true; + const effectId = nextId.current++; + setHitEffects((p) => [...p, { id: effectId, x: e.x, y: FORTRESS_Y, color: "rgba(219, 45, 84, 0.3)" }]); + setTimeout(() => setHitEffects((p) => p.filter((h) => h.id !== effectId)), 500); + } + } else { + next.push({ ...e, y: ny }); + } + } + if (spamBreached) { + setShieldHealth((prev) => { + const nh = prev - 1; + if (nh <= 0) setGameState("lost"); + return Math.max(0, nh); + }); + } + return next; + }); + + animFrameRef.current = requestAnimationFrame(tick); + }; + + animFrameRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(animFrameRef.current); + }, [gameState, spawnEnemy]); + + const getEnemyStyle = (type: Enemy["type"]) => { + switch (type) { + case "phishing": + return { bg: "rgba(234, 179, 8, 0.15)", border: "rgba(234, 179, 8, 0.4)", color: "rgb(234, 179, 8)" }; + case "legit": + return { bg: "rgba(34, 197, 94, 0.12)", border: "rgba(34, 197, 94, 0.4)", color: "rgb(34, 197, 94)" }; + default: + return { bg: "rgba(219, 45, 84, 0.1)", border: "rgba(219, 45, 84, 0.3)", color: "rgb(219, 45, 84)" }; + } + }; + + return ( +
+
+ {/* Header */} +
+
+ + Spam Siege +
+ +
+ + {/* HUD */} +
+
+ Score: {score} + Time: {timeLeft}s +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+ + {/* Game area */} +
+ {/* Grid lines for depth */} +
+ + {/* Fortress wall */} +
+
+ {/* Shield centered above the line */} +
+ 0 ? "rgb(219, 45, 84)" : "rgb(100, 100, 100)" }} + fill={shieldHealth > 0 ? "rgba(219, 45, 84, 0.2)" : "none"} + /> +
+ {/* Solid line */} +
0 ? "rgba(219, 45, 84, 0.35)" : "rgba(100, 100, 100, 0.3)" }} + /> +
+ {/* Subtle gradient fill below */} +
0 + ? "linear-gradient(to bottom, rgba(219, 45, 84, 0.06), transparent)" + : "linear-gradient(to bottom, rgba(100, 100, 100, 0.04), transparent)", + }} + /> +
+ + {/* Enemies */} + {enemies.map((e) => { + const style = getEnemyStyle(e.type); + return ( +
handleHover(e)} + > + {e.type === "phishing" ? ( + + ) : e.type === "legit" ? ( + + ) : ( + + )} +
+ ); + })} + + {/* Destroy effects */} + {destroyEffects.map((e) => ( +
+ +
+ ))} + + {/* Deliver effects (legit mail arrived) */} + {deliverEffects.map((e) => ( +
+ +
+ ))} + + {/* Hit effects on fortress */} + {hitEffects.map((e) => ( +
+
+
+ ))} + + {/* Idle overlay */} + {gameState === "idle" && ( +
+ +
+

Spam Siege

+

+ Hover over threats to block them. Let legitimate mail through. Survive {GAME_DURATION} seconds. +

+
+ + Spam + + + Phishing + + + Legit + +
+
+ +
+ )} + + {/* Won overlay */} + {gameState === "won" && ( +
+ +
+

Fortress Secured

+

+ Score: {score} +

+
+
+ + +
+
+ )} + + {/* Lost overlay */} + {gameState === "lost" && ( +
+ +
+

Fortress Breached

+

+ Score: {score} +

+
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/locales/en/common.json b/locales/en/common.json index 1a731fb2..43ceda12 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1210,6 +1210,9 @@ "label": "Import Settings", "description": "Upload settings from JSON file", "button": "Import" + }, + "about": { + "title": "Bulwark Webmail" } }, "sidebar_apps": { diff --git a/next.config.ts b/next.config.ts index f0b267cd..cc78bd8e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,22 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +import { execSync } from "child_process"; +import { readFileSync } from "fs"; +import { join } from "path"; + +let gitCommitHash = "unknown"; +try { + gitCommitHash = execSync("git rev-parse --short HEAD").toString().trim(); +} catch { + // git not available +} + +let appVersion = "0.0.0"; +try { + appVersion = readFileSync(join(import.meta.dirname, "VERSION"), "utf-8").trim(); +} catch { + // VERSION file not found +} const nextConfig: NextConfig = { output: "standalone", @@ -7,6 +24,10 @@ const nextConfig: NextConfig = { turbopack: { root: import.meta.dirname, }, + env: { + NEXT_PUBLIC_GIT_COMMIT: gitCommitHash, + NEXT_PUBLIC_APP_VERSION: appVersion, + }, }; const withNextIntl = createNextIntlPlugin();