fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
Full-site fake-data audit findings: - SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a 'corrosion-agent' binary that don't exist -> real host-agent commands - 'View live demo' CTA on 5 marketing pages linked to a login, not a demo -> honest 'Sign in' - Google Fonts @import was silently dropped from the production CSS bundle (mid-bundle @import) -> <link> tags in index.html; prod was shipping system fallback fonts - App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a single failed fetch until manual reload -> resets on route change + content-scoped boundary inside DashboardLayout so nav chrome survives - Status page KPIs showed fake zeros while the fetch failed -> em dash - Login lacked the forgot-password link (flow already existed end-to-end) - AdminSeedService: fresh DB had schema but no login possible; seeds super-admin + license from ADMIN_* env when users table is empty Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed (Site Audit — Fake Data, Resilience, Fonts — 2026-06-11)
|
||||
|
||||
**Frontend:**
|
||||
- `SetupWizardView.vue` — Replaced fake install instructions (`get.corrosionmgmt.com | sh` install script and `corrosion-agent` binary, neither of which exists) with the real host-agent download + run commands matching ServerView; multi-game copy on the completion step
|
||||
- Marketing views (Landing, Pricing, HowItWorks, Roadmap, EarlyAccess) — Replaced "View live demo" CTA (no demo exists; it linked to the panel login) with an honest "Sign in" link
|
||||
- `ErrorBoundary.vue` — Error state now resets on route change (previously one failed view bricked the entire SPA, including marketing pages, until manual reload); added `content` variant
|
||||
- `DashboardLayout.vue` — Routed views are now wrapped in a content-scoped ErrorBoundary so the sidebar/topbar survive a view failure instead of the whole panel unmounting
|
||||
- `index.html` / `styles/tokens/fonts.css` — Google Fonts moved from CSS `@import` to `<link>` tags. The bundler silently dropped the mid-bundle `@import`, so production shipped system fallback fonts (Geist/JetBrains Mono/Oxanium never loaded)
|
||||
- `StatusPageView.vue` — Platform KPIs show "—" until the first successful fetch instead of fake zeros
|
||||
- `LoginView.vue` — Added missing "Forgot password?" link (route + backend endpoint already existed)
|
||||
|
||||
**Backend (NestJS):**
|
||||
- `AdminSeedService` (new, auth module) — Bootstraps a super-admin user + active license from `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`ADMIN_USERNAME`/`ADMIN_LICENSE_KEY` when the users table is empty. A fresh deploy previously had a schema but no possible login. Compose already passes the env vars
|
||||
|
||||
**Purpose:** Findings from the full-site fake-data audit. Show real data or honest empty states — never invented values, dead URLs, or fabricated zeros.
|
||||
|
||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||
|
||||
**Frontend:**
|
||||
|
||||
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
82
backend-nest/src/modules/auth/admin-seed.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
|
||||
/**
|
||||
* Bootstraps the first admin account on a fresh database.
|
||||
*
|
||||
* A fresh deploy builds the schema via docker-entrypoint-initdb.d but contains
|
||||
* zero users, so the panel has no possible login. If ADMIN_EMAIL and
|
||||
* ADMIN_PASSWORD are set and the users table is empty, this creates a
|
||||
* super-admin user plus an active license — the same rows the register flow
|
||||
* would create. It never runs against a database that already has users.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminSeedService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AdminSeedService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@InjectRepository(User) private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(License) private readonly licenseRepository: Repository<License>,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.seedAdminIfEmpty();
|
||||
} catch (err) {
|
||||
// A failed seed must not take the API down — surface it loudly and move on
|
||||
this.logger.error(`Admin bootstrap failed: ${(err as Error).message}`, (err as Error).stack);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedAdminIfEmpty(): Promise<void> {
|
||||
const email = this.config.get<string>('admin.email');
|
||||
const password = this.config.get<string>('admin.password');
|
||||
const username = this.config.get<string>('admin.username') || 'Commander';
|
||||
|
||||
if (!email || !password) {
|
||||
this.logger.log('Admin bootstrap skipped: ADMIN_EMAIL / ADMIN_PASSWORD not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const userCount = await this.userRepository.count();
|
||||
if (userCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = await argon2.hash(password);
|
||||
const user = this.userRepository.create({
|
||||
email: email.toLowerCase(),
|
||||
username,
|
||||
password_hash,
|
||||
email_verified: true,
|
||||
is_super_admin: true,
|
||||
});
|
||||
await this.userRepository.save(user);
|
||||
|
||||
const licenseKey = this.config.get<string>('admin.licenseKey') || this.generateLicenseKey();
|
||||
const license = this.licenseRepository.create({
|
||||
license_key: licenseKey,
|
||||
owner_user_id: user.id,
|
||||
status: 'active',
|
||||
modules_enabled: [],
|
||||
webstore_active: false,
|
||||
});
|
||||
await this.licenseRepository.save(license);
|
||||
|
||||
this.logger.log(`Bootstrap admin created: ${user.email} (license ${license.license_key})`);
|
||||
}
|
||||
|
||||
private generateLicenseKey(): string {
|
||||
const part1 = randomBytes(2).toString('hex').toUpperCase();
|
||||
const part2 = randomBytes(2).toString('hex').toUpperCase();
|
||||
const part3 = randomBytes(2).toString('hex').toUpperCase();
|
||||
return `CORR-${part1}-${part2}-${part3}`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { User } from '../../entities/user.entity';
|
||||
import { License } from '../../entities/license.entity';
|
||||
@@ -27,7 +28,7 @@ import { TeamMember } from '../../entities/team-member.entity';
|
||||
TypeOrmModule.forFeature([User, License, Role, TeamMember]),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, AdminSeedService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<title>Corrosion Management</title>
|
||||
<!-- Fonts via <link>, NOT a CSS @import — the bundler drops @import rules
|
||||
that land mid-file after concatenation, silently shipping system fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap"
|
||||
/>
|
||||
<script>
|
||||
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||
so the design-system tokens paint with the right skin from frame one. */
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onErrorCaptured } from 'vue'
|
||||
import { ref, watch, onErrorCaptured } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** 'screen' fills the viewport (app root); 'content' fills its container (inside layout chrome) */
|
||||
variant?: 'screen' | 'content'
|
||||
}>(), { variant: 'screen' })
|
||||
|
||||
const route = useRoute()
|
||||
const hasError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
@@ -13,6 +20,12 @@ onErrorCaptured((err) => {
|
||||
return false
|
||||
})
|
||||
|
||||
// A failed view must not brick navigation — clear the error when the route changes
|
||||
watch(() => route.fullPath, () => {
|
||||
hasError.value = false
|
||||
errorMessage.value = ''
|
||||
})
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
errorMessage.value = ''
|
||||
@@ -21,7 +34,7 @@ function retry() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasError" class="eb-screen">
|
||||
<div v-if="hasError" class="eb-screen" :class="{ 'eb-screen--content': variant === 'content' }">
|
||||
<div class="eb-card">
|
||||
<div class="eb-icon-wrap">
|
||||
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||
@@ -44,6 +57,11 @@ function retry() {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.eb-screen--content {
|
||||
min-height: 60vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.eb-card {
|
||||
background: var(--surface-base);
|
||||
box-shadow: var(--ring-default), var(--shadow-md);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useThemeGame } from '@/composables/useThemeGame'
|
||||
import { useGameProfile } from '@/config/gameProfiles'
|
||||
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary.vue'
|
||||
import Logo from '@/components/ds/brand/Logo.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||
@@ -284,9 +285,11 @@ const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<!-- Page content — boundary keeps sidebar/topbar alive when a view fails -->
|
||||
<main class="app__content">
|
||||
<RouterView />
|
||||
<ErrorBoundary variant="content">
|
||||
<RouterView />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
JetBrains Mono — console, data, IDs, telemetry
|
||||
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
||||
------------------------------------------------------------
|
||||
NOTE: Loaded from Google Fonts CDN. If you want these self-
|
||||
hosted (offline), send the woff2 files and these @imports
|
||||
become @font-face rules.
|
||||
NOTE: The Google Fonts stylesheet is loaded via <link> tags in
|
||||
index.html — NOT @import here. A CSS @import that ends up
|
||||
mid-bundle after concatenation is silently dropped by the
|
||||
optimizer (fonts never load in production). If you want these
|
||||
self-hosted (offline), send the woff2 files and they become
|
||||
@font-face rules here.
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||
|
||||
@@ -191,6 +191,8 @@ function handleBackToLogin() {
|
||||
<p v-if="!showTotpInput" class="auth-footer">
|
||||
No account?
|
||||
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
||||
·
|
||||
<router-link to="/forgot-password" class="auth-footer__link">Forgot password?</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,14 +196,17 @@ async function completeSetup() {
|
||||
</div>
|
||||
|
||||
<div class="setup-code">
|
||||
<p class="setup-code__comment"># Download and install the Corrosion host agent</p>
|
||||
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
|
||||
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
|
||||
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
|
||||
<p class="setup-code__comment"># Download the Corrosion host agent (Linux)</p>
|
||||
<p class="setup-code__cmd">curl -LO https://cdn.corrosionmgmt.com/host-agent/latest/corrosion-host-agent-linux-amd64</p>
|
||||
<p class="setup-code__cmd">chmod +x corrosion-host-agent-linux-amd64</p>
|
||||
<p class="setup-code__comment setup-code__comment--mt"># Start with your license key</p>
|
||||
<p class="setup-code__cmd">export LICENSE_ID="{{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}"</p>
|
||||
<p class="setup-code__cmd">export NATS_URL="nats://nats.corrosionmgmt.com:4222"</p>
|
||||
<p class="setup-code__cmd">./corrosion-host-agent-linux-amd64</p>
|
||||
</div>
|
||||
|
||||
<p class="setup-hint">
|
||||
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
|
||||
On Windows, download the agent from the Server page after setup. The agent connects outbound and auto-registers with your panel.
|
||||
</p>
|
||||
|
||||
<div class="setup-actions">
|
||||
@@ -235,7 +238,7 @@ async function completeSetup() {
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="setup-card__title">You're all set</h1>
|
||||
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
|
||||
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your game server.</p>
|
||||
<Button
|
||||
type="button"
|
||||
:loading="isLoading"
|
||||
|
||||
@@ -291,7 +291,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
Sign up above
|
||||
</a>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +350,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
Join early access
|
||||
</RouterLink>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ const mockActiveGame = activeGame
|
||||
Join early access
|
||||
</RouterLink>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
<!-- Game pills -->
|
||||
@@ -672,7 +672,7 @@ const mockActiveGame = activeGame
|
||||
Join early access
|
||||
</RouterLink>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +351,7 @@ const plans: Plan[] = [
|
||||
Join early access
|
||||
</RouterLink>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,7 @@ onUnmounted(() => { io?.disconnect() })
|
||||
Join early access
|
||||
</RouterLink>
|
||||
<a class="btn btn--ghost btn--lg" :href="panelUrl + '/login'">
|
||||
<Icon name="play" :size="17" />View live demo
|
||||
<Icon name="key" :size="17" />Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,12 +41,8 @@ interface StatusResponse {
|
||||
|
||||
const api = useApi()
|
||||
const servers = ref<ServerStatus[]>([])
|
||||
const platformHealth = ref<PlatformHealth>({
|
||||
total_servers: 0,
|
||||
online_servers: 0,
|
||||
total_players: 0,
|
||||
uptime_percent: 0,
|
||||
})
|
||||
// null until the first successful fetch — KPIs render '—', never fake zeros
|
||||
const platformHealth = ref<PlatformHealth | null>(null)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(true)
|
||||
@@ -148,10 +144,10 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Platform KPIs -->
|
||||
<div v-if="!loading" class="sp-kpis">
|
||||
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
|
||||
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
|
||||
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
|
||||
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
|
||||
<StatCard icon="server" label="Total servers" :value="platformHealth ? String(platformHealth.total_servers) : '—'" />
|
||||
<StatCard icon="activity" label="Online now" :value="platformHealth ? String(platformHealth.online_servers) : '—'" />
|
||||
<StatCard icon="users" label="Total players" :value="platformHealth ? String(platformHealth.total_players) : '—'" />
|
||||
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth?.uptime_percent ?? null, 1, '—')" unit="%" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
|
||||
Reference in New Issue
Block a user