feat: admin page with dynamic converter loading

Implements full admin interface for managing units and groups.
Migrates converter from static imports to server-side data loading.

- Switch adapter-static → adapter-node
- Add bcryptjs session auth with HMAC-signed cookies
- Add data/units.json and data/config.json data layer
- Add atomic file writes via temp-file rename
- Add public GET /api/units endpoint
- Add auth-gated admin CRUD API for units and groups
- Add two-panel admin UI with group tree and edit forms
- Add YOLO mode toggle for cross-group conversions
- Add visual group dividers in converter results grid
- Update ResultItem type for nullable convertedValue and isYolo flag
- Group deletion supports reassign/orphan with toBase recalculation

Rollback point: 067fd44

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Falkan
2026-03-17 13:55:11 -04:00
parent 067fd445ef
commit d1f51c141e
27 changed files with 1915 additions and 81 deletions

View File

@@ -1,10 +1,11 @@
import { Big } from 'big.js';
import type { Unit } from './types';
import type { Unit, Group } from './types';
/**
* Convert a value from one unit to another using canonical base-unit factors.
* Uses big.js for all arithmetic to avoid floating-point precision loss.
* Pure function — no side effects, no imports of the unit registry.
* Only valid for units in the same group.
*/
export function convert(value: Big, from: Unit, to: Unit): Big {
if (from.id === to.id) return value;
@@ -29,3 +30,29 @@ export function convertById(
if (!to) throw new Error(`Unknown unit id: ${toId}`);
return convert(Big(value), from, to);
}
/**
* Returns true if from and to are in the same group (both non-null and equal).
*/
export function canConvert(from: Unit, to: Unit): boolean {
return from.group !== null && from.group === to.group;
}
/**
* Cross-group "YOLO" conversion using toUniversal scale factors.
* Path: value * from.toBase * fromGroup.toUniversal / toGroup.toUniversal / to.toBase
*/
export function convertYolo(value: Big, from: Unit, to: Unit, groups: Group[]): Big {
if (from.id === to.id) return value;
const fromGroup = groups.find((g) => g.id === from.group);
const toGroup = groups.find((g) => g.id === to.group);
if (!fromGroup || !toGroup) {
// No group info — fall back to raw toBase ratio
return value.times(from.toBase).div(to.toBase);
}
return value
.times(from.toBase)
.times(fromGroup.toUniversal)
.div(toGroup.toUniversal)
.div(to.toBase);
}

63
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import bcrypt from 'bcryptjs';
import { createHmac, timingSafeEqual } from 'crypto';
import { parse } from 'cookie';
import type { AppConfig } from './data';
const BCRYPT_COST = 12;
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_COST);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!hash) return false;
return bcrypt.compare(plain, hash);
}
/**
* Creates an HMAC-signed session token.
* Format: `timestamp.hmac`
*/
export function createSession(secret: string): string {
const timestamp = Date.now().toString();
const hmac = createHmac('sha256', secret).update(timestamp).digest('hex');
return `${timestamp}.${hmac}`;
}
/**
* Verifies an HMAC-signed session token.
* Returns false if format is wrong, HMAC doesn't match, or token is >7 days old.
*/
export function verifySession(token: string, secret: string): boolean {
const parts = token.split('.');
if (parts.length !== 2) return false;
const [timestamp, providedHmac] = parts;
// Check age (7 days)
const ts = parseInt(timestamp, 10);
if (isNaN(ts)) return false;
if (Date.now() - ts > 7 * 24 * 60 * 60 * 1000) return false;
// Verify HMAC using timing-safe comparison
const expectedHmac = createHmac('sha256', secret).update(timestamp).digest('hex');
try {
const a = Buffer.from(providedHmac, 'hex');
const b = Buffer.from(expectedHmac, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
} catch {
return false;
}
}
/**
* Checks the hu_session cookie in the request.
* Returns true if a valid session is present.
*/
export function requireAuth(request: Request, config: AppConfig): boolean {
const cookieHeader = request.headers.get('cookie') ?? '';
const cookies = parse(cookieHeader);
const token = cookies['hu_session'];
if (!token) return false;
return verifySession(token, config.sessionSecret);
}

40
src/lib/server/data.ts Normal file
View File

@@ -0,0 +1,40 @@
import { readFileSync, writeFileSync, renameSync } from 'fs';
import { resolve } from 'path';
import type { UnitsData } from '$lib/types';
// Path is relative to project root (where the server runs from)
const DATA_DIR = resolve('data');
const UNITS_PATH = resolve(DATA_DIR, 'units.json');
const CONFIG_PATH = resolve(DATA_DIR, 'config.json');
export interface AppConfig {
adminPath: string;
passwordHash: string;
sessionSecret: string;
}
/** Read units.json synchronously. Returns parsed UnitsData. */
export function loadData(): UnitsData {
const raw = readFileSync(UNITS_PATH, 'utf8');
return JSON.parse(raw) as UnitsData;
}
/** Write full UnitsData atomically (write to temp, rename). */
export function saveData(data: UnitsData): void {
const tmp = UNITS_PATH + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
renameSync(tmp, UNITS_PATH);
}
/** Read config.json synchronously. */
export function loadConfig(): AppConfig {
const raw = readFileSync(CONFIG_PATH, 'utf8');
return JSON.parse(raw) as AppConfig;
}
/** Write config.json atomically. */
export function saveConfig(config: AppConfig): void {
const tmp = CONFIG_PATH + '.tmp';
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
renameSync(tmp, CONFIG_PATH);
}

View File

@@ -22,19 +22,31 @@ export interface Unit {
*/
toBase: number;
/** Optional grouping tag for multi-group converters. */
group?: string;
/** Group this unit belongs to, or null if orphaned. */
group: string | null;
}
/** A named collection of units sharing a base unit. */
export interface UnitGroup {
/** A group of units sharing a base unit and a universal scale factor. */
export interface Group {
id: string;
label: string;
units: Unit[];
/** ID of the unit that serves as the base (toBase = 1.0) for this group. */
baseUnitId: string;
/** Relative scale for cross-group YOLO conversions. */
toUniversal: number;
}
/** One computed conversion result — unit plus its converted Big value. */
/** Full data payload for units and groups. */
export interface UnitsData {
units: Unit[];
groups: Group[];
}
/** One computed conversion result — unit plus its converted value. */
export interface ResultItem {
unit: Unit;
convertedValue: import('big.js').Big;
/** Converted value, or null if N/A (cross-group with YOLO off). */
convertedValue: import('big.js').Big | null;
/** True if this result used cross-group YOLO conversion. */
isYolo: boolean;
}