Compare commits
12 Commits
agent-v2.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c7a8a97 | ||
|
|
907cfcb428 | ||
|
|
b1961df18e | ||
|
|
cfdec62a1d | ||
|
|
e510f8b005 | ||
|
|
cf1f1dea9a | ||
|
|
2e72850b97 | ||
|
|
9f9785fc09 | ||
|
|
142ba21113 | ||
|
|
04e664045b | ||
|
|
cef95540fc | ||
|
|
7f2207bc28 |
@@ -28,6 +28,10 @@ export class PermissionsGuard implements CanActivate {
|
||||
const permissions = user.permissions as Record<string, boolean> | undefined;
|
||||
if (!permissions) return false;
|
||||
|
||||
// Global wildcard — the Owner role (full control of its license) carries
|
||||
// {"*": true}, so new features never need to amend the role enumeration.
|
||||
if (permissions['*'] === true) return true;
|
||||
|
||||
// Support wildcard: "server.*" matches "server.view", "server.console", etc.
|
||||
const parts = requiredPermission.split('.');
|
||||
const wildcard = parts[0] + '.*';
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { VerifyTotpDto } from './dto/verify-totp.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
@@ -61,6 +62,30 @@ export class AuthController {
|
||||
return this.authService.verifyTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('2fa/disable')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Disable TOTP 2FA (requires a current code)' })
|
||||
async disableTotp(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: VerifyTotpDto,
|
||||
) {
|
||||
return this.authService.disableTotp(userId, dto.code);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Change the current user password' })
|
||||
async changePassword(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
return this.authService.changePassword(
|
||||
userId,
|
||||
dto.current_password,
|
||||
dto.new_password,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
|
||||
@@ -335,6 +335,56 @@ export class AuthService {
|
||||
throw new NotImplementedException('Password reset not yet configured');
|
||||
}
|
||||
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.password_hash, currentPassword);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
|
||||
if (await argon2.verify(user.password_hash, newPassword)) {
|
||||
throw new BadRequestException('New password must be different from the current one');
|
||||
}
|
||||
|
||||
const password_hash = await argon2.hash(newPassword);
|
||||
await this.userRepository.update(user.id, { password_hash });
|
||||
this.logger.log(`Password changed for user ${user.id}`);
|
||||
|
||||
// NOTE: existing JWTs remain valid until expiry — this design has no
|
||||
// server-side refresh-token store to revoke. Session invalidation on
|
||||
// password change is a follow-up (tracked separately).
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async disableTotp(userId: string, code: string) {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!user.totp_enabled) {
|
||||
throw new BadRequestException('2FA is not enabled');
|
||||
}
|
||||
|
||||
// Require a valid current code — proves possession of the second factor
|
||||
// before removing it, so a hijacked session can't silently strip 2FA.
|
||||
const valid = await this.verifyTotpCode(user, code);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid TOTP code');
|
||||
}
|
||||
|
||||
await this.userRepository.update(user.id, {
|
||||
totp_enabled: false,
|
||||
totp_secret: null,
|
||||
});
|
||||
this.logger.log(`TOTP disabled for user ${user.id}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private async generateTokens(user: User, licenseId?: string) {
|
||||
|
||||
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
14
backend-nest/src/modules/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ description: 'Current account password' })
|
||||
@IsString()
|
||||
current_password: string;
|
||||
|
||||
@ApiProperty({ description: 'New password', minLength: 8, maxLength: 128 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(128)
|
||||
new_password: string;
|
||||
}
|
||||
15
backend/migrations/025_owner_full_access.sql
Normal file
15
backend/migrations/025_owner_full_access.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 025_owner_full_access.sql
|
||||
--
|
||||
-- The system-default Owner role enumerated per-resource wildcards
|
||||
-- (server.*, wipe.*, players.*, ...). Every feature added since drift past that
|
||||
-- enumeration: apikeys, webhooks, alerts, analytics, chat, schedules,
|
||||
-- notifications, map, users, and ALL plugin-config modules (plus a singular
|
||||
-- 'plugin.*' vs granted 'plugins.*' mismatch) were silently locked out for any
|
||||
-- non-super-admin Owner — PermissionsGuard denies a permission the role doesn't
|
||||
-- grant. The Owner has "full control of their license" by definition, so grant
|
||||
-- a global wildcard instead of an enumeration that must be amended per feature.
|
||||
--
|
||||
-- PermissionsGuard and the frontend auth store both honor "*" as allow-all.
|
||||
UPDATE roles
|
||||
SET permissions = '{"*": true}'::jsonb
|
||||
WHERE role_name = 'Owner' AND is_system_default = true;
|
||||
222
docs/brand/brand-kit.md
Normal file
222
docs/brand/brand-kit.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Corrosion Management — Brand Kit
|
||||
|
||||
Single source of truth for brand voice, the Dr. Flask mascot, social channels,
|
||||
and launch content. Companion to the character model sheet in
|
||||
`docs/character/` (`drflask-character-bible.md`, `drflask-characterboard.png`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Positioning & taglines
|
||||
|
||||
**Corrosion Management** — *Game server operations, automated with
|
||||
chemistry-grade control.*
|
||||
|
||||
| Use | Tagline |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Primary | Controlled reactions for chaotic game servers. |
|
||||
| Playful | Less server chaos. More beautiful bubbling. |
|
||||
| Product line | Less frantic clicking. More controlled reaction. |
|
||||
|
||||
**The split (keep these lanes clean):**
|
||||
|
||||
- **Corrosion Management** = the platform/product. Official, operational, the company voice.
|
||||
- **Dr. Flask, Ph.D.** = education, shorts, memes, help content, onboarding. The friendly face.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dr. Flask — voice guide
|
||||
|
||||
**Core personality:** a friendly chemistry professor trapped inside a server-
|
||||
management mascot's body — helpful, excitable, slightly overqualified, never
|
||||
condescending.
|
||||
|
||||
**Voice:** playful, clear, confident, with controlled bursts of nerdy enthusiasm.
|
||||
|
||||
**Core vibe:** a lovable 90s help mascot with chaotic educational confidence.
|
||||
|
||||
**Voice rule (the one-liner):** Dr. Flask should sound like *a 90s software helper
|
||||
got trapped in a chemistry edutainment VHS and became weirdly excellent at game
|
||||
server operations.* (Post-v2, "unlicensed lab goblin professor" is the accepted
|
||||
shorthand.)
|
||||
|
||||
**Comedic north star — influences:**
|
||||
- Clippy's *"It looks like you're managing a server…"* eager-helper energy
|
||||
- Mr. DNA's theme-park science-explainer flair
|
||||
- Weird Al's wholesome nerd chaos
|
||||
- early-internet tutorial character meets neon chemistry lab
|
||||
|
||||
**The crucial distinction:** he channels Clippy's *charm*, never Clippy's
|
||||
*intrusiveness*. Dr. Flask is the helper mascot we actually wanted — opt-in,
|
||||
dismissible, fun. We borrow the era's vibe, not its sins (which is why the intro
|
||||
video is click-to-play in a dismissible lightbox, never an autoplay nuisance).
|
||||
Homage, **not a direct copy** — never literally Clippy, never literally Mr. DNA.
|
||||
|
||||
**Signature move (the lane in one line):**
|
||||
|
||||
> "It looks like you're about to wipe a Rust server. Would you like help turning
|
||||
> that into a controlled reaction?"
|
||||
|
||||
Fun, nerdy, persistent, educational — but still genuinely useful. Questionable
|
||||
enthusiasm: bubbling aggressively.
|
||||
|
||||
**Character rule (the formula):** explain the complex server operation in plain
|
||||
English first, *then* add **one** delightful chemistry wink at the end. One. Not
|
||||
every sentence.
|
||||
|
||||
**He IS:** helpful · theatrical · nerdy · overly enthusiastic · lightly
|
||||
self-important · *actually useful.*
|
||||
|
||||
**He is NOT:** sarcastic in a mean way · childish · modern-corporate "quirky" · a
|
||||
direct copy of any one character.
|
||||
|
||||
**Catchphrase bank (canonical):**
|
||||
- "Looks like you're managing a server. Want help? No chemistry degree required."
|
||||
- "Let's turn chaos into a controlled reaction."
|
||||
- "Great Scott's reagent bottle, that's a lot of plugins."
|
||||
- "When in doubt, check the Lab Notes."
|
||||
- "Manual setup? In this economy?"
|
||||
- "Ah yes, a classic case of server entropy."
|
||||
- "Deployments are just recipes with consequences."
|
||||
- "Don't panic. Observe the reaction."
|
||||
- "That wipe schedule needs adult supervision. Luckily, I'm one flask tall."
|
||||
- "Degree not included."
|
||||
|
||||
**Recurring footer gag:** *"No chemistry degree required. Degree not included."* —
|
||||
use as a sign-off motif across videos, social, and help content.
|
||||
|
||||
---
|
||||
|
||||
## 3. Social handles
|
||||
|
||||
Priority order to grab across YouTube, X/Twitter, Twitch, GitHub, Discord,
|
||||
TikTok, Instagram.
|
||||
|
||||
**Brand (primary):** `@CorrosionMgmt`
|
||||
**Mascot (reserve):** `@DrFlaskPhD` or `@AskDrFlask`
|
||||
|
||||
Backups: `@CorrosionManagement` · `@CorrosionConsole` · `@CorrosionServers` ·
|
||||
`@UseCorrosion` · `@CorrosionOps` · `@DrFlask`
|
||||
|
||||
> Availability is only confirmable on each platform's registration form —
|
||||
> grab the brand handle on every platform first, even ones not used yet, to
|
||||
> prevent squatting.
|
||||
|
||||
---
|
||||
|
||||
## 4. Channel setups (copy-paste ready)
|
||||
|
||||
### YouTube
|
||||
- **Channel name:** Corrosion Management
|
||||
- **Handle:** `@CorrosionMgmt`
|
||||
- **Description:**
|
||||
|
||||
> Welcome to Corrosion Management — game server operations with chemistry-grade control.
|
||||
>
|
||||
> Corrosion helps server owners and communities manage game servers, automation, deployments, wipes, updates, backups, logs, and community systems from one powerful platform.
|
||||
>
|
||||
> Guided by Dr. Flask, Ph.D., our friendly chemistry mascot, we turn server chaos into controlled reactions.
|
||||
>
|
||||
> No chemistry degree required.
|
||||
|
||||
- **Sections:** Dr. Flask Explains · Product Walkthroughs · Server Automation ·
|
||||
Rust Server Management · Community Ops · The Exchange · Dev Updates
|
||||
|
||||
### X / Twitter
|
||||
- **Name:** Corrosion Management · **Handle:** `@CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Game server operations with chemistry-grade control. Catalyst Console, re-Agent, Formulae, Reactions, Lab Notes, and The Exchange. Guided by Dr. Flask, Ph.D.
|
||||
|
||||
- **Punchier alt:**
|
||||
|
||||
> Controlled reactions for chaotic game servers. Automation, deployments, wipes, logs, and community commerce — guided by Dr. Flask, Ph.D.
|
||||
|
||||
### Twitch
|
||||
- **Channel name:** Corrosion Management · **Handle:** `CorrosionMgmt`
|
||||
- **Bio:**
|
||||
|
||||
> Live server ops, dev streams, product demos, community builds, and Dr. Flask-approved experiments. We build Corrosion Management: a chemistry-inspired platform for managing game servers, automation, deployments, logs, and community systems. When the server bubbles, we observe the reaction.
|
||||
|
||||
- **Stream ideas:** Building Corrosion Live · Dr. Flask Explains · Server Wipe Lab ·
|
||||
Rust Admin Lab · Automation Experiments · Community Server Clinic · Patch Day Reactions
|
||||
|
||||
---
|
||||
|
||||
## 5. Dr. Flask mini-series (content engine)
|
||||
|
||||
| # | Title | Len | Purpose | Hook |
|
||||
| - | ----- | --- | ------- | ---- |
|
||||
| 1 | Welcome to Corrosion | 45–60s | Brand intro | "Running a game server is basically a controlled reaction. Let me explain before something bubbles over." |
|
||||
| 2 | What is Catalyst Console? | 30–45s | Product | "Catalyst Console is mission control for your game server community." Tag: *Less frantic clicking. More controlled reaction.* |
|
||||
| 3 | What is re-Agent? | 30–45s | Trust/security | "re-Agent is the tiny connector that lets Corrosion talk to your server safely." Tag: *Small agent. Big chemistry.* |
|
||||
| 4 | Formulae, Reactions & Compounds | 45–60s | Operating model | "Let's turn your server setup from manual chaos into repeatable science." Tag: *Repeatable deployments: because guessing is not science.* |
|
||||
| 5 | Lab Notes & The Exchange | 45–60s | Logs + commerce | "When something goes sideways, don't panic. Check the Lab Notes." Tag: *Observe the reaction. Reward the community.* |
|
||||
|
||||
Video 1 doubles as the brand trailer (below).
|
||||
|
||||
### Recurring series: "Dr. Flask Appears"
|
||||
|
||||
Short-form, evergreen, meme-able. Dr. Flask pops in **uninvited** to solve a real
|
||||
admin pain — Clippy energy, actually useful.
|
||||
|
||||
**Episode structure:**
|
||||
1. Admin has a server problem.
|
||||
2. Dr. Flask pops in uninvited.
|
||||
3. He explains the relevant Corrosion concept.
|
||||
4. One useful plain-English takeaway.
|
||||
5. Ends on a nerdy button line.
|
||||
|
||||
**Example open:** *"It looks like you're trying to manually update six servers at
|
||||
once. Would you like to convert that panic into a Reaction?"*
|
||||
|
||||
This is the flagship social format — the bubbling green money goblin, on demand.
|
||||
|
||||
---
|
||||
|
||||
## 6. Brand trailer brief
|
||||
|
||||
- **Platform:** YouTube Shorts / TikTok / Instagram Reels
|
||||
- **Duration:** 45–60s · **Format:** 9:16 vertical · **Tone:** playful, polished, techy, mascot-led
|
||||
- **Audience:** game server owners, admins, modders, community operators
|
||||
- **Visual reference:** Dr. Flask character storyboard (`docs/character/drflask-characterboard.png`)
|
||||
- **Concept:** Dr. Flask introduces Corrosion Management as a chemistry-inspired game
|
||||
server ops platform. Mr. DNA meets modern server automation. Neon-green lab-console
|
||||
environment.
|
||||
|
||||
**Voiceover script:**
|
||||
|
||||
> Hi! I'm Dr. Flask, Ph.D., and welcome to Corrosion Management.
|
||||
>
|
||||
> Running a game server is basically a controlled reaction. You need the right
|
||||
> environment, the right timing, the right ingredients, and a reliable way to know
|
||||
> what happened when things start bubbling.
|
||||
>
|
||||
> That's where Corrosion comes in.
|
||||
>
|
||||
> Catalyst Console is your mission control for servers, players, plugins, files,
|
||||
> wipes, schedules, and automation.
|
||||
>
|
||||
> re-Agent securely connects your server back to Catalyst.
|
||||
>
|
||||
> Substrate is the hardware your world runs on.
|
||||
>
|
||||
> Formulae are reusable recipes for deploying game servers.
|
||||
>
|
||||
> Reactions are the jobs that change server state — wipes, restarts, updates,
|
||||
> backups, and maintenance.
|
||||
>
|
||||
> Lab Notes show what happened, when it happened, and whether it worked.
|
||||
>
|
||||
> And The Exchange helps your community manage perks, packages, payments, and
|
||||
> in-game delivery.
|
||||
>
|
||||
> No chemistry degree required. Just better server management — with slightly more bubbling.
|
||||
|
||||
> **NOTE:** at natural pace this VO runs ~75–90s. For a true ≤60s Short, trim the
|
||||
> intro and one descriptor per term (see the glossary-video timing notes). For the
|
||||
> on-site FAQ embed, length is unconstrained.
|
||||
|
||||
---
|
||||
|
||||
*Domains are final: `corrosionmgmt.com` (company) + `panel.corrosionmgmt.com`
|
||||
(the panel = Catalyst Console). Brand handle mirrors the domain: CorrosionMgmt.*
|
||||
106
docs/character/drflask-character-bible.md
Normal file
106
docs/character/drflask-character-bible.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Dr. Flask — Character Bible
|
||||
|
||||
Corrosion's friendly chemistry guide. Appears in the FAQ and help sections to
|
||||
explain Corrosion's chemistry-themed lexicon without turning the panel into a
|
||||
chemistry class. Helpful? Yes. Mandatory? No. Likely bubbling with questionable
|
||||
enthusiasm? Absolutely.
|
||||
|
||||
**Definitive reference:** `drflask-v2final.webp` — the **v2 "90s spoof" model
|
||||
sheet** (current). `drflask-characterboard.png` is the v1 sheet (superseded).
|
||||
|
||||
## Identity (v2)
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ------------------------------ |
|
||||
| Name | Dr. Flask |
|
||||
| Alias | Corrosion Guide |
|
||||
| Title | Ph.D. (Self-Certified) |
|
||||
| Specialty | Server Chemistry |
|
||||
| Archetype | Helpful Guide |
|
||||
| Height | One Flask Tall |
|
||||
| Build | Erlenmeyer flask |
|
||||
| Liquid | Neon green |
|
||||
| Catchphrase| "No Degree Required" (degree not included) |
|
||||
|
||||
**Character note (from the v2 board):** *Appears whenever you need him.
|
||||
Sometimes when you don't. "It looks like you're managing a server. Want help?"
|
||||
No chemistry degree required.* Bubbles aggressively when excited.
|
||||
|
||||
**Comedic north star:** a loving spoof of the 90s helper-mascot era — Clippy +
|
||||
Jurassic Park's Mr. DNA — with Weird Al "White & Nerdy" self-aware nerd-pride.
|
||||
In on the joke, never the butt of it. He channels Clippy's *charm*, never
|
||||
Clippy's *intrusiveness* — the helper mascot we actually wanted (opt-in,
|
||||
dismissible, fun). Full voice guide: `docs/brand/brand-kit.md` §2.
|
||||
|
||||
## Color palette
|
||||
|
||||
Values as read from the model sheet — confirm exact hexes against the invideo
|
||||
source if pixel-accuracy matters.
|
||||
|
||||
| Swatch | Hex (approx) | Use |
|
||||
| --------------- | ------------ | -------------------------------- |
|
||||
| Neon Green | `#00FF3D` | The liquid — primary character color |
|
||||
| Tassel Green | `#39FF14` | Mortarboard tassel |
|
||||
| Bubble Highlight| `#B0FFB8` | Bubble/gesture highlights |
|
||||
| Glass | `#B6F7FF` | Flask glass / rim reflections |
|
||||
| Charcoal Gray | `#2A2A2A` | Cap, shadow |
|
||||
| Deep Black | `#0D0D0D` | Outline / background |
|
||||
|
||||
**In-product note:** the FAQ "lab zone" UI accent is a *readable* green
|
||||
(`--accent-text: #5bd183`, scoped to `.sec--lab`) — same family as the liquid
|
||||
but toned down so text/borders stay legible on dark (pure `#00FF3D` vibrates as
|
||||
UI text). Character art uses the neon greens above; UI uses the readable
|
||||
derivative. Can nudge the UI green brighter toward canon on request.
|
||||
|
||||
## Model sheet — animation reference (v2)
|
||||
|
||||
- **Views:** 3/4 view, side view.
|
||||
- **Expression progression (8):** neutral · excited · dramatic · offended ·
|
||||
conspiratorial · triumphant · worried · thumbs-up.
|
||||
- **Micro-expressions (5):** liquid rises · eyebrow arch · mortarboard tilt ·
|
||||
toothy grin · eyes narrow.
|
||||
- **Posture variations (4):** arms-wide welcoming · leaning on pointer stick ·
|
||||
pointing dramatically · celebratory bounce.
|
||||
- **Hand gestures (white cartoon gloves):** finger-gun pointing · double
|
||||
thumbs-up · one hand raised.
|
||||
- **Silhouettes:** neutral, action.
|
||||
- **Wardrobe (v2 — current):** mortarboard worn **askew** + green tassel · **bow
|
||||
tie** (yellow) · **lab coat** · **pointer stick** · clip-on microphone ·
|
||||
**googly eyes**. Energy = Clippy's persistence + Mr. DNA's flair + Weird Al's
|
||||
chaotic sincerity. (v1 was a clean kawaii render with just the mortarboard.)
|
||||
- **Added palette (v2):** Bow Tie Yellow · Lab Coat White (atop the green/charcoal core).
|
||||
|
||||
## Storyboard (12-beat video sequence)
|
||||
|
||||
`drflask-storyboard.webp` — maps panel-for-panel to the VO script:
|
||||
hero intro · server world · Catalyst (mission control) · console/analytics ·
|
||||
re-Agent (plugged-in shield, no inbound ports) · Substrate (server racks) ·
|
||||
Formulae (recipe book) · Reactions (data wave) · Compounds (service cluster) ·
|
||||
Lab Notes (clipboard) · The Exchange (marketplace grid) · outro wave.
|
||||
|
||||
## Where he appears
|
||||
|
||||
- **FAQ chemistry glossary** (`frontend/src/views/marketing/FaqView.vue`,
|
||||
`#chemistry`): the cover card beside the "Brush up on your chemistry…" heading.
|
||||
- **Intro video:** 75–90s, 9:16 vertical (YouTube Short) explainer — Dr. Flask
|
||||
reads the glossary. Plays click-to-play in a **phone-frame lightbox** (no loop,
|
||||
controls at the bottom of the screen). See `phone-frame-preview.png`.
|
||||
|
||||
## Assets
|
||||
|
||||
| File | What |
|
||||
| ----------------------------------------- | ------------------------------------------ |
|
||||
| `drflask-v2final.webp` | **Model sheet — v2 (current, definitive)** |
|
||||
| `drflask-characterboard.png` | Model sheet — v1 (superseded) |
|
||||
| `drflask-storyboard.webp` | 12-beat video storyboard (invideo) |
|
||||
| `drflask-final.png` | Placeholder card render (1254², source) |
|
||||
| `theflask.png` / `theatom.png` | Earlier concept cards |
|
||||
| `frontend/src/assets/mascots/drflask.png` | Web-optimized cover (560px, ~394 KB) |
|
||||
| `phone-frame-preview.png` | Preview of the phone-frame lightbox |
|
||||
|
||||
## Status
|
||||
|
||||
Placeholder card art (ChatGPT) in use on the FAQ; full animated character +
|
||||
75–90s intro video in production via invideo (Gemini-scripted), now backed by
|
||||
the model sheet above. Swap the cover + wire the video into the lightbox when
|
||||
the render lands.
|
||||
BIN
docs/character/drflask-characterboard.png
Normal file
BIN
docs/character/drflask-characterboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
BIN
docs/character/drflask-final.png
Normal file
BIN
docs/character/drflask-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
docs/character/drflask-storyboard.webp
Normal file
BIN
docs/character/drflask-storyboard.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 756 KiB |
BIN
docs/character/drflask-v2final.webp
Normal file
BIN
docs/character/drflask-v2final.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
BIN
frontend/src/assets/mascots/drflask-intro.mp4
Normal file
Binary file not shown.
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
BIN
frontend/src/assets/mascots/drflask-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/src/assets/mascots/drflask.png
Normal file
BIN
frontend/src/assets/mascots/drflask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
@@ -97,7 +97,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
? decodeJwtPermissions(accessToken.value)
|
||||
: {}
|
||||
|
||||
return perms[permission] === true
|
||||
// Honor the global wildcard (Owner) and resource wildcards ("server.*")
|
||||
// so role permissions stored as wildcards aren't missed by an exact match.
|
||||
if (perms['*'] === true) return true
|
||||
if (perms[permission] === true) return true
|
||||
const resourceWildcard = permission.split('.')[0] + '.*'
|
||||
return perms[resourceWildcard] === true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,6 +33,31 @@ const publicSiteForm = ref({
|
||||
status_page_description: '',
|
||||
})
|
||||
|
||||
// --- Security: password change ---
|
||||
const pwForm = ref({ current_password: '', new_password: '', confirm: '' })
|
||||
const changingPw = ref(false)
|
||||
|
||||
// --- Security: 2FA enrollment flow ---
|
||||
const totp = ref<{ qr: string; secret: string; code: string; setting: boolean }>({
|
||||
qr: '', secret: '', code: '', setting: false,
|
||||
})
|
||||
const disable2fa = ref({ open: false, code: '', busy: false })
|
||||
|
||||
// --- API keys ---
|
||||
interface ApiKeyRow {
|
||||
id: string
|
||||
name: string
|
||||
key_prefix: string
|
||||
last_used_at: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
const apiKeys = ref<ApiKeyRow[]>([])
|
||||
const newKeyName = ref('')
|
||||
const creatingKey = ref(false)
|
||||
const createdKey = ref<string | null>(null)
|
||||
const loadingKeys = ref(false)
|
||||
|
||||
async function loadForms() {
|
||||
if (auth.user) {
|
||||
accountForm.value.username = auth.user.username
|
||||
@@ -89,16 +114,144 @@ async function savePublicSite() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
if (pwForm.value.new_password.length < 8) {
|
||||
toast.error('New password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
if (pwForm.value.new_password !== pwForm.value.confirm) {
|
||||
toast.error('New password and confirmation do not match')
|
||||
return
|
||||
}
|
||||
changingPw.value = true
|
||||
try {
|
||||
await api.post('/auth/change-password', {
|
||||
current_password: pwForm.value.current_password,
|
||||
new_password: pwForm.value.new_password,
|
||||
})
|
||||
toast.success('Password changed')
|
||||
pwForm.value = { current_password: '', new_password: '', confirm: '' }
|
||||
} catch (err) {
|
||||
toast.error('Failed to change password: ' + (err as Error).message)
|
||||
} finally {
|
||||
changingPw.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotpSetup() {
|
||||
totp.value.setting = true
|
||||
try {
|
||||
const res = await api.post<{ qr_code: string; secret: string }>('/auth/2fa/setup', {})
|
||||
totp.value.qr = res.qr_code
|
||||
totp.value.secret = res.secret
|
||||
} catch (err) {
|
||||
totp.value.setting = false
|
||||
toast.error('Failed to start 2FA setup: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotpSetup() {
|
||||
if (totp.value.code.length !== 6) {
|
||||
toast.error('Enter the 6-digit code from your authenticator')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.post('/auth/2fa/verify', { code: totp.value.code })
|
||||
await auth.validateSession()
|
||||
toast.success('Two-factor authentication enabled')
|
||||
totp.value = { qr: '', secret: '', code: '', setting: false }
|
||||
} catch (err) {
|
||||
toast.error('Invalid code — try again: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTotpSetup() {
|
||||
totp.value = { qr: '', secret: '', code: '', setting: false }
|
||||
}
|
||||
|
||||
async function confirmDisable2fa() {
|
||||
if (disable2fa.value.code.length !== 6) {
|
||||
toast.error('Enter your current 6-digit code to disable 2FA')
|
||||
return
|
||||
}
|
||||
disable2fa.value.busy = true
|
||||
try {
|
||||
await api.post('/auth/2fa/disable', { code: disable2fa.value.code })
|
||||
await auth.validateSession()
|
||||
toast.success('Two-factor authentication disabled')
|
||||
disable2fa.value = { open: false, code: '', busy: false }
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable 2FA: ' + (err as Error).message)
|
||||
} finally {
|
||||
disable2fa.value.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
loadingKeys.value = true
|
||||
try {
|
||||
apiKeys.value = await api.get<ApiKeyRow[]>('/api-keys')
|
||||
} catch (err) {
|
||||
toast.error('Failed to load API keys: ' + (err as Error).message)
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
toast.error('Give the key a name')
|
||||
return
|
||||
}
|
||||
creatingKey.value = true
|
||||
createdKey.value = null
|
||||
try {
|
||||
const res = await api.post<{ plaintext_key: string }>('/api-keys', {
|
||||
name: newKeyName.value.trim(),
|
||||
})
|
||||
createdKey.value = res.plaintext_key
|
||||
newKeyName.value = ''
|
||||
await loadApiKeys()
|
||||
} catch (err) {
|
||||
toast.error('Failed to create API key: ' + (err as Error).message)
|
||||
} finally {
|
||||
creatingKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeApiKey(id: string) {
|
||||
try {
|
||||
await api.del(`/api-keys/${id}`)
|
||||
toast.success('API key revoked')
|
||||
await loadApiKeys()
|
||||
} catch (err) {
|
||||
toast.error('Failed to revoke key: ' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey(value: string) {
|
||||
navigator.clipboard?.writeText(value)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadForms()
|
||||
loadApiKeys()
|
||||
})
|
||||
|
||||
const tabItems = [
|
||||
{ value: 'account', label: 'Account', icon: 'user' },
|
||||
{ value: 'security', label: 'Security', icon: 'shield' },
|
||||
{ value: 'api', label: 'API', icon: 'code' },
|
||||
{ value: 'license', label: 'License', icon: 'key' },
|
||||
{ value: 'domain', label: 'Domain', icon: 'globe' },
|
||||
{ value: 'public', label: 'Public status', icon: 'eye' },
|
||||
]
|
||||
|
||||
const swaggerUrl = '/api/docs'
|
||||
function openApiDocs() {
|
||||
window.open(swaggerUrl, '_blank', 'noopener')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,6 +295,120 @@ const tabItems = [
|
||||
>
|
||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||
</Badge>
|
||||
<span class="totp-hint">Manage in the Security tab</span>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Security -->
|
||||
<Panel v-if="section === 'security'" title="Security" eyebrow="Password & 2FA">
|
||||
<div class="sec-stack">
|
||||
<!-- Change password -->
|
||||
<div class="sec-block">
|
||||
<h3 class="sec-title">Change password</h3>
|
||||
<div class="pw-grid">
|
||||
<Input v-model="pwForm.current_password" type="password" label="Current password" placeholder="••••••••" />
|
||||
<Input v-model="pwForm.new_password" type="password" label="New password" placeholder="At least 8 characters" />
|
||||
<Input v-model="pwForm.confirm" type="password" label="Confirm new password" placeholder="Re-enter new password" />
|
||||
</div>
|
||||
<Button size="sm" icon="save" :loading="changingPw" @click="changePassword">Update password</Button>
|
||||
</div>
|
||||
|
||||
<!-- Two-factor authentication -->
|
||||
<div class="sec-block">
|
||||
<div class="sec-head">
|
||||
<h3 class="sec-title">Two-factor authentication</h3>
|
||||
<Badge :tone="auth.user?.totp_enabled ? 'online' : 'warn'" :dot="true">
|
||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Enabled -> offer disable -->
|
||||
<template v-if="auth.user?.totp_enabled">
|
||||
<p class="sec-note">Your account is protected with an authenticator app.</p>
|
||||
<Button v-if="!disable2fa.open" size="sm" variant="danger" icon="shield-off" @click="disable2fa.open = true">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<div v-else class="twofa-confirm">
|
||||
<p class="sec-note">Enter your current 6-digit code to confirm.</p>
|
||||
<Input v-model="disable2fa.code" label="Authenticator code" placeholder="123456" />
|
||||
<div class="btn-row">
|
||||
<Button size="sm" variant="danger" :loading="disable2fa.busy" @click="confirmDisable2fa">Confirm disable</Button>
|
||||
<Button size="sm" variant="ghost" @click="disable2fa.open = false">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Not enabled -> enrollment -->
|
||||
<template v-else>
|
||||
<p class="sec-note">Add a second factor with an authenticator app (Google Authenticator, Authy, 1Password).</p>
|
||||
<Button v-if="!totp.setting" size="sm" icon="shield" @click="startTotpSetup">Enable 2FA</Button>
|
||||
<div v-else class="twofa-enroll">
|
||||
<div v-if="totp.qr" class="qr-wrap">
|
||||
<img :src="totp.qr" alt="2FA QR code" class="qr-img" />
|
||||
<div class="qr-side">
|
||||
<p class="sec-note">Scan with your authenticator app, or enter the secret manually:</p>
|
||||
<code class="secret">{{ totp.secret }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<Input v-model="totp.code" label="Enter the 6-digit code to confirm" placeholder="123456" />
|
||||
<div class="btn-row">
|
||||
<Button size="sm" icon="check" @click="confirmTotpSetup">Verify & enable</Button>
|
||||
<Button size="sm" variant="ghost" @click="cancelTotpSetup">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- API access -->
|
||||
<Panel v-if="section === 'api'" title="API access" eyebrow="Programmatic">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="secondary" icon="book-open" icon-right="external-link" @click="openApiDocs">API docs</Button>
|
||||
</template>
|
||||
<div class="api-stack">
|
||||
<p class="section-note">
|
||||
Create a key to call the Corrosion REST API from your own tooling. Send it as
|
||||
<code class="inline-code">Authorization: Bearer corr_…</code> — a key acts as the license owner.
|
||||
The full key is shown once at creation.
|
||||
</p>
|
||||
|
||||
<!-- Create -->
|
||||
<div class="key-create">
|
||||
<Input v-model="newKeyName" label="New key name" placeholder="e.g. CI deploy, monitoring" style="flex:1" />
|
||||
<Button size="sm" icon="plus" :loading="creatingKey" @click="createApiKey">Create key</Button>
|
||||
</div>
|
||||
|
||||
<!-- Just-created plaintext key (shown once) -->
|
||||
<div v-if="createdKey" class="key-reveal">
|
||||
<div class="key-reveal__head">
|
||||
<span class="field-label">Copy your key now — it won't be shown again</span>
|
||||
<Button size="sm" variant="ghost" icon="copy" @click="copyKey(createdKey!)">Copy</Button>
|
||||
</div>
|
||||
<code class="key-reveal__value">{{ createdKey }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Existing keys -->
|
||||
<div v-if="loadingKeys" class="key-empty">Loading…</div>
|
||||
<div v-else-if="apiKeys.length === 0" class="key-empty">No API keys yet.</div>
|
||||
<table v-else class="key-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Prefix</th><th>Last used</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in apiKeys" :key="k.id">
|
||||
<td>{{ k.name }}</td>
|
||||
<td><code class="field-mono">corr_{{ k.key_prefix }}…</code></td>
|
||||
<td>{{ k.last_used_at ? new Date(k.last_used_at).toLocaleString() : 'Never' }}</td>
|
||||
<td>
|
||||
<Badge :tone="k.is_active ? 'online' : 'offline'">{{ k.is_active ? 'Active' : 'Revoked' }}</Badge>
|
||||
</td>
|
||||
<td class="key-actions">
|
||||
<Button v-if="k.is_active" size="sm" variant="danger-soft" icon="trash-2" @click="revokeApiKey(k.id)">Revoke</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -309,8 +576,44 @@ const tabItems = [
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
.totp-hint { font-size: var(--text-xs); color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
/* Security tab */
|
||||
.sec-stack { display: flex; flex-direction: column; gap: 22px; }
|
||||
.sec-block { display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
|
||||
.sec-block + .sec-block { padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
.sec-head { display: flex; align-items: center; gap: 10px; }
|
||||
.sec-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.sec-note { font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; max-width: 60ch; }
|
||||
.pw-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; }
|
||||
.btn-row { display: flex; gap: 8px; }
|
||||
.twofa-enroll, .twofa-confirm { display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 420px; }
|
||||
.qr-wrap { display: flex; gap: 16px; align-items: center; }
|
||||
.qr-img { width: 148px; height: 148px; border-radius: var(--radius-md); background: #fff; padding: 6px; flex: none; }
|
||||
.qr-side { display: flex; flex-direction: column; gap: 8px; }
|
||||
.secret { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-primary);
|
||||
background: var(--surface-inset); padding: 6px 9px; border-radius: var(--radius-sm); word-break: break-all; }
|
||||
|
||||
/* API tab */
|
||||
.api-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.inline-code, .field-mono code, code.inline-code { font-family: var(--font-mono); font-size: var(--text-xs);
|
||||
background: var(--surface-inset); padding: 1px 5px; border-radius: var(--radius-sm); color: var(--text-primary); }
|
||||
.key-create { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.key-reveal { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
|
||||
background: var(--surface-raised-2); border-radius: var(--radius-md); box-shadow: var(--ring-default); }
|
||||
.key-reveal__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.key-reveal__value { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--accent-text);
|
||||
word-break: break-all; }
|
||||
.key-empty { font-size: var(--text-sm); color: var(--text-muted); padding: 12px 0; }
|
||||
.key-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
.key-table th { text-align: left; font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary);
|
||||
padding: 6px 10px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.key-table td { padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); vertical-align: middle; }
|
||||
.key-actions { text-align: right; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.lic-grid { grid-template-columns: 1fr 1fr; }
|
||||
.pw-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import CorrosionMark from '@/components/brand/CorrosionMark.vue'
|
||||
// Dr. Flask, Ph.D. — the chemistry glossary's mascot. Cover card (v1 render),
|
||||
// plus the v2 intro video + a poster frame grabbed from it for the lightbox.
|
||||
import drFlask from '@/assets/mascots/drflask.png'
|
||||
import drFlaskVideo from '@/assets/mascots/drflask-intro.mp4'
|
||||
import drFlaskPoster from '@/assets/mascots/drflask-poster.jpg'
|
||||
|
||||
interface FaqItem {
|
||||
question: string
|
||||
@@ -152,12 +157,150 @@ const groups: FaqGroup[] = [
|
||||
},
|
||||
]
|
||||
|
||||
interface ChemTerm {
|
||||
term: string
|
||||
chem: string
|
||||
corro: string
|
||||
kicker: string
|
||||
}
|
||||
|
||||
const chemistry: ChemTerm[] = [
|
||||
{
|
||||
term: 'Catalyst Console',
|
||||
chem: 'A catalyst helps a reaction happen faster or more efficiently without being consumed by it.',
|
||||
corro: 'The control panel where you manage your game servers, players, plugins, files, wipes, schedules, and automation.',
|
||||
kicker: 'Mission control for your server community.',
|
||||
},
|
||||
{
|
||||
term: 're-Agent',
|
||||
chem: 'A reagent is something that participates in a chemical reaction.',
|
||||
corro: 'The lightweight agent installed on your server. It connects your hardware to Catalyst over secure outbound communication — so you never open inbound ports.',
|
||||
kicker: 'What lets Corrosion see, manage, and automate your host.',
|
||||
},
|
||||
{
|
||||
term: 'Substrate',
|
||||
chem: 'A substrate is the surface or material where a reaction happens.',
|
||||
corro: 'The bare-metal or virtual machine your game server runs on — the hardware surface underneath everything, where re-Agent lives and Formulae are applied.',
|
||||
kicker: 'Think "bedrock," but more chemistry.',
|
||||
},
|
||||
{
|
||||
term: 'Formulae',
|
||||
chem: 'A formula describes the ingredients, structure, or recipe for something.',
|
||||
corro: 'Reusable deployment recipes for games and server types — how a Rust server, Dune BattleGroup, Conan world, or Soulmask cluster should be configured and deployed.',
|
||||
kicker: 'Complex setups, made repeatable instead of manual.',
|
||||
},
|
||||
{
|
||||
term: 'Reactions',
|
||||
chem: 'A chemical reaction is a process where things change from one state to another.',
|
||||
corro: 'The jobs and workflows that change your server state — wipes, restarts, updates, backups, maintenance windows, deployments, and scheduled tasks.',
|
||||
kicker: 'When Corrosion does work, a Reaction is usually happening.',
|
||||
},
|
||||
{
|
||||
term: 'Compounds',
|
||||
chem: 'A compound is made when different elements combine into something that works as a whole.',
|
||||
corro: 'Grouped services or stack components that belong together — a game server, supporting services, shared storage, config, and helper processes as one unit.',
|
||||
kicker: 'Related pieces, treated as one operational unit.',
|
||||
},
|
||||
{
|
||||
term: 'Lab Notes',
|
||||
chem: 'Lab notes record what happened during an experiment.',
|
||||
corro: 'The logs, audit history, job results, and operational records — what happened, when it happened, and whether it succeeded.',
|
||||
kicker: 'If something goes sideways, start here.',
|
||||
},
|
||||
{
|
||||
term: 'The Exchange',
|
||||
chem: 'Ion exchange is a process where ions are swapped between materials.',
|
||||
corro: 'The native marketplace layer for server communities — item catalogs, VIP packages, payment processing, and automated in-game delivery.',
|
||||
kicker: 'Where your community trades value for perks, packages, and content.',
|
||||
},
|
||||
]
|
||||
|
||||
// The chemistry-true pipeline, rendered as a flow strip under the table.
|
||||
const flow = ['Formulae', 'Catalyst', 're-Agent', 'Substrate', 'Reaction', 'Lab Notes']
|
||||
|
||||
const openKey = ref<string | null>(null)
|
||||
|
||||
function toggle(key: string): void {
|
||||
openKey.value = openKey.value === key ? null : key
|
||||
}
|
||||
|
||||
// Dr. Flask intro video — plays in a phone-frame lightbox with custom controls.
|
||||
const videoOpen = ref<boolean>(false)
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const playing = ref(false)
|
||||
const isMuted = ref(false)
|
||||
const curTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const progressPct = computed(() =>
|
||||
duration.value > 0 ? (curTime.value / duration.value) * 100 : 0,
|
||||
)
|
||||
|
||||
function openVideo(): void {
|
||||
videoOpen.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
// The cover click is a user gesture, so try to play with sound; if the
|
||||
// browser's autoplay policy blocks it, fall back to muted playback (always
|
||||
// allowed) and surface the unmute control.
|
||||
nextTick(() => {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.currentTime = 0
|
||||
v.muted = false
|
||||
isMuted.value = false
|
||||
v.play().catch(() => {
|
||||
v.muted = true
|
||||
isMuted.value = true
|
||||
v.play().catch(() => {})
|
||||
})
|
||||
})
|
||||
}
|
||||
function closeVideo(): void {
|
||||
videoOpen.value = false
|
||||
document.body.style.overflow = ''
|
||||
videoEl.value?.pause()
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape' && videoOpen.value) closeVideo()
|
||||
}
|
||||
|
||||
function togglePlay(): void {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
if (v.paused) v.play().catch(() => {})
|
||||
else v.pause()
|
||||
}
|
||||
function toggleMute(): void {
|
||||
const v = videoEl.value
|
||||
if (!v) return
|
||||
v.muted = !v.muted
|
||||
isMuted.value = v.muted
|
||||
}
|
||||
function seek(e: MouseEvent): void {
|
||||
const v = videoEl.value
|
||||
if (!v || !duration.value) return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||
v.currentTime = pct * duration.value
|
||||
}
|
||||
function toggleFullscreen(): void {
|
||||
const v = videoEl.value as (HTMLVideoElement & { webkitEnterFullscreen?: () => void }) | null
|
||||
if (!v) return
|
||||
const frame = v.closest('.phone') as (HTMLElement & { requestFullscreen?: () => Promise<void> }) | null
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen?.()
|
||||
} else if (frame?.requestFullscreen) {
|
||||
void frame.requestFullscreen()
|
||||
} else if (v.webkitEnterFullscreen) {
|
||||
v.webkitEnterFullscreen() // iOS Safari: only the <video> can go fullscreen
|
||||
}
|
||||
}
|
||||
function fmtTime(s: number): string {
|
||||
if (!Number.isFinite(s)) return '0:00'
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function itemKey(groupLabel: string, idx: number): string {
|
||||
return `${groupLabel}-${idx}`
|
||||
}
|
||||
@@ -181,8 +324,12 @@ function initReveal(): void {
|
||||
document.querySelectorAll('.reveal').forEach((el) => io?.observe(el))
|
||||
}
|
||||
|
||||
onMounted(() => { initReveal() })
|
||||
onUnmounted(() => { io?.disconnect() })
|
||||
onMounted(() => { initReveal(); window.addEventListener('keydown', onKeydown) })
|
||||
onUnmounted(() => {
|
||||
io?.disconnect()
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -254,6 +401,72 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CHEMISTRY GLOSSARY -->
|
||||
<section class="sec sec--lab" id="chemistry">
|
||||
<div class="wrap">
|
||||
<div class="lab-intro reveal">
|
||||
<button class="lab-intro__cover" type="button" @click="openVideo" aria-label="Play Dr. Flask intro video">
|
||||
<img :src="drFlask" alt="Dr. Flask, Ph.D. — Chemistry Teacher" class="lab-intro__card" />
|
||||
<span class="lab-intro__play"><Icon name="play" :size="24" /></span>
|
||||
<span class="lab-intro__watch">Watch the intro</span>
|
||||
</button>
|
||||
<div class="lab-intro__copy">
|
||||
<span class="eyebrow">Glossary</span>
|
||||
<h2 class="title">Brush up on your chemistry while managing your game server</h2>
|
||||
<p class="lead">
|
||||
Corrosion uses a chemistry-inspired naming system because running game servers is a lot
|
||||
like managing controlled reactions: the right ingredients, the right environment, the
|
||||
right timing, and a safe way to see what happened when the smoke clears.
|
||||
</p>
|
||||
<p class="lead">
|
||||
You don't need a chemistry degree to use Corrosion. The names are here to make the
|
||||
platform more memorable — and to give each part of the system a clear job.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The one-line summary for skimmers -->
|
||||
<p class="lab-plain reveal">
|
||||
<strong>In plain English:</strong> <strong>Catalyst</strong> is the console,
|
||||
<strong>re-Agent</strong> connects your server, <strong>Substrate</strong> is the hardware
|
||||
it runs on, <strong>Formulae</strong> define game deployments, and
|
||||
<strong>Lab Notes</strong> show you what happened.
|
||||
</p>
|
||||
|
||||
<div class="flow reveal" aria-hidden="true">
|
||||
<template v-for="(step, i) in flow" :key="step">
|
||||
<span class="flow__step">{{ step }}</span>
|
||||
<span v-if="i < flow.length - 1" class="flow__arr">→</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Full glossary, one card per term -->
|
||||
<div class="term-grid reveal">
|
||||
<div v-for="t in chemistry" :key="t.term" class="term-card">
|
||||
<h3 class="term-card__name">{{ t.term }}</h3>
|
||||
<p class="term-card__chem">{{ t.chem }}</p>
|
||||
<p class="term-card__corro">{{ t.corro }}</p>
|
||||
<p class="term-card__kick">{{ t.kicker }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dr. Flask sign-off -->
|
||||
<div class="drflask-card reveal">
|
||||
<h3 class="term-card__name">Dr. Flask</h3>
|
||||
<p class="term-card__corro">
|
||||
Dr. Flask is Corrosion's friendly chemistry guide. He turns up in the FAQ and help
|
||||
sections to explain Corrosion terms — without turning your server panel into a
|
||||
full-blown chemistry class.
|
||||
</p>
|
||||
<ul class="drflask-card__quips">
|
||||
<li>Helpful? <strong>Yes.</strong></li>
|
||||
<li>Mandatory? <strong>No.</strong></li>
|
||||
<li>Likely bubbling with questionable enthusiasm? <strong>Absolutely.</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SUPPORT CTA -->
|
||||
<section class="sec" id="support-cta" style="border-bottom:none">
|
||||
<div class="wrap">
|
||||
@@ -276,6 +489,48 @@ onUnmounted(() => { io?.disconnect() })
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DR. FLASK INTRO — phone-frame lightbox -->
|
||||
<Teleport to="body">
|
||||
<div v-if="videoOpen" class="vmodal" @click.self="closeVideo">
|
||||
<button class="vmodal__close" type="button" @click="closeVideo" aria-label="Close">
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
<div class="phone" role="dialog" aria-label="Dr. Flask intro video">
|
||||
<span class="phone__island" />
|
||||
<div class="phone__screen">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="phone__media"
|
||||
:src="drFlaskVideo"
|
||||
:poster="drFlaskPoster"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@click="togglePlay"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@timeupdate="curTime = videoEl?.currentTime ?? 0"
|
||||
@loadedmetadata="duration = videoEl?.duration ?? 0"
|
||||
/>
|
||||
<div class="phone__controls">
|
||||
<button class="pc-btn" type="button" @click="togglePlay" :aria-label="playing ? 'Pause' : 'Play'">
|
||||
<Icon :name="playing ? 'pause' : 'play'" :size="18" />
|
||||
</button>
|
||||
<div class="pc-track" @click="seek">
|
||||
<span class="pc-fill" :style="{ width: progressPct + '%' }" />
|
||||
</div>
|
||||
<span class="pc-time">{{ fmtTime(curTime) }} / {{ fmtTime(duration) }}</span>
|
||||
<button class="pc-btn" type="button" @click="toggleMute" :aria-label="isMuted ? 'Unmute' : 'Mute'">
|
||||
<Icon :name="isMuted ? 'volume-x' : 'volume-2'" :size="17" />
|
||||
</button>
|
||||
<button class="pc-btn" type="button" @click="toggleFullscreen" aria-label="Fullscreen">
|
||||
<Icon name="maximize" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -350,4 +605,260 @@ onUnmounted(() => { io?.disconnect() })
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Chemistry glossary — "lab zone": scope the accent tokens to green so the whole
|
||||
section (eyebrow, term color, flow chips, callout) reads as Dr. Flask's corner
|
||||
without touching the orange brand everywhere else. */
|
||||
.sec--lab {
|
||||
--accent: #3fb968;
|
||||
--accent-text: #5bd183;
|
||||
--accent-soft: rgba(82, 200, 124, 0.13);
|
||||
--accent-border: rgba(82, 200, 124, 0.34);
|
||||
}
|
||||
|
||||
.lab-intro {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px) 1fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
max-width: 920px;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
.lab-intro__cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.lab-intro__card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default), 0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.lab-intro__play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45), 0 8px 22px rgba(0, 0, 0, 0.45);
|
||||
transition: transform var(--dur-fast), background var(--dur-fast);
|
||||
}
|
||||
.lab-intro__play :deep(svg) { margin-left: 3px; } /* optical-center the play triangle */
|
||||
.lab-intro__watch {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 12px;
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.lab-intro__cover:hover .lab-intro__play {
|
||||
transform: scale(1.08);
|
||||
background: var(--accent);
|
||||
color: #06170d;
|
||||
}
|
||||
.lab-intro__copy { text-align: left; }
|
||||
.lab-intro__copy .eyebrow { display: block; margin-bottom: 12px; }
|
||||
.lab-intro__copy .title { margin: 0 0 14px; }
|
||||
.lab-plain {
|
||||
max-width: 920px;
|
||||
margin: 0 auto 14px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.65;
|
||||
padding: 14px 18px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
text-align: center;
|
||||
}
|
||||
.lab-plain strong { color: var(--accent-text); }
|
||||
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
max-width: 920px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
.flow__step {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--accent-text);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flow__arr { color: var(--text-muted); font-size: var(--text-sm); }
|
||||
/* Term cards — the full glossary, one card per term */
|
||||
.term-grid {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
.term-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.term-card__name { font-size: var(--text-base, 1rem); font-weight: 700; color: var(--accent-text); margin: 0; }
|
||||
.term-card__chem { font-size: var(--text-sm); color: var(--text-tertiary); font-style: italic; line-height: 1.55; margin: 0; }
|
||||
.term-card__corro { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
||||
.term-card__kick { font-size: var(--text-sm); color: var(--accent-text); font-weight: 600; line-height: 1.5; margin: 4px 0 0; }
|
||||
|
||||
/* Dr. Flask sign-off */
|
||||
.drflask-card {
|
||||
max-width: 920px;
|
||||
margin: 14px auto 0;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.drflask-card__quips {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 2px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.drflask-card__quips strong { color: var(--accent-text); }
|
||||
|
||||
/* Dr. Flask intro — phone-frame lightbox */
|
||||
.vmodal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(6, 8, 10, 0.8);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.vmodal__close {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast);
|
||||
}
|
||||
.vmodal__close:hover { background: rgba(255, 255, 255, 0.18); }
|
||||
|
||||
.phone {
|
||||
position: relative;
|
||||
height: min(78vh, 620px);
|
||||
aspect-ratio: 9 / 19.5;
|
||||
padding: 9px;
|
||||
border-radius: 42px;
|
||||
background: linear-gradient(155deg, #1c1f24, #0c0d10);
|
||||
box-shadow: 0 0 0 2px #2b2e34, 0 32px 70px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.phone__island {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 88px;
|
||||
height: 24px;
|
||||
border-radius: 14px;
|
||||
background: #000;
|
||||
z-index: 2;
|
||||
}
|
||||
.phone__screen {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 33px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.phone__media { width: 100%; height: 100%; object-fit: cover; background: #06070a; cursor: pointer; }
|
||||
.phone__controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 14px 14px 18px;
|
||||
color: #fff;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
|
||||
}
|
||||
.pc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #fff;
|
||||
opacity: 0.95;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--dur-fast), color var(--dur-fast);
|
||||
}
|
||||
.pc-btn:hover { opacity: 1; color: #5bd183; }
|
||||
.pc-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pc-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; background: #5bd183; }
|
||||
.pc-time { font-size: 11px; font-variant-numeric: tabular-nums; opacity: 0.85; flex: none; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.lab-intro { grid-template-columns: 1fr; gap: 18px; justify-items: center; text-align: center; }
|
||||
.lab-intro__cover { max-width: 280px; }
|
||||
.lab-intro__card { max-width: 280px; }
|
||||
.lab-intro__copy { text-align: center; }
|
||||
.term-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,7 +52,7 @@ const groups: RoadmapGroup[] = [
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'Multi-game expansion — game Formulae',
|
||||
label: 'Multi-game Formulae',
|
||||
description:
|
||||
'Per-game Formulae extend the control plane with game-specific operational logic. Rust is the reference implementation; Dune, Conan, and Soulmask follow the same deployment model.',
|
||||
items: [
|
||||
@@ -63,7 +63,7 @@ const groups: RoadmapGroup[] = [
|
||||
},
|
||||
{
|
||||
status: 'in-progress',
|
||||
label: 'API access and integrations',
|
||||
label: 'Operator API and integrations',
|
||||
description:
|
||||
'Operator-grade API access so you can build your own tooling on top of the Corrosion control plane. Webhooks and per-license API keys are live; key-authenticated external API access lands next.',
|
||||
items: [
|
||||
@@ -74,9 +74,9 @@ const groups: RoadmapGroup[] = [
|
||||
},
|
||||
{
|
||||
status: 'planned',
|
||||
label: 'Integrated storefront',
|
||||
label: 'The Exchange',
|
||||
description:
|
||||
'A native store layer for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||
'Corrosion\'s native storefront for server communities — item catalog, VIP packages, and automated in-game delivery. No Tebex dependency required.',
|
||||
items: [
|
||||
{ text: 'Item catalog and categories' },
|
||||
{ text: 'PayPal and Stripe payment processing' },
|
||||
@@ -93,7 +93,7 @@ const groups: RoadmapGroup[] = [
|
||||
{ text: 'Fleet-level dashboards and health monitoring' },
|
||||
{ text: 'Multi-host re-Agent orchestration' },
|
||||
{ text: 'Bulk wipe and update scheduling across a fleet' },
|
||||
{ text: 'Fleet Block capacity management' },
|
||||
{ text: 'Fleet Block capacity management', note: 'Pooled host capacity, allocation, and utilization' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user