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:
@@ -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
63
src/lib/server/auth.ts
Normal 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
40
src/lib/server/data.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user