refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export type JsonParseResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string }
|
||||
|
||||
export interface SafeJsonParseOptions<T> {
|
||||
fallback?: T
|
||||
silent?: boolean
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface SafeJsonParseWithValidationOptions<T> {
|
||||
fallback: T
|
||||
validator: (data: unknown) => data is T
|
||||
validatorMessage?: string
|
||||
context?: string
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
interface JsonErrorPosition {
|
||||
line?: number
|
||||
column?: number
|
||||
position?: number
|
||||
}
|
||||
|
||||
function extractErrorPosition(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): JsonErrorPosition {
|
||||
if (!(error instanceof Error)) return {}
|
||||
|
||||
const message = error.message
|
||||
|
||||
// Try to extract position from error message
|
||||
// Format 1: "Unexpected token } in JSON at position 15"
|
||||
const positionMatch = message.match(/at position (\d+)/i)
|
||||
if (positionMatch) {
|
||||
const position = parseInt(positionMatch[1], 10)
|
||||
const { line, column } = getLineAndColumn(jsonString, position)
|
||||
return { line, column, position }
|
||||
}
|
||||
|
||||
// Format 2: "JSON.parse: ... at line 2 column 3"
|
||||
const lineColMatch = message.match(/at line (\d+) column (\d+)/i)
|
||||
if (lineColMatch) {
|
||||
return {
|
||||
line: parseInt(lineColMatch[1], 10),
|
||||
column: parseInt(lineColMatch[2], 10),
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getLineAndColumn(
|
||||
text: string,
|
||||
position: number
|
||||
): { line: number; column: number } {
|
||||
const lines = text.substring(0, position).split('\n')
|
||||
return {
|
||||
line: lines.length,
|
||||
column: lines[lines.length - 1].length + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDescription(
|
||||
error: unknown,
|
||||
jsonString: string
|
||||
): string | undefined {
|
||||
if (!(error instanceof Error)) return undefined
|
||||
|
||||
const position = extractErrorPosition(error, jsonString)
|
||||
const message = error.message
|
||||
|
||||
// Check if it's a "missing comma" type error
|
||||
const isMissingCommaError =
|
||||
message.includes("Expected ','") ||
|
||||
message.includes('Expected property name') ||
|
||||
message.includes('Unexpected string')
|
||||
|
||||
if (position.line && position.column) {
|
||||
let hint = ''
|
||||
if (isMissingCommaError && position.line > 1) {
|
||||
hint = ` (check line ${position.line - 1} for missing comma)`
|
||||
}
|
||||
return `Error at line ${position.line}, column ${position.column}: ${message}${hint}`
|
||||
}
|
||||
|
||||
if (position.position !== undefined) {
|
||||
return `Error at position ${position.position}: ${message}`
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
export function safeJsonParse<T = unknown>(
|
||||
value: string | undefined | null,
|
||||
options: SafeJsonParseOptions<T> = {}
|
||||
): T {
|
||||
const { fallback, silent = false, context } = options
|
||||
|
||||
if (!value || value.trim() === '') {
|
||||
return (fallback ?? null) as T
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmedValue) as T
|
||||
} catch (error) {
|
||||
// Log error for debugging in development
|
||||
if (import.meta.env.DEV && !silent) {
|
||||
const message = context
|
||||
? `Failed to parse ${context}`
|
||||
: 'Invalid JSON format'
|
||||
const description = formatErrorDescription(error, trimmedValue)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[JSON Parse Error] ${message}:`, description)
|
||||
}
|
||||
return (fallback ?? null) as T
|
||||
}
|
||||
}
|
||||
|
||||
export function safeJsonParseWithValidation<T>(
|
||||
value: string | undefined | null,
|
||||
options: SafeJsonParseWithValidationOptions<T>
|
||||
): T {
|
||||
const {
|
||||
fallback,
|
||||
validator,
|
||||
validatorMessage,
|
||||
context,
|
||||
silent = false,
|
||||
} = options
|
||||
const parsed = safeJsonParse(value, { fallback, silent: true, context })
|
||||
|
||||
if (!validator(parsed)) {
|
||||
// Log error for debugging in development
|
||||
if (import.meta.env.DEV && !silent) {
|
||||
const message =
|
||||
validatorMessage ??
|
||||
(context ? `Invalid ${context} structure` : 'Invalid data structure')
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[JSON Validation Error] ${message}`, { parsed })
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function tryJsonParse<T = unknown>(
|
||||
value: string | undefined | null
|
||||
): JsonParseResult<T> {
|
||||
if (!value || value.trim() === '') {
|
||||
return { success: false, error: 'Empty value' }
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(value.trim()) as T
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export const isObjectRecord = (
|
||||
data: unknown
|
||||
): data is Record<string, unknown> =>
|
||||
typeof data === 'object' && data !== null && !Array.isArray(data)
|
||||
|
||||
export const isArray = (data: unknown): data is unknown[] => Array.isArray(data)
|
||||
|
||||
export const isStringArray = (data: unknown): data is string[] =>
|
||||
Array.isArray(data) && data.every((item) => typeof item === 'string')
|
||||
|
||||
export const isNumberArray = (data: unknown): data is number[] =>
|
||||
Array.isArray(data) && data.every((item) => typeof item === 'number')
|
||||
|
||||
export const isObjectArray = (
|
||||
data: unknown
|
||||
): data is Record<string, unknown>[] =>
|
||||
Array.isArray(data) && data.every((item) => isObjectRecord(item))
|
||||
|
||||
export function createObjectValidator<T extends Record<string, unknown>>(
|
||||
requiredKeys: (keyof T)[]
|
||||
): (data: unknown) => data is T {
|
||||
return (data): data is T => {
|
||||
if (!isObjectRecord(data)) return false
|
||||
return requiredKeys.every((key) => key in data)
|
||||
}
|
||||
}
|
||||
|
||||
export function createArrayValidator<T>(
|
||||
itemValidator: (item: unknown) => item is T
|
||||
): (data: unknown) => data is T[] {
|
||||
return (data): data is T[] => {
|
||||
if (!Array.isArray(data)) return false
|
||||
return data.every(itemValidator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type {
|
||||
ControllerRenderProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
} from 'react-hook-form'
|
||||
|
||||
/**
|
||||
* Props produced by {@link safeNumberFieldProps} for a native
|
||||
* `<input type="number">`. They are intentionally narrow so consumers can
|
||||
* spread them onto our shared `Input` component without leaking the
|
||||
* react-hook-form internals (e.g. `disabled`) that need overriding per call.
|
||||
*/
|
||||
export type SafeNumberFieldProps = {
|
||||
value: number | ''
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onBlur: () => void
|
||||
name: string
|
||||
ref: (instance: HTMLInputElement | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for binding a react-hook-form numeric field to a native
|
||||
* `<input type="number">` without ever putting `NaN` into form state.
|
||||
*
|
||||
* Why this exists:
|
||||
* - `<input type="number">` reports `valueAsNumber === NaN` whenever the field
|
||||
* is empty or holds an in-progress non-numeric token (e.g. just a minus
|
||||
* sign or a trailing dot). Forwarding `NaN` to `field.onChange` makes Zod
|
||||
* numeric validators (`z.number().min(...)`, `z.coerce.number()`, etc.)
|
||||
* fail at submit time, so `form.handleSubmit` silently refuses to call
|
||||
* `onSubmit` — the save button appears frozen with no toast and no error.
|
||||
* - Numeric inputs should snap back to the previous valid number instead of
|
||||
* keeping `NaN`. We preserve that behaviour by ignoring `NaN`
|
||||
* updates: React's controlled-input reconciliation will restore the last
|
||||
* valid value to the DOM on the next render.
|
||||
*
|
||||
* Display:
|
||||
* - When the underlying state is not a finite number, the prop returns `''`
|
||||
* so the input visibly renders empty instead of literal "NaN".
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <FormField
|
||||
* control={form.control}
|
||||
* name='performance_setting.monitor_cpu_threshold'
|
||||
* render={({ field }) => (
|
||||
* <Input type='number' min={0} {...safeNumberFieldProps(field)} />
|
||||
* )}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function safeNumberFieldProps<
|
||||
TFieldValues extends FieldValues,
|
||||
TName extends FieldPath<TFieldValues>,
|
||||
>(field: ControllerRenderProps<TFieldValues, TName>): SafeNumberFieldProps {
|
||||
const raw = field.value as unknown
|
||||
const display: number | '' =
|
||||
typeof raw === 'number' && Number.isFinite(raw) ? raw : ''
|
||||
|
||||
return {
|
||||
value: display,
|
||||
onChange: (event) => {
|
||||
const next = event.target.valueAsNumber
|
||||
if (Number.isFinite(next)) {
|
||||
;(field.onChange as (value: number) => void)(next)
|
||||
}
|
||||
},
|
||||
onBlur: field.onBlur,
|
||||
name: field.name,
|
||||
ref: field.ref,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { redirect } from '@tanstack/react-router'
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* Create search schema for settings routes with section parameter
|
||||
*/
|
||||
export function createSectionSearchSchema<TSectionId extends string>(
|
||||
sectionIds: readonly [TSectionId, ...TSectionId[]],
|
||||
defaultSection: TSectionId
|
||||
) {
|
||||
return z.object({
|
||||
section: z
|
||||
.enum(sectionIds as unknown as [string, ...string[]])
|
||||
.optional()
|
||||
.catch(defaultSection),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a settings route config
|
||||
*/
|
||||
export type SettingsRouteConfigOptions<
|
||||
TSectionId extends string,
|
||||
TComponent = unknown,
|
||||
> = {
|
||||
/** Section IDs array from section-registry */
|
||||
sectionIds: readonly [TSectionId, ...TSectionId[]]
|
||||
/** Default section ID */
|
||||
defaultSection: TSectionId
|
||||
/** Settings component to render */
|
||||
component: TComponent
|
||||
/** Route path for redirect (e.g., '/system-settings/site') */
|
||||
routePath: string
|
||||
/** Whether to redirect to default section if no section is provided (default: false) */
|
||||
redirectToDefault?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a settings route configuration with common setup
|
||||
* This abstracts the repetitive pattern of:
|
||||
* - Creating search schema
|
||||
* - Setting up validateSearch
|
||||
* - Optionally redirecting to default section
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* export const Route = createFileRoute('/_authenticated/system-settings/site')(
|
||||
* createSettingsRouteConfig({
|
||||
* sectionIds: SITE_SECTION_IDS,
|
||||
* defaultSection: SITE_DEFAULT_SECTION,
|
||||
* component: SiteSettings,
|
||||
* routePath: '/system-settings/site',
|
||||
* redirectToDefault: true,
|
||||
* })
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function createSettingsRouteConfig<
|
||||
TSectionId extends string,
|
||||
TComponent = unknown,
|
||||
>(options: SettingsRouteConfigOptions<TSectionId, TComponent>) {
|
||||
const {
|
||||
sectionIds,
|
||||
defaultSection,
|
||||
component,
|
||||
routePath,
|
||||
redirectToDefault = false,
|
||||
} = options
|
||||
|
||||
const searchSchema = createSectionSearchSchema(sectionIds, defaultSection)
|
||||
|
||||
const routeConfig = {
|
||||
validateSearch: searchSchema,
|
||||
component,
|
||||
...(redirectToDefault && {
|
||||
beforeLoad: ({
|
||||
search,
|
||||
}: {
|
||||
search?: { section?: TSectionId | string }
|
||||
}) => {
|
||||
if (!search?.section) {
|
||||
throw redirect({
|
||||
to: routePath,
|
||||
search: { section: defaultSection } as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
return routeConfig
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Section definition for settings pages
|
||||
*/
|
||||
export type SectionDefinition<TSettings, TExtraArgs extends unknown[] = []> = {
|
||||
id: string
|
||||
titleKey: string
|
||||
build: (settings: TSettings, ...extraArgs: TExtraArgs) => ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Section registry configuration
|
||||
*/
|
||||
export type SectionRegistryConfig<
|
||||
TSectionId extends string,
|
||||
TSettings,
|
||||
TExtraArgs extends unknown[] = [],
|
||||
> = {
|
||||
sections: readonly SectionDefinition<TSettings, TExtraArgs>[]
|
||||
defaultSection: TSectionId
|
||||
basePath: string
|
||||
/** 'query' = `${basePath}?section=${id}`, 'path' = `${basePath}/${id}` */
|
||||
urlStyle?: 'query' | 'path'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a section registry with helper functions
|
||||
*/
|
||||
export function createSectionRegistry<
|
||||
TSectionId extends string,
|
||||
TSettings,
|
||||
TExtraArgs extends unknown[] = [],
|
||||
>(config: SectionRegistryConfig<TSectionId, TSettings, TExtraArgs>) {
|
||||
const { sections, defaultSection, basePath, urlStyle = 'query' } = config
|
||||
|
||||
type SectionId = TSectionId
|
||||
|
||||
const sectionIds = sections.map((section) => section.id) as [
|
||||
SectionId,
|
||||
...SectionId[],
|
||||
]
|
||||
|
||||
/**
|
||||
* Get navigation items for sidebar
|
||||
*/
|
||||
function getSectionNavItems(t: TFunction) {
|
||||
return sections.map((section) => ({
|
||||
title: t(section.titleKey),
|
||||
url:
|
||||
urlStyle === 'path'
|
||||
? `${basePath}/${section.id}`
|
||||
: `${basePath}?section=${section.id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get section content by section ID
|
||||
*/
|
||||
function getSectionContent(
|
||||
sectionId: SectionId,
|
||||
settings: TSettings,
|
||||
...extraArgs: TExtraArgs
|
||||
) {
|
||||
return getSectionMeta(sectionId).build(settings, ...extraArgs)
|
||||
}
|
||||
|
||||
function getSectionMeta(sectionId: SectionId) {
|
||||
const section =
|
||||
sections.find((item) => item.id === sectionId) ?? sections[0]
|
||||
return section
|
||||
}
|
||||
|
||||
return {
|
||||
sectionIds,
|
||||
defaultSection,
|
||||
getSectionNavItems,
|
||||
getSectionContent,
|
||||
getSectionMeta,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user