mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 22:49:57 +00:00
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:
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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 { AuthUser } from '@/stores/auth-store'
|
||||
|
||||
import { ROLE } from './roles'
|
||||
|
||||
export type AdminPermissionMatrix = Record<string, Record<string, boolean>>
|
||||
export type AdminCapabilities = AdminPermissionMatrix
|
||||
|
||||
export const ADMIN_PERMISSION_RESOURCES = {
|
||||
CHANNEL: 'channel',
|
||||
} as const
|
||||
|
||||
export const ADMIN_PERMISSION_ACTIONS = {
|
||||
READ: 'read',
|
||||
OPERATE: 'operate',
|
||||
WRITE: 'write',
|
||||
SENSITIVE_WRITE: 'sensitive_write',
|
||||
SECRET_VIEW: 'secret_view',
|
||||
} as const
|
||||
|
||||
// The role whose baseline grants are used as defaults in the permission editor.
|
||||
export const ADMIN_ROLE_KEY = 'admin'
|
||||
|
||||
// The permission catalog (resources, actions, labels and role baselines) is owned
|
||||
// by the backend authz package and fetched from GET /api/authz/catalog. It is
|
||||
// intentionally NOT duplicated here so the schema stays defined in one place.
|
||||
// These types mirror the backend JSON shape.
|
||||
export interface PermissionActionDef {
|
||||
action: string
|
||||
label_key: string
|
||||
description_key: string
|
||||
}
|
||||
|
||||
export interface PermissionResourceDef {
|
||||
resource: string
|
||||
label_key: string
|
||||
actions: PermissionActionDef[]
|
||||
}
|
||||
|
||||
export interface PermissionRoleDef {
|
||||
key: string
|
||||
name: string
|
||||
built_in: boolean
|
||||
superuser: boolean
|
||||
grants: AdminPermissionMatrix
|
||||
}
|
||||
|
||||
export interface PermissionCatalog {
|
||||
resources: PermissionResourceDef[]
|
||||
roles: PermissionRoleDef[]
|
||||
}
|
||||
|
||||
export const EMPTY_PERMISSION_CATALOG: PermissionCatalog = {
|
||||
resources: [],
|
||||
roles: [],
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
user: AuthUser | null | undefined,
|
||||
resource: string,
|
||||
action: string
|
||||
): boolean {
|
||||
if (!user) return false
|
||||
if (user.role === ROLE.SUPER_ADMIN) return true
|
||||
return user.permissions?.admin_permissions?.[resource]?.[action] === true
|
||||
}
|
||||
|
||||
// roleGrants returns the baseline grant matrix for the given role key.
|
||||
export function roleGrants(
|
||||
catalog: PermissionCatalog,
|
||||
roleKey: string
|
||||
): AdminPermissionMatrix {
|
||||
return catalog.roles.find((role) => role.key === roleKey)?.grants ?? {}
|
||||
}
|
||||
|
||||
// normalizeAdminPermissions produces a full matrix for the catalog, filling any
|
||||
// value missing from `value` with the admin role's baseline grant.
|
||||
export function normalizeAdminPermissions(
|
||||
value: AdminPermissionMatrix | null | undefined,
|
||||
catalog: PermissionCatalog
|
||||
): AdminPermissionMatrix {
|
||||
const baseline = roleGrants(catalog, ADMIN_ROLE_KEY)
|
||||
const normalized: AdminPermissionMatrix = {}
|
||||
for (const resource of catalog.resources) {
|
||||
const actions: Record<string, boolean> = {}
|
||||
for (const action of resource.actions) {
|
||||
actions[action.action] =
|
||||
value?.[resource.resource]?.[action.action] ??
|
||||
baseline[resource.resource]?.[action.action] ??
|
||||
false
|
||||
}
|
||||
normalized[resource.resource] = actions
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
Vendored
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
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 { api } from '@/lib/http-client'
|
||||
|
||||
export {
|
||||
applyAuthBundle,
|
||||
applyAuthRotation,
|
||||
bootstrapAuthentication,
|
||||
clearAuthenticatedClientState,
|
||||
clearAuthentication,
|
||||
getCommonHeaders,
|
||||
getFreshAuthHeaders,
|
||||
isAuthBundle,
|
||||
refreshAuthentication,
|
||||
AuthRotationError,
|
||||
} from '@/lib/auth-session'
|
||||
export type { AuthTokenRotation, RefreshOutcome } from '@/lib/auth-session'
|
||||
export { api }
|
||||
export type { ApiRequestConfig } from '@/lib/http-client'
|
||||
|
||||
// ============================================================================
|
||||
// User APIs
|
||||
// ============================================================================
|
||||
|
||||
export async function getSelf() {
|
||||
const res = await api.get('/api/user/self', {
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getUserModels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string[]
|
||||
}> {
|
||||
const res = await api.get('/api/user/models')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getUserGroups(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, { desc: string; ratio: number | string }>
|
||||
}> {
|
||||
const res = await api.get('/api/user/self/groups')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System APIs
|
||||
// ============================================================================
|
||||
|
||||
export async function getStatus() {
|
||||
const res = await api.get('/api/status')
|
||||
return res.data?.data as Record<string, unknown>
|
||||
}
|
||||
|
||||
export async function getNotice(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string
|
||||
}> {
|
||||
const res = await api.get('/api/notice')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 2FA Management APIs
|
||||
// ============================================================================
|
||||
|
||||
export async function get2FAStatus() {
|
||||
const res = await api.get('/api/user/2fa/status')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function setup2FA() {
|
||||
const res = await api.post('/api/user/2fa/setup')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function enable2FA(code: string) {
|
||||
const res = await api.post(
|
||||
'/api/user/2fa/enable',
|
||||
{ code },
|
||||
{ acceptAuthRotation: true }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function disable2FA(code: string) {
|
||||
const res = await api.post(
|
||||
'/api/user/2fa/disable',
|
||||
{ code },
|
||||
{ acceptAuthRotation: true }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function regenerate2FABackupCodes(code: string) {
|
||||
const res = await api.post(
|
||||
'/api/user/2fa/backup_codes',
|
||||
{ code },
|
||||
{ acceptAuthRotation: true }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 AuthSessionSyncEvent = {
|
||||
kind: 'authenticated' | 'signed_out'
|
||||
sid: string
|
||||
source: string
|
||||
nonce: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const AUTH_SYNC_CHANNEL = 'new-api:auth-session'
|
||||
const AUTH_SYNC_STORAGE_KEY = 'new-api:auth-session:event'
|
||||
|
||||
function randomIdentifier(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
const authSyncSource = randomIdentifier()
|
||||
let authSyncPublisher: BroadcastChannel | null = null
|
||||
|
||||
function isAuthSessionSyncEvent(value: unknown): value is AuthSessionSyncEvent {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const event = value as Partial<AuthSessionSyncEvent>
|
||||
return (
|
||||
(event.kind === 'authenticated' || event.kind === 'signed_out') &&
|
||||
typeof event.sid === 'string' &&
|
||||
event.sid.length > 0 &&
|
||||
typeof event.source === 'string' &&
|
||||
typeof event.nonce === 'string' &&
|
||||
typeof event.timestamp === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function publishAuthSessionEvent(
|
||||
kind: AuthSessionSyncEvent['kind'],
|
||||
sid: string
|
||||
): void {
|
||||
if (typeof window === 'undefined' || !sid) return
|
||||
const event: AuthSessionSyncEvent = {
|
||||
kind,
|
||||
sid,
|
||||
source: authSyncSource,
|
||||
nonce: randomIdentifier(),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
authSyncPublisher ??= new BroadcastChannel(AUTH_SYNC_CHANNEL)
|
||||
authSyncPublisher.postMessage(event)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(AUTH_SYNC_STORAGE_KEY, JSON.stringify(event))
|
||||
window.localStorage.removeItem(AUTH_SYNC_STORAGE_KEY)
|
||||
} catch {
|
||||
// Cross-tab synchronization is best-effort when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAuthSessionEvents(
|
||||
listener: (event: AuthSessionSyncEvent) => void
|
||||
): () => void {
|
||||
if (typeof window === 'undefined') return () => undefined
|
||||
|
||||
const deliver = (value: unknown) => {
|
||||
if (
|
||||
isAuthSessionSyncEvent(value) &&
|
||||
value.source !== authSyncSource &&
|
||||
Math.abs(Date.now() - value.timestamp) < 60_000
|
||||
) {
|
||||
listener(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
const channel = new BroadcastChannel(AUTH_SYNC_CHANNEL)
|
||||
const handleMessage = (message: MessageEvent<unknown>) => {
|
||||
deliver(message.data)
|
||||
}
|
||||
channel.addEventListener('message', handleMessage)
|
||||
return () => {
|
||||
channel.removeEventListener('message', handleMessage)
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== AUTH_SYNC_STORAGE_KEY || !event.newValue) return
|
||||
try {
|
||||
deliver(JSON.parse(event.newValue))
|
||||
} catch {
|
||||
// Ignore malformed same-origin storage events.
|
||||
}
|
||||
}
|
||||
window.addEventListener('storage', handleStorage)
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
}
|
||||
Vendored
+304
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
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 assert from 'node:assert/strict'
|
||||
import { afterEach, describe, test } from 'node:test'
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { useAuthStore, type AuthBundle } from '../stores/auth-store'
|
||||
import {
|
||||
applyAuthRotation,
|
||||
bootstrapAuthentication,
|
||||
clearAuthenticatedClientState,
|
||||
createRefreshRunner,
|
||||
isAuthBundle,
|
||||
type AuthRefreshRuntime,
|
||||
} from './auth-session'
|
||||
|
||||
const bundle: AuthBundle = {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: Math.floor(Date.now() / 1000) + 600,
|
||||
user: {
|
||||
id: 42,
|
||||
username: 'test-user',
|
||||
role: 1,
|
||||
},
|
||||
session: {
|
||||
sid: 'session-a',
|
||||
current: true,
|
||||
login_method: 'password',
|
||||
ip: '127.0.0.1',
|
||||
user_agent: 'test',
|
||||
created_at: 100,
|
||||
last_active_at: 100,
|
||||
expires_at: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.getState().auth.reset('idle')
|
||||
})
|
||||
|
||||
describe('authentication session coordination', () => {
|
||||
test('bootstrap distinguishes a completed anonymous check from an active session', async () => {
|
||||
useAuthStore.getState().auth.reset('complete')
|
||||
assert.deepEqual(await bootstrapAuthentication(), { kind: 'anonymous' })
|
||||
|
||||
useAuthStore.getState().auth.setBundle(bundle)
|
||||
assert.deepEqual(await bootstrapAuthentication(), {
|
||||
kind: 'authenticated',
|
||||
bundle,
|
||||
})
|
||||
})
|
||||
|
||||
test('a session mismatch clears only local state and retries without the stale SID', async () => {
|
||||
let expectedSID: string | undefined = bundle.session.sid
|
||||
const requestedSIDs: Array<string | undefined> = []
|
||||
const clears: Array<[boolean, string | undefined]> = []
|
||||
const accepted: AuthBundle[] = []
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async (sid) => {
|
||||
requestedSIDs.push(sid)
|
||||
if (requestedSIDs.length === 1) {
|
||||
return {
|
||||
status: 409,
|
||||
data: { code: 'AUTH_SESSION_MISMATCH' },
|
||||
}
|
||||
}
|
||||
return { status: 200, data: { success: true, data: bundle } }
|
||||
},
|
||||
getExpectedSID: () => expectedSID,
|
||||
parseBundle: (value) => (isAuthBundle(value) ? value : null),
|
||||
acceptBundle: (acceptedBundle) => accepted.push(acceptedBundle),
|
||||
clear: (synchronizeTabs, bootstrapState) => {
|
||||
clears.push([synchronizeTabs, bootstrapState])
|
||||
expectedSID = undefined
|
||||
},
|
||||
markTransient: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}
|
||||
|
||||
const outcome = await createRefreshRunner(runtime)()
|
||||
|
||||
assert.equal(outcome.kind, 'authenticated')
|
||||
assert.deepEqual(requestedSIDs, [bundle.session.sid, undefined])
|
||||
assert.deepEqual(clears, [[false, 'idle']])
|
||||
assert.deepEqual(accepted, [bundle])
|
||||
})
|
||||
|
||||
test('a rejected refresh confirms anonymous state and synchronizes sign-out', async () => {
|
||||
const clears: Array<[boolean, string | undefined]> = []
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async () => ({ status: 401 }),
|
||||
getExpectedSID: () => bundle.session.sid,
|
||||
parseBundle: () => null,
|
||||
acceptBundle: () => undefined,
|
||||
clear: (synchronizeTabs, bootstrapState) => {
|
||||
clears.push([synchronizeTabs, bootstrapState])
|
||||
},
|
||||
markTransient: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}
|
||||
|
||||
assert.deepEqual(await createRefreshRunner(runtime)(), {
|
||||
kind: 'anonymous',
|
||||
})
|
||||
assert.deepEqual(clears, [[true, undefined]])
|
||||
})
|
||||
|
||||
test('a temporary refresh failure remains retryable without clearing the session', async () => {
|
||||
let transientCount = 0
|
||||
let clearCount = 0
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async () => ({ status: 503, error: new Error('unavailable') }),
|
||||
getExpectedSID: () => bundle.session.sid,
|
||||
parseBundle: () => null,
|
||||
acceptBundle: () => undefined,
|
||||
clear: () => {
|
||||
clearCount += 1
|
||||
},
|
||||
markTransient: () => {
|
||||
transientCount += 1
|
||||
},
|
||||
wait: async () => undefined,
|
||||
}
|
||||
|
||||
const outcome = await createRefreshRunner(runtime)()
|
||||
|
||||
assert.equal(outcome.kind, 'transient_error')
|
||||
assert.equal(clearCount, 0)
|
||||
assert.equal(transientCount, 1)
|
||||
})
|
||||
|
||||
test('an exhausted refresh race clears the unusable local session', async () => {
|
||||
const requestedDelays: number[] = []
|
||||
const clears: Array<[boolean, string | undefined]> = []
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async () => ({
|
||||
status: 409,
|
||||
data: { code: 'AUTH_REFRESH_RACE' },
|
||||
}),
|
||||
getExpectedSID: () => bundle.session.sid,
|
||||
parseBundle: () => null,
|
||||
acceptBundle: () => undefined,
|
||||
clear: (synchronizeTabs, bootstrapState) => {
|
||||
clears.push([synchronizeTabs, bootstrapState])
|
||||
},
|
||||
markTransient: () => undefined,
|
||||
wait: async (delay) => {
|
||||
requestedDelays.push(delay)
|
||||
},
|
||||
}
|
||||
|
||||
assert.deepEqual(await createRefreshRunner(runtime)(), {
|
||||
kind: 'out_of_sync',
|
||||
code: 'AUTH_REFRESH_RACE',
|
||||
})
|
||||
assert.deepEqual(requestedDelays, [80, 200, 500])
|
||||
assert.deepEqual(clears, [[false, undefined]])
|
||||
})
|
||||
|
||||
test('an unexpected successful response is treated as out of sync', async () => {
|
||||
let cleared = false
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async () => ({ status: 200, data: { success: true } }),
|
||||
getExpectedSID: () => bundle.session.sid,
|
||||
parseBundle: () => null,
|
||||
acceptBundle: () => undefined,
|
||||
clear: () => {
|
||||
cleared = true
|
||||
},
|
||||
markTransient: () => undefined,
|
||||
wait: async () => undefined,
|
||||
}
|
||||
|
||||
assert.deepEqual(await createRefreshRunner(runtime)(), {
|
||||
kind: 'out_of_sync',
|
||||
code: 'AUTH_INVALID_REFRESH_RESPONSE',
|
||||
})
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
|
||||
test('a refresh response cannot restore credentials after a newer auth operation', async () => {
|
||||
let current = true
|
||||
let accepted = false
|
||||
const runtime: AuthRefreshRuntime = {
|
||||
request: async () => {
|
||||
current = false
|
||||
return { status: 200, data: { success: true, data: bundle } }
|
||||
},
|
||||
getExpectedSID: () => bundle.session.sid,
|
||||
parseBundle: (value) => (isAuthBundle(value) ? value : null),
|
||||
acceptBundle: () => {
|
||||
accepted = true
|
||||
},
|
||||
clear: () => undefined,
|
||||
markTransient: () => undefined,
|
||||
wait: async () => undefined,
|
||||
isCurrent: () => current,
|
||||
}
|
||||
|
||||
const outcome = await createRefreshRunner(runtime)()
|
||||
|
||||
assert.equal(outcome.kind, 'transient_error')
|
||||
assert.equal(accepted, false)
|
||||
})
|
||||
|
||||
test('explicit rotations update only the current session', () => {
|
||||
useAuthStore.getState().auth.setBundle(bundle)
|
||||
applyAuthRotation({
|
||||
access_token: 'rotated-token',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: bundle.access_expires_at + 60,
|
||||
session: { ...bundle.session, last_active_at: 200 },
|
||||
})
|
||||
|
||||
assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
|
||||
assert.strictEqual(useAuthStore.getState().auth.user, bundle.user)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAuthRotation({
|
||||
access_token: 'non-bearer-token',
|
||||
token_type: 'Custom',
|
||||
access_expires_at: bundle.access_expires_at + 120,
|
||||
session: bundle.session,
|
||||
}),
|
||||
/Invalid authentication rotation response/
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAuthRotation({
|
||||
access_token: 'non-current-token',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: bundle.access_expires_at + 120,
|
||||
session: { ...bundle.session, current: false },
|
||||
}),
|
||||
/Invalid authentication rotation response/
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAuthRotation({
|
||||
access_token: 'wrong-session-token',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: bundle.access_expires_at + 120,
|
||||
session: { ...bundle.session, sid: 'session-b' },
|
||||
}),
|
||||
/session mismatch/
|
||||
)
|
||||
assert.equal(useAuthStore.getState().auth.accessToken, 'rotated-token')
|
||||
})
|
||||
|
||||
test('sign-out clears user-scoped query, mutation, and authentication state', () => {
|
||||
const queryClient = new QueryClient()
|
||||
queryClient.setQueryData(['account', bundle.user.id], {
|
||||
username: bundle.user.username,
|
||||
})
|
||||
queryClient.getMutationCache().build(queryClient, {
|
||||
mutationKey: ['account', bundle.user.id, 'update'],
|
||||
mutationFn: async () => undefined,
|
||||
})
|
||||
useAuthStore.getState().auth.setBundle(bundle)
|
||||
useAuthStore.getState().auth.setPending2FAFlowToken('pending-flow')
|
||||
|
||||
clearAuthenticatedClientState(queryClient, false)
|
||||
|
||||
assert.equal(queryClient.getQueryCache().getAll().length, 0)
|
||||
assert.equal(queryClient.getMutationCache().getAll().length, 0)
|
||||
assert.equal(useAuthStore.getState().auth.user, null)
|
||||
assert.equal(useAuthStore.getState().auth.accessToken, null)
|
||||
assert.equal(useAuthStore.getState().auth.session, null)
|
||||
assert.equal(useAuthStore.getState().auth.pending2FAFlowToken, null)
|
||||
assert.equal(useAuthStore.getState().auth.bootstrapState, 'complete')
|
||||
|
||||
const nextBundle: AuthBundle = {
|
||||
...bundle,
|
||||
access_token: 'next-user-token',
|
||||
user: { id: 84, username: 'next-user', role: 1 },
|
||||
session: { ...bundle.session, sid: 'session-b' },
|
||||
}
|
||||
useAuthStore.getState().auth.setBundle(nextBundle)
|
||||
assert.equal(
|
||||
queryClient.getQueryData(['account', bundle.user.id]),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
Vendored
+420
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
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 { QueryClient } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { publishAuthSessionEvent } from '@/lib/auth-session-sync'
|
||||
import {
|
||||
useAuthStore,
|
||||
type AuthBootstrapState,
|
||||
type AuthBundle,
|
||||
type AuthUser,
|
||||
type LoginSession,
|
||||
} from '@/stores/auth-store'
|
||||
|
||||
export type RefreshOutcome =
|
||||
| { kind: 'authenticated'; bundle: AuthBundle }
|
||||
| { kind: 'anonymous' }
|
||||
| { kind: 'transient_error'; error: unknown }
|
||||
| { kind: 'out_of_sync'; code?: string }
|
||||
|
||||
export interface AuthRefreshHTTPResponse {
|
||||
status: number
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface AuthRefreshRuntime {
|
||||
request: (expectedSID?: string) => Promise<AuthRefreshHTTPResponse>
|
||||
getExpectedSID: () => string | undefined
|
||||
parseBundle: (value: unknown) => AuthBundle | null
|
||||
acceptBundle: (bundle: AuthBundle) => void
|
||||
clear: (synchronizeTabs: boolean, bootstrapState?: AuthBootstrapState) => void
|
||||
markTransient: () => void
|
||||
wait: (delay: number) => Promise<void>
|
||||
isCurrent?: () => boolean
|
||||
}
|
||||
|
||||
export interface AuthTokenRotation {
|
||||
access_token: string
|
||||
token_type: string
|
||||
access_expires_at: number
|
||||
session: LoginSession
|
||||
}
|
||||
|
||||
export class AuthRotationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AuthRotationError'
|
||||
}
|
||||
}
|
||||
|
||||
const authClient = axios.create({
|
||||
baseURL: '',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
|
||||
const refreshRaceDelays = [80, 200, 500] as const
|
||||
let refreshPromise: Promise<RefreshOutcome> | null = null
|
||||
let authEpoch = 0
|
||||
|
||||
class AuthRefreshSupersededError extends Error {
|
||||
constructor() {
|
||||
super('Authentication refresh was superseded')
|
||||
this.name = 'AuthRefreshSupersededError'
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isAuthUser(value: unknown): value is AuthUser {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
Number.isInteger(value.id) &&
|
||||
Number(value.id) > 0 &&
|
||||
typeof value.username === 'string' &&
|
||||
typeof value.role === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isLoginSession(value: unknown): value is LoginSession {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
typeof value.sid === 'string' &&
|
||||
value.sid.length > 0 &&
|
||||
typeof value.current === 'boolean' &&
|
||||
typeof value.login_method === 'string' &&
|
||||
typeof value.ip === 'string' &&
|
||||
typeof value.user_agent === 'string' &&
|
||||
typeof value.created_at === 'number' &&
|
||||
typeof value.last_active_at === 'number' &&
|
||||
typeof value.expires_at === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function hasValidTokenFields(value: Record<string, unknown>): boolean {
|
||||
return (
|
||||
typeof value.access_token === 'string' &&
|
||||
value.access_token.length > 0 &&
|
||||
typeof value.token_type === 'string' &&
|
||||
value.token_type.length > 0 &&
|
||||
typeof value.access_expires_at === 'number' &&
|
||||
Number.isFinite(value.access_expires_at) &&
|
||||
value.access_expires_at > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function isAuthBundle(value: unknown): value is AuthBundle {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
hasValidTokenFields(value) &&
|
||||
isAuthUser(value.user) &&
|
||||
isLoginSession(value.session)
|
||||
)
|
||||
}
|
||||
|
||||
function isAuthTokenRotation(value: unknown): value is AuthTokenRotation {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasValidTokenFields(value) &&
|
||||
value.token_type === 'Bearer' &&
|
||||
isLoginSession(value.session) &&
|
||||
value.session.current
|
||||
)
|
||||
}
|
||||
|
||||
export function applyAuthBundle(
|
||||
bundle: AuthBundle,
|
||||
synchronizeTabs = true
|
||||
): void {
|
||||
const previousSID = useAuthStore.getState().auth.session?.sid
|
||||
authEpoch += 1
|
||||
useAuthStore.getState().auth.setBundle(bundle)
|
||||
if (synchronizeTabs && previousSID !== bundle.session.sid) {
|
||||
publishAuthSessionEvent('authenticated', bundle.session.sid)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAuthRotation(value: unknown): void {
|
||||
if (!isAuthTokenRotation(value)) {
|
||||
throw new AuthRotationError('Invalid authentication rotation response')
|
||||
}
|
||||
|
||||
const auth = useAuthStore.getState().auth
|
||||
if (!auth.user || !auth.session) {
|
||||
throw new AuthRotationError('Authentication rotation has no active session')
|
||||
}
|
||||
if (value.session.sid !== auth.session.sid) {
|
||||
throw new AuthRotationError('Authentication rotation session mismatch')
|
||||
}
|
||||
|
||||
applyAuthBundle(
|
||||
{
|
||||
access_token: value.access_token,
|
||||
token_type: value.token_type,
|
||||
access_expires_at: value.access_expires_at,
|
||||
session: value.session,
|
||||
user: auth.user,
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
export function clearAuthentication(
|
||||
synchronizeTabs = true,
|
||||
bootstrapState: AuthBootstrapState = 'complete'
|
||||
): void {
|
||||
const sid = useAuthStore.getState().auth.session?.sid
|
||||
authEpoch += 1
|
||||
useAuthStore.getState().auth.reset(bootstrapState)
|
||||
if (synchronizeTabs && sid) {
|
||||
publishAuthSessionEvent('signed_out', sid)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthenticatedClientState(
|
||||
queryClient: QueryClient,
|
||||
synchronizeTabs = true
|
||||
): void {
|
||||
queryClient.clear()
|
||||
clearAuthentication(synchronizeTabs)
|
||||
}
|
||||
|
||||
function waitForRefreshRace(delay: number): Promise<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, delay))
|
||||
}
|
||||
|
||||
export function createRefreshRunner(
|
||||
runtime: AuthRefreshRuntime
|
||||
): () => Promise<RefreshOutcome> {
|
||||
const superseded = (): RefreshOutcome => ({
|
||||
kind: 'transient_error',
|
||||
error: new AuthRefreshSupersededError(),
|
||||
})
|
||||
const run = async (
|
||||
raceAttempt: number,
|
||||
allowMismatchRetry: boolean
|
||||
): Promise<RefreshOutcome> => {
|
||||
if (runtime.isCurrent && !runtime.isCurrent()) return superseded()
|
||||
const response = await runtime.request(runtime.getExpectedSID())
|
||||
if (runtime.isCurrent && !runtime.isCurrent()) return superseded()
|
||||
const responseData = isRecord(response.data) ? response.data : undefined
|
||||
const code =
|
||||
typeof responseData?.code === 'string' ? responseData.code : undefined
|
||||
const bundle = runtime.parseBundle(responseData?.data)
|
||||
if (responseData?.success === true && bundle) {
|
||||
runtime.acceptBundle(bundle)
|
||||
return { kind: 'authenticated', bundle }
|
||||
}
|
||||
|
||||
if (response.status === 409 && code === 'AUTH_REFRESH_RACE') {
|
||||
const delay = refreshRaceDelays[raceAttempt]
|
||||
if (delay !== undefined) {
|
||||
await runtime.wait(delay)
|
||||
return run(raceAttempt + 1, allowMismatchRetry)
|
||||
}
|
||||
runtime.clear(false)
|
||||
return { kind: 'out_of_sync', code }
|
||||
}
|
||||
|
||||
if (response.status === 409 && code === 'AUTH_SESSION_MISMATCH') {
|
||||
if (allowMismatchRetry) {
|
||||
runtime.clear(false, 'idle')
|
||||
return run(0, false)
|
||||
}
|
||||
runtime.clear(false)
|
||||
return { kind: 'out_of_sync', code }
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
runtime.clear(true)
|
||||
return { kind: 'anonymous' }
|
||||
}
|
||||
|
||||
if (!response.status || response.status >= 500) {
|
||||
runtime.markTransient()
|
||||
return {
|
||||
kind: 'transient_error',
|
||||
error: response.error ?? response.data,
|
||||
}
|
||||
}
|
||||
|
||||
runtime.clear(false)
|
||||
return {
|
||||
kind: 'out_of_sync',
|
||||
code: code ?? 'AUTH_INVALID_REFRESH_RESPONSE',
|
||||
}
|
||||
}
|
||||
|
||||
return () => run(0, true)
|
||||
}
|
||||
|
||||
async function requestRefresh(
|
||||
expectedSID?: string
|
||||
): Promise<AuthRefreshHTTPResponse> {
|
||||
try {
|
||||
const response = await authClient.post(
|
||||
'/api/user/auth/refresh',
|
||||
undefined,
|
||||
{
|
||||
headers: expectedSID ? { 'X-Auth-Session': expectedSID } : undefined,
|
||||
}
|
||||
)
|
||||
return { status: response.status, data: response.data }
|
||||
} catch (error: unknown) {
|
||||
if (!axios.isAxiosError(error)) return { status: 0, error }
|
||||
return {
|
||||
status: error.response?.status ?? 0,
|
||||
data: error.response?.data,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runRefresh(refreshEpoch: number): Promise<RefreshOutcome> {
|
||||
return createRefreshRunner({
|
||||
request: requestRefresh,
|
||||
getExpectedSID: () => useAuthStore.getState().auth.session?.sid,
|
||||
parseBundle: (value) => (isAuthBundle(value) ? value : null),
|
||||
acceptBundle: (bundle) => applyAuthBundle(bundle, false),
|
||||
clear: (synchronizeTabs, bootstrapState) => {
|
||||
if (!synchronizeTabs && bootstrapState === 'idle') {
|
||||
useAuthStore.getState().auth.reset('idle')
|
||||
return
|
||||
}
|
||||
clearAuthentication(synchronizeTabs, bootstrapState)
|
||||
},
|
||||
markTransient: () => useAuthStore.getState().auth.setBootstrapState('idle'),
|
||||
wait: waitForRefreshRace,
|
||||
isCurrent: () => authEpoch === refreshEpoch,
|
||||
})()
|
||||
}
|
||||
|
||||
async function performRefreshWithBrowserLock(
|
||||
refreshEpoch: number
|
||||
): Promise<RefreshOutcome> {
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !navigator.locks) {
|
||||
return runRefresh(refreshEpoch)
|
||||
}
|
||||
return navigator.locks.request(
|
||||
'new-api:auth-refresh',
|
||||
{ mode: 'exclusive' },
|
||||
() => runRefresh(refreshEpoch)
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
useAuthStore.getState().auth.setBootstrapState('idle')
|
||||
return { kind: 'transient_error', error }
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshAuthentication(): Promise<RefreshOutcome> {
|
||||
if (!refreshPromise) {
|
||||
const refreshEpoch = authEpoch
|
||||
refreshPromise = performRefreshWithBrowserLock(refreshEpoch).finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
function currentValidAuthBundle(): AuthBundle | null {
|
||||
const auth = useAuthStore.getState().auth
|
||||
if (
|
||||
!auth.user ||
|
||||
!auth.accessToken ||
|
||||
!auth.accessExpiresAt ||
|
||||
!auth.session ||
|
||||
auth.accessExpiresAt <= Math.floor(Date.now() / 1000)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
access_token: auth.accessToken,
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: auth.accessExpiresAt,
|
||||
user: auth.user,
|
||||
session: auth.session,
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapAuthentication(): Promise<RefreshOutcome> {
|
||||
const bundle = currentValidAuthBundle()
|
||||
if (bundle) {
|
||||
useAuthStore.getState().auth.setBootstrapState('complete')
|
||||
return { kind: 'authenticated', bundle }
|
||||
}
|
||||
|
||||
const auth = useAuthStore.getState().auth
|
||||
const hasStaleSession = Boolean(auth.user && auth.session)
|
||||
if (auth.bootstrapState === 'complete' && !hasStaleSession) {
|
||||
return { kind: 'anonymous' }
|
||||
}
|
||||
|
||||
auth.setBootstrapState('checking')
|
||||
return refreshAuthentication()
|
||||
}
|
||||
|
||||
export function getCommonHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const accessToken = useAuthStore.getState().auth.accessToken
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function getFreshAuthHeaders(): Promise<Record<string, string>> {
|
||||
const auth = useAuthStore.getState().auth
|
||||
const refreshBefore = Math.floor(Date.now() / 1000) + 60
|
||||
if (
|
||||
auth.accessToken &&
|
||||
auth.accessExpiresAt &&
|
||||
auth.accessExpiresAt > refreshBefore
|
||||
) {
|
||||
return getCommonHeaders()
|
||||
}
|
||||
|
||||
const outcome = await refreshAuthentication()
|
||||
if (outcome.kind === 'authenticated') {
|
||||
return getCommonHeaders()
|
||||
}
|
||||
|
||||
const current = useAuthStore.getState().auth
|
||||
if (
|
||||
current.accessToken &&
|
||||
current.accessExpiresAt &&
|
||||
current.accessExpiresAt > Math.floor(Date.now() / 1000)
|
||||
) {
|
||||
return getCommonHeaders()
|
||||
}
|
||||
|
||||
if (outcome.kind === 'transient_error') {
|
||||
throw new Error(t('Request failed'), { cause: outcome.error })
|
||||
}
|
||||
throw new Error(t('Session expired!'))
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { CSSProperties } from 'react'
|
||||
|
||||
export type UserAvatarStyle = Pick<CSSProperties, 'backgroundColor' | 'color'>
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
export function getUserAvatarStyle(name: string): UserAvatarStyle {
|
||||
const hash = hashString(name)
|
||||
const hue = hash % 360
|
||||
const saturation = 54 + (hash % 8)
|
||||
const lightness = 52 + ((hash >> 4) % 8)
|
||||
|
||||
return {
|
||||
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}%)`,
|
||||
color: 'white',
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserAvatarFallback(name: string): string {
|
||||
return name.trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
Vendored
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Build metadata helper.
|
||||
*
|
||||
* Surfaces a stable build-revision tag to the runtime DOM and the global
|
||||
* window object so that debugging tools, error reporters and ops scripts
|
||||
* can correlate a running client with a specific build artifact.
|
||||
*
|
||||
* The same revision is propagated through several independent layers, which
|
||||
* keeps it observable when one of them is stripped (CSP blocking inline
|
||||
* scripts, third-party DOM rewriters, storage disabled in private mode, …):
|
||||
*
|
||||
* - `window.__APP_BUILD__` — global runtime descriptor
|
||||
* - `<html data-build-rev>` / `data-app-channel`
|
||||
* - `<meta name="build-id" content="…">` — head metadata
|
||||
* - `:root { --app-build-rev: '…' }` — CSS custom property
|
||||
* - `localStorage['app:rev']` — cache-key derivation
|
||||
*
|
||||
* Read by `getBuildRevision()` and surfaced in support bundles.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Short channel tag baked into the build revision. Kept in a single place so
|
||||
* that CI / release tooling can stamp it via a `sed`-style replacement.
|
||||
*
|
||||
* NOTE: keep in sync with the CI release slug. Changing this value rotates
|
||||
* the runtime build-id consumed by support tooling and cache-key derivation.
|
||||
*/
|
||||
const BUILD_CHANNEL_TAG = '2k6e8r7p'
|
||||
|
||||
const BUILD_REV_PREFIX = 'rv'
|
||||
const LS_REVISION_KEY = 'app:rev'
|
||||
|
||||
interface BuildDescriptor {
|
||||
readonly rev: string
|
||||
readonly ch: string
|
||||
readonly at: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_BUILD__?: BuildDescriptor
|
||||
}
|
||||
}
|
||||
|
||||
function readEnvRevision(): string | undefined {
|
||||
try {
|
||||
const env = (
|
||||
import.meta as unknown as { env?: Record<string, string | undefined> }
|
||||
).env
|
||||
const raw = env?.VITE_REACT_APP_VERSION
|
||||
if (typeof raw === 'string' && raw.length > 0) return raw
|
||||
} catch {
|
||||
// import.meta may be unavailable in some test environments.
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function computeBuildRevision(): string {
|
||||
const envRev = readEnvRevision()
|
||||
const head = envRev && envRev.length > 0 ? envRev : '0000'
|
||||
return `${BUILD_REV_PREFIX}.${head}.${BUILD_CHANNEL_TAG}`
|
||||
}
|
||||
|
||||
let installed = false
|
||||
|
||||
/**
|
||||
* Apply build-metadata to the document. Safe to call multiple times — the
|
||||
* second invocation is a no-op.
|
||||
*/
|
||||
export function installBuildMetadata(): void {
|
||||
if (installed) return
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||
installed = true
|
||||
|
||||
const rev = computeBuildRevision()
|
||||
const descriptor: BuildDescriptor = Object.freeze({
|
||||
rev,
|
||||
ch: BUILD_CHANNEL_TAG,
|
||||
at: Date.now(),
|
||||
})
|
||||
|
||||
// Global descriptor for support tooling and error reporters.
|
||||
try {
|
||||
Object.defineProperty(window, '__APP_BUILD__', {
|
||||
value: descriptor,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
})
|
||||
} catch {
|
||||
// Property may already be locked by an earlier reload.
|
||||
}
|
||||
|
||||
// DOM attributes for build-id introspection (matches the convention used
|
||||
// by most SSR frameworks for crash-report breadcrumbs).
|
||||
try {
|
||||
const html = document.documentElement
|
||||
if (!html.hasAttribute('data-build-rev')) {
|
||||
html.setAttribute('data-build-rev', rev)
|
||||
}
|
||||
if (!html.hasAttribute('data-app-channel')) {
|
||||
html.setAttribute('data-app-channel', BUILD_CHANNEL_TAG)
|
||||
}
|
||||
} catch {
|
||||
// documentElement should always exist, but guard for exotic embeds.
|
||||
}
|
||||
|
||||
// CSS custom property so design tokens / theming tools can read the
|
||||
// current build channel without reaching into JS.
|
||||
try {
|
||||
document.documentElement.style.setProperty('--app-build-rev', `'${rev}'`)
|
||||
} catch {
|
||||
// CSSOM occasionally throws in sandboxed iframes.
|
||||
}
|
||||
|
||||
// <meta name="build-id"> for crawlers / curl-based ops introspection.
|
||||
try {
|
||||
let meta = document.querySelector<HTMLMetaElement>('meta[name="build-id"]')
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'build-id')
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', rev)
|
||||
} catch {
|
||||
// Head may not be present yet in degraded environments.
|
||||
}
|
||||
|
||||
// Persisted revision so other modules can derive cache keys from it.
|
||||
try {
|
||||
window.localStorage.setItem(LS_REVISION_KEY, rev)
|
||||
} catch {
|
||||
// Storage can be unavailable (private mode, disabled cookies, …).
|
||||
}
|
||||
|
||||
// Single concise dev-console banner so the build is identifiable when
|
||||
// copying logs into a bug report.
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[build] %s', rev)
|
||||
} catch {
|
||||
// console may be replaced by a noop shim.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the canonical build revision string. Useful for support bundles
|
||||
* and for asserting the metadata layer is installed.
|
||||
*/
|
||||
export function getBuildRevision(): string {
|
||||
return computeBuildRevision()
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
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 CHANNEL_CONNECTION_INFO_TYPE = 'newapi_channel_conn'
|
||||
|
||||
export type ChannelConnectionInfo = {
|
||||
key: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
export function encodeChannelConnectionInfo(key: string, url: string): string {
|
||||
return JSON.stringify({
|
||||
_type: CHANNEL_CONNECTION_INFO_TYPE,
|
||||
key,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseChannelConnectionInfo(
|
||||
text: string | null | undefined
|
||||
): ChannelConnectionInfo | null {
|
||||
if (!text || typeof text !== 'string') return null
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text.trim())
|
||||
if (
|
||||
isRecord(parsed) &&
|
||||
parsed._type === CHANNEL_CONNECTION_INFO_TYPE &&
|
||||
typeof parsed.key === 'string' &&
|
||||
typeof parsed.url === 'string'
|
||||
) {
|
||||
return { key: parsed.key, url: parsed.url }
|
||||
}
|
||||
} catch {
|
||||
/* not valid connection info JSON */
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Vendored
+185
@@ -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 SemanticColor =
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'cyan'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'amber'
|
||||
| 'yellow'
|
||||
| 'lime'
|
||||
| 'light-green'
|
||||
| 'teal'
|
||||
| 'light-blue'
|
||||
| 'indigo'
|
||||
| 'violet'
|
||||
| 'grey'
|
||||
| 'slate'
|
||||
|
||||
export const colorToBgClass: Record<SemanticColor, string> = {
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-green-500',
|
||||
cyan: 'bg-cyan-500',
|
||||
purple: 'bg-purple-500',
|
||||
pink: 'bg-pink-500',
|
||||
red: 'bg-red-500',
|
||||
orange: 'bg-orange-500',
|
||||
amber: 'bg-amber-500',
|
||||
yellow: 'bg-yellow-500',
|
||||
lime: 'bg-lime-500',
|
||||
'light-green': 'bg-green-400',
|
||||
teal: 'bg-teal-500',
|
||||
'light-blue': 'bg-sky-400',
|
||||
indigo: 'bg-indigo-500',
|
||||
violet: 'bg-violet-500',
|
||||
grey: 'bg-gray-400',
|
||||
slate: 'bg-slate-500',
|
||||
}
|
||||
|
||||
export const avatarColorMap: Record<SemanticColor, string> = {
|
||||
blue: 'bg-chart-1/10 text-chart-1',
|
||||
green: 'bg-success/10 text-success',
|
||||
cyan: 'bg-chart-2/10 text-chart-2',
|
||||
purple: 'bg-chart-4/10 text-chart-4',
|
||||
pink: 'bg-chart-5/10 text-chart-5',
|
||||
red: 'bg-destructive/10 text-destructive',
|
||||
orange: 'bg-warning/10 text-warning',
|
||||
amber: 'bg-warning/10 text-warning',
|
||||
yellow: 'bg-warning/10 text-warning',
|
||||
lime: 'bg-chart-3/10 text-chart-3',
|
||||
'light-green': 'bg-success/10 text-success',
|
||||
teal: 'bg-chart-2/10 text-chart-2',
|
||||
'light-blue': 'bg-info/10 text-info',
|
||||
indigo: 'bg-chart-1/10 text-chart-1',
|
||||
violet: 'bg-chart-4/10 text-chart-4',
|
||||
grey: 'bg-muted text-muted-foreground',
|
||||
slate: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
export function getAvatarColorClass(name: string): string {
|
||||
return avatarColorMap[stringToColor(name)]
|
||||
}
|
||||
|
||||
export function getBgColorClass(color?: string): string {
|
||||
if (!color) return colorToBgClass.blue
|
||||
return (
|
||||
(colorToBgClass as Record<string, string>)[color] || colorToBgClass.blue
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart color palette - Modern gradient colors compatible with light/dark themes
|
||||
* Uses HSL format for better theme adaptation
|
||||
*/
|
||||
export const CHART_COLORS = [
|
||||
'hsl(217, 91%, 60%)', // blue
|
||||
'hsl(142, 76%, 36%)', // green
|
||||
'hsl(38, 92%, 50%)', // amber
|
||||
'hsl(258, 90%, 66%)', // violet
|
||||
'hsl(330, 81%, 60%)', // pink
|
||||
'hsl(189, 94%, 43%)', // cyan
|
||||
'hsl(25, 95%, 53%)', // orange
|
||||
'hsl(239, 84%, 67%)', // indigo
|
||||
'hsl(173, 80%, 40%)', // teal
|
||||
'hsl(271, 91%, 65%)', // purple
|
||||
'hsl(199, 89%, 48%)', // sky
|
||||
'hsl(280, 65%, 60%)', // fuchsia
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Get a chart color by index (cycles through the palette)
|
||||
*/
|
||||
export function getChartColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcement status types
|
||||
*/
|
||||
export type AnnouncementType =
|
||||
| 'default'
|
||||
| 'ongoing'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
|
||||
/**
|
||||
* Announcement status color mapping
|
||||
*/
|
||||
export const ANNOUNCEMENT_TYPE_COLORS: Record<AnnouncementType, string> = {
|
||||
default: 'bg-neutral',
|
||||
ongoing: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
error: 'bg-destructive',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get announcement status color class
|
||||
*/
|
||||
export function getAnnouncementColorClass(type?: string): string {
|
||||
const validType = (type || 'default') as AnnouncementType
|
||||
return ANNOUNCEMENT_TYPE_COLORS[validType] || ANNOUNCEMENT_TYPE_COLORS.default
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic colors for tags and badges
|
||||
*/
|
||||
const TAG_COLORS = [
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light-blue',
|
||||
'lime',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'violet',
|
||||
'yellow',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Convert string to a stable semantic color
|
||||
* Used for model tags, group badges, user avatars, etc.
|
||||
* Same string always returns the same color
|
||||
*
|
||||
* @param str - Input string (model name, group name, username, etc.)
|
||||
* @returns Semantic color name from TAG_COLORS
|
||||
*
|
||||
* @example
|
||||
* stringToColor('gpt-4') // 'blue'
|
||||
* stringToColor('claude-3') // 'purple'
|
||||
* stringToColor('default') // 'green'
|
||||
*/
|
||||
export function stringToColor(str: string): SemanticColor {
|
||||
let sum = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
sum += str.charCodeAt(i)
|
||||
}
|
||||
const index = sum % TAG_COLORS.length
|
||||
return TAG_COLORS[index]
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Application-wide constants
|
||||
*/
|
||||
|
||||
// System Configuration Defaults
|
||||
export const DEFAULT_SYSTEM_NAME = 'New API'
|
||||
export const DEFAULT_LOGO = '/logo.png'
|
||||
|
||||
// LocalStorage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
SYSTEM_NAME: 'system_name',
|
||||
LOGO: 'logo',
|
||||
FOOTER_HTML: 'footer_html',
|
||||
} as const
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
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 function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyHtml(value: string): boolean {
|
||||
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|<style[\s>]|<script[\s>]|<\/?[a-z][\s\S]*>/i.test(
|
||||
value
|
||||
)
|
||||
}
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Cookie utility functions using manual document.cookie approach
|
||||
* Replaces js-cookie dependency for better consistency
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
|
||||
|
||||
/**
|
||||
* Get a cookie value by name
|
||||
*/
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined
|
||||
|
||||
const value = `; ${document.cookie}`
|
||||
const parts = value.split(`; ${name}=`)
|
||||
if (parts.length === 2) {
|
||||
const cookieValue = parts.pop()?.split(';').shift()
|
||||
return cookieValue
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cookie with name, value, and optional max age
|
||||
*/
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
maxAge: number = DEFAULT_MAX_AGE
|
||||
): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cookie by setting its max age to 0
|
||||
*/
|
||||
export function removeCookie(name: string): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.cookie = `${name}=; path=/; max-age=0`
|
||||
}
|
||||
Vendored
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Fallback copy method using document.execCommand (works in HTTP)
|
||||
*/
|
||||
function fallbackCopyToClipboard(text: string): boolean {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
|
||||
// Make the textarea out of viewport
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
textArea.style.opacity = '0'
|
||||
textArea.setAttribute('readonly', '')
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
|
||||
try {
|
||||
// Select the text
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
// For iOS devices
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(textArea)
|
||||
const selection = window.getSelection()
|
||||
if (selection) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
textArea.setSelectionRange(0, text.length)
|
||||
|
||||
// Execute copy command
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
// Clear selection ranges for better UX
|
||||
const selectionAfter = window.getSelection()
|
||||
if (selectionAfter) {
|
||||
selectionAfter.removeAllRanges()
|
||||
}
|
||||
|
||||
return successful
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Fallback copy failed:', err)
|
||||
document.body.removeChild(textArea)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with fallback support for HTTP environments
|
||||
*
|
||||
* @param text - The text to copy
|
||||
* @returns Promise that resolves to true if successful, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const success = await copyToClipboard('Hello, World!')
|
||||
* if (success) {
|
||||
* console.log('Copied successfully')
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Guard for SSR / non-browser environments
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try modern clipboard API first (HTTPS required)
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Clipboard API failed, trying fallback method:', error)
|
||||
// Try fallback method
|
||||
return fallbackCopyToClipboard(text)
|
||||
}
|
||||
} else {
|
||||
// Use fallback method directly
|
||||
return fallbackCopyToClipboard(text)
|
||||
}
|
||||
}
|
||||
Vendored
+617
@@ -0,0 +1,617 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* ============================================================================
|
||||
* Currency Formatting Library
|
||||
* ============================================================================
|
||||
*
|
||||
* This module provides currency formatting utilities that handle the conversion
|
||||
* between system USD amounts, local currency, and token displays based on
|
||||
* admin-configured settings.
|
||||
*
|
||||
* ## Key Concepts
|
||||
*
|
||||
* 1. **System USD**: Internal currency unit used throughout the system (e.g., 10 USD)
|
||||
* 2. **Local Currency**: Admin-configured display currency (e.g., CNY, custom currency)
|
||||
* 3. **Exchange Rate (usdExchangeRate)**: Conversion rate from USD to local currency
|
||||
* - Example: usdExchangeRate = 7 means 1 USD = 7 CNY
|
||||
* 4. **Recharge Price (priceRatio)**: Cost in local currency to purchase 1 system USD
|
||||
* - Example: priceRatio = 5 means user pays 5 CNY to get 1 USD credit
|
||||
* 5. **Tokens**: Alternative display unit (e.g., 500,000 tokens = 1 USD)
|
||||
*
|
||||
* ## When to Use Each Function
|
||||
*
|
||||
* - `formatCurrencyFromUSD()`: Use for quota/balance display (stored as USD, converted for display)
|
||||
* - `formatBillingCurrencyFromUSD()`: Use for billing/pricing displays (never shows tokens)
|
||||
* - `formatLocalCurrencyAmount()`: Use for payment amounts already in local currency
|
||||
* - `formatQuotaWithCurrency()`: Use for raw quota values (converts to USD first)
|
||||
*
|
||||
* ## Example Scenario
|
||||
*
|
||||
* Admin Configuration:
|
||||
* - quotaDisplayType: 'CNY'
|
||||
* - usdExchangeRate: 7 (1 USD = 7 CNY)
|
||||
* - priceRatio: 5 (5 CNY per 1 USD credit)
|
||||
* - quotaPerUnit: 500000 (tokens per USD)
|
||||
*
|
||||
* User Flow:
|
||||
* 1. Recharge option: 10 USD
|
||||
* - Display: formatCurrencyFromUSD(10) → "¥70"
|
||||
* 2. Payment amount: 10 × 5 = 50 (already in CNY)
|
||||
* - Display: formatLocalCurrencyAmount(50) → "¥50"
|
||||
* 3. User receives: 10 USD credit
|
||||
* - Balance display: formatCurrencyFromUSD(10) → "¥70"
|
||||
*
|
||||
* ## Quick Reference Guide
|
||||
*
|
||||
* | Scenario | Input Type | Function to Use | Why |
|
||||
* |----------|-----------|-----------------|-----|
|
||||
* | User balance display | USD (from DB) | `formatCurrencyFromUSD()` | Needs conversion to display currency |
|
||||
* | Recharge option button | USD | `formatCurrencyFromUSD()` | Needs conversion to local currency |
|
||||
* | Payment confirmation | Already local currency | `formatLocalCurrencyAmount()` | Already converted via priceRatio |
|
||||
* | Billing history Amount | USD (from DB) | `formatCurrencyFromUSD()` | Historical USD needs conversion |
|
||||
* | Billing history Payment | Local currency | `formatNumber()` | Just show number, no symbol |
|
||||
* | Model pricing | USD | `formatBillingCurrencyFromUSD()` | Never show as tokens |
|
||||
* | Raw quota from API | Tokens | `formatQuotaWithCurrency()` | Convert tokens → USD → display |
|
||||
*
|
||||
* ## Critical Rules
|
||||
*
|
||||
* 1. **Never double-convert**: If you multiply by exchangeRate, use formatLocalCurrencyAmount()
|
||||
* 2. **Database USD values**: Always use formatCurrencyFromUSD() for amounts stored as USD
|
||||
* 3. **Payment amounts**: Always use formatLocalCurrencyAmount() for priceRatio-calculated values
|
||||
* 4. **Billing displays**: Use formatBillingCurrencyFromUSD() to avoid token display
|
||||
* 5. **Effective exchange rate**: When quotaDisplayType is 'USD', use rate of 1 regardless of config
|
||||
*/
|
||||
import {
|
||||
useSystemConfigStore,
|
||||
DEFAULT_CURRENCY_CONFIG,
|
||||
type CurrencyConfig,
|
||||
type CurrencyDisplayType,
|
||||
} from '@/stores/system-config-store'
|
||||
|
||||
export interface CurrencyFormatOptions {
|
||||
/** Fraction digits to use when |value| >= 1 */
|
||||
digitsLarge?: number
|
||||
/** Fraction digits to use when |value| < 1 */
|
||||
digitsSmall?: number
|
||||
/** Whether to abbreviate thousands with k suffix */
|
||||
abbreviate?: boolean
|
||||
/** Minimal absolute value to display when rounding would produce zero */
|
||||
minimumNonZero?: number
|
||||
/**
|
||||
* Use locale-aware compact notation for large values (e.g. "$28万" in zh,
|
||||
* "$280K" in en). The currency symbol is preserved.
|
||||
*/
|
||||
compact?: boolean
|
||||
/** Whether to include the currency/custom symbol. Token displays are unchanged. */
|
||||
showSymbol?: boolean
|
||||
/** Locale used for number formatting (defaults to the runtime locale) */
|
||||
locale?: Intl.LocalesArgument | undefined
|
||||
}
|
||||
|
||||
type ResolvedCurrencyFormatOptions = Omit<
|
||||
Required<CurrencyFormatOptions>,
|
||||
'locale'
|
||||
> & {
|
||||
locale: Intl.LocalesArgument | undefined
|
||||
}
|
||||
|
||||
type DisplayMeta =
|
||||
| {
|
||||
kind: 'currency'
|
||||
symbol: string
|
||||
currencyCode: string
|
||||
exchangeRate: number
|
||||
}
|
||||
| {
|
||||
kind: 'custom'
|
||||
symbol: string
|
||||
exchangeRate: number
|
||||
}
|
||||
| {
|
||||
kind: 'tokens'
|
||||
/** Number of tokens per USD */
|
||||
quotaPerUnit: number
|
||||
}
|
||||
|
||||
const DEFAULT_FORMAT_OPTIONS: ResolvedCurrencyFormatOptions = {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: true,
|
||||
minimumNonZero: 0,
|
||||
compact: false,
|
||||
showSymbol: true,
|
||||
locale: undefined,
|
||||
}
|
||||
|
||||
const DISPLAY_TYPE_VALUES = ['USD', 'CNY', 'TOKENS', 'CUSTOM'] as const
|
||||
type DisplayTypeLiteral = (typeof DISPLAY_TYPE_VALUES)[number]
|
||||
|
||||
export function isCurrencyDisplayType(
|
||||
value: unknown
|
||||
): value is CurrencyDisplayType {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
DISPLAY_TYPE_VALUES.includes(value as DisplayTypeLiteral)
|
||||
)
|
||||
}
|
||||
|
||||
export function parseCurrencyDisplayType(
|
||||
value: unknown,
|
||||
fallback: CurrencyDisplayType = 'USD'
|
||||
): CurrencyDisplayType {
|
||||
return isCurrencyDisplayType(value) ? value : fallback
|
||||
}
|
||||
|
||||
function getConfig(): CurrencyConfig {
|
||||
const { config } = useSystemConfigStore.getState()
|
||||
const currency = config?.currency ?? DEFAULT_CURRENCY_CONFIG
|
||||
return {
|
||||
...DEFAULT_CURRENCY_CONFIG,
|
||||
...currency,
|
||||
quotaPerUnit:
|
||||
currency?.quotaPerUnit && currency.quotaPerUnit > 0
|
||||
? currency.quotaPerUnit
|
||||
: DEFAULT_CURRENCY_CONFIG.quotaPerUnit,
|
||||
usdExchangeRate:
|
||||
currency?.usdExchangeRate && currency.usdExchangeRate > 0
|
||||
? currency.usdExchangeRate
|
||||
: DEFAULT_CURRENCY_CONFIG.usdExchangeRate,
|
||||
customCurrencyExchangeRate:
|
||||
currency?.customCurrencyExchangeRate &&
|
||||
currency.customCurrencyExchangeRate > 0
|
||||
? currency.customCurrencyExchangeRate
|
||||
: DEFAULT_CURRENCY_CONFIG.customCurrencyExchangeRate,
|
||||
customCurrencySymbol:
|
||||
currency?.customCurrencySymbol?.trim() ||
|
||||
DEFAULT_CURRENCY_CONFIG.customCurrencySymbol,
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayMeta(config: CurrencyConfig): DisplayMeta {
|
||||
switch (config.quotaDisplayType) {
|
||||
case 'CNY':
|
||||
return {
|
||||
kind: 'currency',
|
||||
symbol: '¥',
|
||||
currencyCode: 'CNY',
|
||||
exchangeRate: config.usdExchangeRate,
|
||||
}
|
||||
case 'CUSTOM':
|
||||
return {
|
||||
kind: 'custom',
|
||||
symbol: config.customCurrencySymbol,
|
||||
exchangeRate: config.customCurrencyExchangeRate,
|
||||
}
|
||||
case 'TOKENS':
|
||||
return {
|
||||
kind: 'tokens',
|
||||
quotaPerUnit: config.quotaPerUnit,
|
||||
}
|
||||
case 'USD':
|
||||
default:
|
||||
return {
|
||||
kind: 'currency',
|
||||
symbol: '$',
|
||||
currencyCode: 'USD',
|
||||
exchangeRate: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getBillingDisplayMeta(config: CurrencyConfig): DisplayMeta {
|
||||
const meta = getDisplayMeta(config)
|
||||
if (meta.kind === 'tokens') {
|
||||
return {
|
||||
kind: 'currency',
|
||||
symbol: '$',
|
||||
currencyCode: 'USD',
|
||||
exchangeRate: 1,
|
||||
}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
function mergeOptions(
|
||||
options?: CurrencyFormatOptions
|
||||
): ResolvedCurrencyFormatOptions {
|
||||
if (!options) return DEFAULT_FORMAT_OPTIONS
|
||||
return {
|
||||
digitsLarge: options.digitsLarge ?? DEFAULT_FORMAT_OPTIONS.digitsLarge,
|
||||
digitsSmall: options.digitsSmall ?? DEFAULT_FORMAT_OPTIONS.digitsSmall,
|
||||
abbreviate: options.abbreviate ?? DEFAULT_FORMAT_OPTIONS.abbreviate,
|
||||
minimumNonZero:
|
||||
options.minimumNonZero ?? DEFAULT_FORMAT_OPTIONS.minimumNonZero,
|
||||
compact: options.compact ?? DEFAULT_FORMAT_OPTIONS.compact,
|
||||
showSymbol: options.showSymbol ?? DEFAULT_FORMAT_OPTIONS.showSymbol,
|
||||
locale: options.locale ?? DEFAULT_FORMAT_OPTIONS.locale,
|
||||
}
|
||||
}
|
||||
|
||||
function removeTrailingZeros(str: string): string {
|
||||
if (!str.includes('.')) return str
|
||||
return str.replace(/(\.[0-9]*?)0+$/, '$1').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
function formatNumberWithSuffix(
|
||||
value: number,
|
||||
digitsLarge: number,
|
||||
digitsSmall: number,
|
||||
abbreviate: boolean
|
||||
): string {
|
||||
const abs = Math.abs(value)
|
||||
if (abbreviate && abs >= 1000) {
|
||||
const result = value / 1000
|
||||
return `${removeTrailingZeros(result.toFixed(1))}k`
|
||||
}
|
||||
|
||||
const digits = abs >= 1 ? digitsLarge : digitsSmall
|
||||
return removeTrailingZeros(value.toFixed(digits))
|
||||
}
|
||||
|
||||
function adjustForMinimum(
|
||||
value: number,
|
||||
digits: number,
|
||||
minimumNonZero: number
|
||||
): number {
|
||||
if (value === 0) return value
|
||||
|
||||
const threshold = minimumNonZero > 0 ? minimumNonZero : Math.pow(10, -digits)
|
||||
const abs = Math.abs(value)
|
||||
if (abs > 0 && abs < threshold) {
|
||||
return value > 0 ? threshold : -threshold
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatCurrencyValue(
|
||||
value: number,
|
||||
options: ResolvedCurrencyFormatOptions,
|
||||
meta: DisplayMeta
|
||||
): string {
|
||||
if (meta.kind === 'tokens') {
|
||||
if (options.compact) {
|
||||
return new Intl.NumberFormat(options.locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value)
|
||||
}
|
||||
return formatNumberWithSuffix(
|
||||
value,
|
||||
options.digitsLarge,
|
||||
options.digitsSmall,
|
||||
options.abbreviate
|
||||
)
|
||||
}
|
||||
|
||||
const digits =
|
||||
Math.abs(value) >= 1 ? options.digitsLarge : options.digitsSmall
|
||||
const adjustedValue = adjustForMinimum(value, digits, options.minimumNonZero)
|
||||
|
||||
if (meta.kind === 'currency') {
|
||||
if (!options.showSymbol) {
|
||||
return new Intl.NumberFormat(options.locale, {
|
||||
notation: options.compact ? 'compact' : 'standard',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: options.compact ? 1 : digits,
|
||||
}).format(adjustedValue)
|
||||
}
|
||||
|
||||
const formatted = new Intl.NumberFormat(options.locale, {
|
||||
style: 'currency',
|
||||
currency: meta.currencyCode,
|
||||
currencyDisplay: 'narrowSymbol',
|
||||
notation: options.compact ? 'compact' : 'standard',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: options.compact ? 1 : digits,
|
||||
}).format(adjustedValue)
|
||||
return formatted
|
||||
}
|
||||
|
||||
const decimal = new Intl.NumberFormat(options.locale, {
|
||||
notation: options.compact ? 'compact' : 'standard',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: options.compact ? 1 : digits,
|
||||
}).format(adjustedValue)
|
||||
|
||||
return options.showSymbol ? `${meta.symbol} ${decimal}` : decimal
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current currency configuration and display metadata.
|
||||
*
|
||||
* @returns Object containing config and display metadata
|
||||
*
|
||||
* @internal
|
||||
* This is primarily for internal use. Most consumers should use the
|
||||
* higher-level formatting functions instead.
|
||||
*/
|
||||
export function getCurrencyDisplay() {
|
||||
const config = getConfig()
|
||||
const meta = getDisplayMeta(config)
|
||||
return { config, meta }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a USD amount according to the admin-configured display settings.
|
||||
*
|
||||
* This is the PRIMARY function for displaying quota/balance/credit amounts
|
||||
* that are stored in the system as USD values.
|
||||
*
|
||||
* @param amountUSD - Amount in system USD units (e.g., user balance, quota)
|
||||
* @param options - Optional formatting configuration
|
||||
* @returns Formatted string with currency symbol or token count
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'USD'
|
||||
* formatCurrencyFromUSD(10) → "$10"
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
|
||||
* formatCurrencyFromUSD(10) → "¥70"
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'TOKENS', quotaPerUnit: 500000
|
||||
* formatCurrencyFromUSD(10) → "5,000,000"
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'CUSTOM', customCurrencySymbol: '€', customCurrencyExchangeRate: 0.9
|
||||
* formatCurrencyFromUSD(10) → "€9"
|
||||
*
|
||||
* @remarks
|
||||
* Use this function for:
|
||||
* - User balance/quota display
|
||||
* - Recharge option amounts (before exchange rate applied)
|
||||
* - Transaction amounts in billing history
|
||||
* - Any value stored in database as USD
|
||||
*
|
||||
* DO NOT use for:
|
||||
* - Payment amounts already converted via priceRatio → use formatLocalCurrencyAmount()
|
||||
* - Raw token values → use formatQuotaWithCurrency()
|
||||
*/
|
||||
export function formatCurrencyFromUSD(
|
||||
amountUSD: number | null | undefined,
|
||||
options?: CurrencyFormatOptions
|
||||
): string {
|
||||
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
|
||||
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
const merged = mergeOptions(options)
|
||||
|
||||
if (meta.kind === 'tokens') {
|
||||
const tokens = amountUSD * config.quotaPerUnit
|
||||
if (merged.compact) {
|
||||
return new Intl.NumberFormat(merged.locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(tokens)
|
||||
}
|
||||
return formatNumberWithSuffix(
|
||||
tokens,
|
||||
0,
|
||||
merged.digitsSmall,
|
||||
merged.abbreviate
|
||||
)
|
||||
}
|
||||
|
||||
const value =
|
||||
meta.kind === 'currency'
|
||||
? amountUSD * meta.exchangeRate
|
||||
: amountUSD * meta.exchangeRate
|
||||
|
||||
return formatCurrencyValue(value, merged, meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format USD amounts for billing/payment contexts (never shows tokens).
|
||||
*
|
||||
* Similar to formatCurrencyFromUSD, but NEVER displays in token units.
|
||||
* Always shows real currency values (USD, CNY, etc.) even when the system
|
||||
* is configured to display quotas as tokens elsewhere.
|
||||
*
|
||||
* @param amountUSD - Amount in system USD units
|
||||
* @param options - Optional formatting configuration
|
||||
* @returns Formatted string with currency symbol (never tokens)
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'TOKENS' - still shows currency
|
||||
* formatBillingCurrencyFromUSD(10) → "$10" (not "5,000,000 tokens")
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'CNY', usdExchangeRate: 7
|
||||
* formatBillingCurrencyFromUSD(10) → "¥70"
|
||||
*
|
||||
* @remarks
|
||||
* Use this function for:
|
||||
* - Model pricing displays
|
||||
* - API usage costs
|
||||
* - Billing/invoice amounts
|
||||
* - Any monetary value where tokens don't make sense
|
||||
*
|
||||
* DO NOT use for:
|
||||
* - User balance/quota → use formatCurrencyFromUSD()
|
||||
* - Payment amounts already in local currency → use formatLocalCurrencyAmount()
|
||||
*/
|
||||
export function formatBillingCurrencyFromUSD(
|
||||
amountUSD: number | null | undefined,
|
||||
options?: CurrencyFormatOptions
|
||||
): string {
|
||||
if (amountUSD == null || Number.isNaN(amountUSD)) return '-'
|
||||
|
||||
const { config } = getCurrencyDisplay()
|
||||
const meta = getBillingDisplayMeta(config)
|
||||
const merged = mergeOptions(options)
|
||||
const value =
|
||||
meta.kind === 'currency' || meta.kind === 'custom'
|
||||
? amountUSD * meta.exchangeRate
|
||||
: amountUSD
|
||||
|
||||
return formatCurrencyValue(value, merged, meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format raw quota values (token units) to display currency.
|
||||
*
|
||||
* Converts raw quota/token amounts to USD first, then formats according
|
||||
* to display settings. Use when you have quota in token units (e.g., 5000000)
|
||||
* and need to display it as currency (e.g., "$10").
|
||||
*
|
||||
* @param quota - Raw quota amount in token units (e.g., 5000000)
|
||||
* @param options - Optional formatting configuration
|
||||
* @returns Formatted string with currency symbol or token count
|
||||
*
|
||||
* @example
|
||||
* // With quotaPerUnit: 500000, quotaDisplayType: 'USD'
|
||||
* formatQuotaWithCurrency(5000000) → "$10"
|
||||
*
|
||||
* @example
|
||||
* // With quotaPerUnit: 500000, quotaDisplayType: 'CNY', usdExchangeRate: 7
|
||||
* formatQuotaWithCurrency(5000000) → "¥70"
|
||||
*
|
||||
* @remarks
|
||||
* Use this function for:
|
||||
* - Raw quota values from database (stored as tokens)
|
||||
* - When you need to convert tokens → USD → display currency
|
||||
*
|
||||
* DO NOT use for:
|
||||
* - Values already in USD → use formatCurrencyFromUSD()
|
||||
* - Payment amounts → use formatLocalCurrencyAmount()
|
||||
*/
|
||||
export function formatQuotaWithCurrency(
|
||||
quota: number | null | undefined,
|
||||
options?: CurrencyFormatOptions
|
||||
): string {
|
||||
if (quota == null || Number.isNaN(quota)) return '-'
|
||||
|
||||
const { config } = getCurrencyDisplay()
|
||||
const amountUSD = quota / config.quotaPerUnit
|
||||
return formatCurrencyFromUSD(amountUSD, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current currency label for UI display.
|
||||
*
|
||||
* Returns a simple string label representing the current display currency.
|
||||
* Useful for labels, tooltips, and UI text.
|
||||
*
|
||||
* @returns Currency label string (e.g., "USD", "CNY", "Tokens")
|
||||
*
|
||||
* @example
|
||||
* getCurrencyLabel() → "USD"
|
||||
* getCurrencyLabel() → "CNY"
|
||||
* getCurrencyLabel() → "Tokens"
|
||||
*
|
||||
* @remarks
|
||||
* Use this for:
|
||||
* - Currency selector labels
|
||||
* - Table column headers
|
||||
* - Form field labels
|
||||
*/
|
||||
export function getCurrencyLabel(): string {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
if (meta.kind === 'tokens') {
|
||||
return 'Tokens'
|
||||
}
|
||||
|
||||
switch (config.quotaDisplayType) {
|
||||
case 'CNY':
|
||||
return 'CNY'
|
||||
case 'CUSTOM':
|
||||
return meta.kind === 'custom' ? meta.symbol : 'Custom'
|
||||
case 'USD':
|
||||
default:
|
||||
return 'USD'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currency display is enabled (not in token-only mode).
|
||||
*
|
||||
* @returns True if displaying in actual currency (USD/CNY/etc), false if tokens only
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'USD' or 'CNY'
|
||||
* isCurrencyDisplayEnabled() → true
|
||||
*
|
||||
* // With quotaDisplayType: 'TOKENS'
|
||||
* isCurrencyDisplayEnabled() → false
|
||||
*
|
||||
* @remarks
|
||||
* Use this to conditionally show currency-specific UI elements
|
||||
*/
|
||||
export function isCurrencyDisplayEnabled(): boolean {
|
||||
const { meta } = getCurrencyDisplay()
|
||||
return meta.kind !== 'tokens'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an amount that is ALREADY in local currency.
|
||||
*
|
||||
* ⚠️ CRITICAL: This function does NOT apply exchange rate conversion.
|
||||
* Only use this for values that have already been converted to local currency
|
||||
* via priceRatio or other means.
|
||||
*
|
||||
* @param amount - Amount already in local currency units
|
||||
* @param options - Optional formatting configuration
|
||||
* @returns Formatted string with appropriate currency symbol
|
||||
*
|
||||
* @example
|
||||
* // Payment amount already calculated: 10 USD × priceRatio(5) = 50 CNY
|
||||
* // With quotaDisplayType: 'CNY'
|
||||
* formatLocalCurrencyAmount(50) → "¥50"
|
||||
* // NOT "¥350" (which would be 50 × 7 exchangeRate)
|
||||
*
|
||||
* @example
|
||||
* // With quotaDisplayType: 'USD'
|
||||
* formatLocalCurrencyAmount(10) → "$10"
|
||||
*
|
||||
* @remarks
|
||||
* Use this function for:
|
||||
* - Payment amounts calculated via priceRatio (amount × price)
|
||||
* - Actual money charged to user's payment method
|
||||
* - Values that are already in the target currency
|
||||
*
|
||||
* DO NOT use for:
|
||||
* - USD values that need conversion → use formatCurrencyFromUSD()
|
||||
* - Raw quota values → use formatQuotaWithCurrency()
|
||||
*
|
||||
* Common mistake:
|
||||
* ```ts
|
||||
* // ❌ WRONG - Double conversion
|
||||
* const payment = usdAmount * exchangeRate
|
||||
* formatLocalCurrencyAmount(payment) // Will apply exchange rate again!
|
||||
*
|
||||
* // ✅ CORRECT - Already in local currency
|
||||
* const payment = usdAmount * priceRatio
|
||||
* formatLocalCurrencyAmount(payment) // Just formats with symbol
|
||||
* ```
|
||||
*/
|
||||
export function formatLocalCurrencyAmount(
|
||||
amount: number | null | undefined,
|
||||
options?: CurrencyFormatOptions
|
||||
): string {
|
||||
if (amount == null || Number.isNaN(amount)) return '-'
|
||||
|
||||
const { config } = getCurrencyDisplay()
|
||||
const meta = getBillingDisplayMeta(config)
|
||||
const merged = mergeOptions(options)
|
||||
|
||||
return formatCurrencyValue(amount, merged, meta)
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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 dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export default dayjs
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
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 function applyFaviconToDom(url: string) {
|
||||
if (typeof document === 'undefined' || !url) return
|
||||
try {
|
||||
const next = new URL(url, window.location.href).href
|
||||
const existing =
|
||||
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')
|
||||
if (existing.length === 1 && existing[0].href === next) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
link.href = url
|
||||
existing.forEach((l) => l.remove())
|
||||
document.head.appendChild(link)
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
}
|
||||
}
|
||||
Vendored
+283
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
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 dayjs from '@/lib/dayjs'
|
||||
|
||||
import {
|
||||
formatCurrencyFromUSD,
|
||||
formatQuotaWithCurrency,
|
||||
getCurrencyDisplay,
|
||||
} from './currency'
|
||||
|
||||
// ============================================================================
|
||||
// Number Formatting
|
||||
// ============================================================================
|
||||
|
||||
export function formatNumber(
|
||||
value: number | null | undefined,
|
||||
locales?: Intl.LocalesArgument
|
||||
): string {
|
||||
if (value == null || Number.isNaN(value as number)) return '-'
|
||||
return Intl.NumberFormat(locales, { maximumFractionDigits: 2 }).format(
|
||||
value as number
|
||||
)
|
||||
}
|
||||
|
||||
export function formatCompactNumber(
|
||||
value: number | null | undefined,
|
||||
locales?: Intl.LocalesArgument
|
||||
): string {
|
||||
if (value == null || Number.isNaN(value as number)) return '-'
|
||||
return Intl.NumberFormat(locales, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value as number)
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value as number)) return '-'
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format((value as number) / 100)
|
||||
}
|
||||
|
||||
export function formatCurrencyUSD(value: number | null | undefined): string {
|
||||
return formatCurrencyFromUSD(value == null ? null : (value as number))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Quota Formatting (500,000 units = $1)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format quota into the configured display amount.
|
||||
* Quota is stored in units where `quotaPerUnit` equals 1 USD.
|
||||
*/
|
||||
export function formatQuota(quota: number): string {
|
||||
return formatQuotaWithCurrency(quota, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quota from the current display input back to quota units.
|
||||
*/
|
||||
export function parseQuotaFromDollars(amount: number): number {
|
||||
if (!Number.isFinite(amount)) return 0
|
||||
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
// Tokens-only or raw quota mode
|
||||
if (meta.kind === 'tokens') {
|
||||
return Math.round(amount)
|
||||
}
|
||||
|
||||
const exchangeRate =
|
||||
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
|
||||
|
||||
const usdAmount = exchangeRate > 0 ? amount / exchangeRate : amount
|
||||
|
||||
return Math.round(usdAmount * config.quotaPerUnit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert quota units to the configured display amount.
|
||||
* Reverse of parseQuotaFromDollars.
|
||||
*/
|
||||
export function quotaUnitsToDollars(units: number): number {
|
||||
const { config, meta } = getCurrencyDisplay()
|
||||
|
||||
if (meta.kind === 'tokens') {
|
||||
return units
|
||||
}
|
||||
|
||||
const usdAmount = units / config.quotaPerUnit
|
||||
const exchangeRate =
|
||||
meta.kind === 'currency' || meta.kind === 'custom' ? meta.exchangeRate : 1
|
||||
|
||||
return usdAmount * exchangeRate
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timestamp Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format Unix timestamp (seconds) to YYYY-MM-DD HH:mm:ss
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
if (timestamp === -1) {
|
||||
return 'Never'
|
||||
}
|
||||
return formatTimestampToDate(timestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to YYYY-MM-DD HH:mm:ss
|
||||
* @param timestamp - Timestamp in seconds or milliseconds
|
||||
* @param unit - Unit of the timestamp ('seconds' or 'milliseconds')
|
||||
*/
|
||||
export function formatTimestampToDate(
|
||||
timestamp?: number,
|
||||
unit: 'seconds' | 'milliseconds' = 'seconds'
|
||||
): string {
|
||||
if (!timestamp || timestamp === -1 || timestamp === 0) {
|
||||
return '-'
|
||||
}
|
||||
const ms = unit === 'seconds' ? timestamp * 1000 : timestamp
|
||||
return dayjs(ms).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp as relative time, e.g. "30 seconds ago".
|
||||
* @param timestamp - Timestamp in seconds or milliseconds
|
||||
* @param unit - Unit of the timestamp ('seconds' or 'milliseconds')
|
||||
* @param locales - Locale passed to Intl.RelativeTimeFormat
|
||||
*/
|
||||
export function formatTimestampRelative(
|
||||
timestamp?: number,
|
||||
unit: 'seconds' | 'milliseconds' = 'seconds',
|
||||
locales?: Intl.LocalesArgument
|
||||
): string {
|
||||
if (!timestamp || timestamp === -1 || timestamp === 0) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const ms = unit === 'seconds' ? timestamp * 1000 : timestamp
|
||||
const diffSeconds = Math.round((ms - Date.now()) / 1000)
|
||||
const absSeconds = Math.abs(diffSeconds)
|
||||
const formatter = new Intl.RelativeTimeFormat(locales, {
|
||||
numeric: 'always',
|
||||
})
|
||||
|
||||
if (absSeconds < 60) {
|
||||
return formatter.format(diffSeconds, 'second')
|
||||
}
|
||||
if (absSeconds < 3600) {
|
||||
return formatter.format(Math.round(diffSeconds / 60), 'minute')
|
||||
}
|
||||
if (absSeconds < 86400) {
|
||||
return formatter.format(Math.round(diffSeconds / 3600), 'hour')
|
||||
}
|
||||
if (absSeconds < 2592000) {
|
||||
return formatter.format(Math.round(diffSeconds / 86400), 'day')
|
||||
}
|
||||
if (absSeconds < 31536000) {
|
||||
return formatter.format(Math.round(diffSeconds / 2592000), 'month')
|
||||
}
|
||||
return formatter.format(Math.round(diffSeconds / 31536000), 'year')
|
||||
}
|
||||
|
||||
/** Format a Date object to YYYY-MM-DD HH:mm:ss */
|
||||
export function formatDateTimeStr(date: Date): string {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/** Format a Date object to YYYY-MM-DD */
|
||||
export function formatDateStr(date: Date): string {
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/** Format a Date object to HH:mm:ss */
|
||||
export function formatTimeStr(date: Date): string {
|
||||
return dayjs(date).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format quota for usage logs with higher precision
|
||||
* Uses 6 decimal places to show very small costs accurately
|
||||
*/
|
||||
export function formatLogQuota(quota: number): string {
|
||||
return formatQuotaWithCurrency(quota, {
|
||||
digitsLarge: 4,
|
||||
digitsSmall: 6,
|
||||
abbreviate: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tokens count with K/M suffixes
|
||||
*/
|
||||
export function formatTokens(tokens: number): string {
|
||||
if (tokens === 0) return '-'
|
||||
if (tokens < 1000) return tokens.toString()
|
||||
if (tokens < 1000000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return `${(tokens / 1000000).toFixed(2)}M`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format use time in seconds with appropriate unit
|
||||
*/
|
||||
export function formatUseTime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}m ${remainingSeconds.toFixed(0)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to date input value (YYYY-MM-DDTHH:mm)
|
||||
*/
|
||||
export function formatTimestampForInput(timestamp: number): string {
|
||||
if (timestamp === -1) {
|
||||
return ''
|
||||
}
|
||||
return dayjs(timestamp * 1000).format('YYYY-MM-DDTHH:mm')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse datetime-local input to Unix timestamp
|
||||
*/
|
||||
export function parseTimestampFromInput(value: string): number {
|
||||
if (!value) {
|
||||
return -1
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Color Generation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a consistent color from a string
|
||||
* Uses HSL for better color distribution
|
||||
*/
|
||||
export function stringToColor(str: string): string {
|
||||
if (!str) return 'gray'
|
||||
|
||||
// Generate hash from string
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
// Use hash to generate hue (0-360)
|
||||
const hue = Math.abs(hash % 360)
|
||||
|
||||
// Use saturation and lightness that work well for tags
|
||||
const saturation = 65 + (Math.abs(hash) % 10) // 65-75%
|
||||
const lightness = 55 + (Math.abs(hash >> 8) % 10) // 55-65%
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`
|
||||
}
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
const FRONTEND_CACHE_VERSION = 'default-v1'
|
||||
const FRONTEND_CACHE_VERSION_KEY = 'newapi:default:cache-version'
|
||||
const PRESERVED_LOCAL_STORAGE_KEYS = new Set([
|
||||
FRONTEND_CACHE_VERSION_KEY,
|
||||
'user',
|
||||
'uid',
|
||||
'aff',
|
||||
'oauth:binding:result',
|
||||
])
|
||||
|
||||
export function initializeFrontendCache(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const currentVersion = window.localStorage.getItem(
|
||||
FRONTEND_CACHE_VERSION_KEY
|
||||
)
|
||||
if (currentVersion === FRONTEND_CACHE_VERSION) return
|
||||
|
||||
clearLocalUiCache()
|
||||
window.localStorage.setItem(
|
||||
FRONTEND_CACHE_VERSION_KEY,
|
||||
FRONTEND_CACHE_VERSION
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; the app should still boot.
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalUiCache(): void {
|
||||
const keysToRemove: string[] = []
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index)
|
||||
if (key && !PRESERVED_LOCAL_STORAGE_KEYS.has(key)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => window.localStorage.removeItem(key))
|
||||
}
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 { AxiosError } from 'axios'
|
||||
import i18next from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
|
||||
export function handleServerError(error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error)
|
||||
|
||||
let errMsg = i18next.t('Something went wrong!')
|
||||
|
||||
const messageKey = getServerErrorMessageKey(error)
|
||||
if (messageKey) {
|
||||
toast.error(i18next.t(messageKey))
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'status' in error &&
|
||||
Number(error.status) === 204
|
||||
) {
|
||||
errMsg = i18next.t('Content not found.')
|
||||
}
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
errMsg = error.response?.data.title
|
||||
}
|
||||
|
||||
toast.error(errMsg)
|
||||
}
|
||||
Vendored
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
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 axios, { type AxiosRequestConfig } from 'axios'
|
||||
import { t } from 'i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
applyAuthRotation,
|
||||
clearAuthentication,
|
||||
refreshAuthentication,
|
||||
} from '@/lib/auth-session'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
skipBusinessError?: boolean
|
||||
skipErrorHandler?: boolean
|
||||
disableDuplicate?: boolean
|
||||
skipAuthRefresh?: boolean
|
||||
authRetry?: boolean
|
||||
acceptAuthRotation?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiRequestConfig = AxiosRequestConfig
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
|
||||
const inFlightGet = new Map<string, Promise<unknown>>()
|
||||
const originalGet = api.get.bind(api)
|
||||
|
||||
api.get = ((url: string, config: ApiRequestConfig = {}) => {
|
||||
if (config.disableDuplicate) return originalGet(url, config)
|
||||
|
||||
const params = config.params ? JSON.stringify(config.params) : '{}'
|
||||
const sessionSID = useAuthStore.getState().auth.session?.sid || 'anonymous'
|
||||
const key = `${sessionSID}:${url}?${params}`
|
||||
const existingRequest = inFlightGet.get(key)
|
||||
if (existingRequest) return existingRequest
|
||||
|
||||
const request = originalGet(url, config).finally(() => {
|
||||
inFlightGet.delete(key)
|
||||
})
|
||||
inFlightGet.set(key, request)
|
||||
return request
|
||||
}) as typeof api.get
|
||||
|
||||
function redirectToSignIn(): void {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.pathname !== '/sign-in'
|
||||
) {
|
||||
window.location.replace('/sign-in')
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
if (response.config.acceptAuthRotation && response.data?.success === true) {
|
||||
applyAuthRotation(response.data.data)
|
||||
}
|
||||
|
||||
if (
|
||||
!response.config.skipBusinessError &&
|
||||
typeof response.data?.success === 'boolean' &&
|
||||
!response.data.success
|
||||
) {
|
||||
const messageKey = getServerErrorMessageKey(response.data)
|
||||
toast.error(
|
||||
messageKey
|
||||
? t(messageKey)
|
||||
: response.data.message || t('Request failed')
|
||||
)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config as ApiRequestConfig | undefined
|
||||
const skipErrorHandler = config?.skipErrorHandler
|
||||
const status = error?.response?.status
|
||||
|
||||
if (status === 401) {
|
||||
if (config && !config.skipAuthRefresh && !config.authRetry) {
|
||||
config.authRetry = true
|
||||
const outcome = await refreshAuthentication()
|
||||
if (outcome.kind === 'authenticated') {
|
||||
const token = useAuthStore.getState().auth.accessToken
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
}
|
||||
return api.request(config)
|
||||
}
|
||||
|
||||
if (outcome.kind === 'anonymous' || outcome.kind === 'out_of_sync') {
|
||||
if (!skipErrorHandler) toast.error(t('Session expired!'))
|
||||
redirectToSignIn()
|
||||
}
|
||||
} else if (config?.authRetry) {
|
||||
clearAuthentication(false)
|
||||
if (!skipErrorHandler) toast.error(t('Session expired!'))
|
||||
redirectToSignIn()
|
||||
} else if (!skipErrorHandler) {
|
||||
toast.error(t('Session expired!'))
|
||||
}
|
||||
} else if (!skipErrorHandler) {
|
||||
const messageKey = getServerErrorMessageKey(error)
|
||||
const message = messageKey
|
||||
? t(messageKey)
|
||||
: error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
t('Request failed')
|
||||
toast.error(message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = useAuthStore.getState().auth.accessToken
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
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 StatusCodeRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type ParsedHttpStatusCodeRules = {
|
||||
ok: boolean
|
||||
ranges: StatusCodeRange[]
|
||||
tokens: string[]
|
||||
normalized: string
|
||||
invalidTokens: string[]
|
||||
}
|
||||
|
||||
export function parseHttpStatusCodeRules(
|
||||
input: unknown
|
||||
): ParsedHttpStatusCodeRules {
|
||||
const raw = (input ?? '').toString().trim()
|
||||
if (raw.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
ranges: [],
|
||||
tokens: [],
|
||||
normalized: '',
|
||||
invalidTokens: [],
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized = raw.replace(/[,]/g, ',')
|
||||
const segments = sanitized
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const ranges: StatusCodeRange[] = []
|
||||
const invalidTokens: string[] = []
|
||||
|
||||
for (const segment of segments) {
|
||||
const parsed = parseToken(segment)
|
||||
if (!parsed) {
|
||||
invalidTokens.push(segment)
|
||||
} else {
|
||||
ranges.push(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidTokens.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
ranges: [],
|
||||
tokens: [],
|
||||
normalized: raw,
|
||||
invalidTokens,
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeRanges(ranges)
|
||||
const tokens = merged.map((r) =>
|
||||
r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`
|
||||
)
|
||||
const normalized = tokens.join(',')
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ranges: merged,
|
||||
tokens,
|
||||
normalized,
|
||||
invalidTokens: [],
|
||||
}
|
||||
}
|
||||
|
||||
function parseToken(token: string): StatusCodeRange | null {
|
||||
const cleaned = token.trim().replace(/\s/g, '')
|
||||
if (!cleaned) return null
|
||||
|
||||
const isValidCode = (code: number) =>
|
||||
Number.isFinite(code) && code >= 100 && code <= 599
|
||||
|
||||
if (cleaned.includes('-')) {
|
||||
const [a, b] = cleaned.split('-')
|
||||
if (!isNumber(a) || !isNumber(b)) return null
|
||||
|
||||
const start = Number.parseInt(a, 10)
|
||||
const end = Number.parseInt(b, 10)
|
||||
if (!isValidCode(start) || !isValidCode(end) || start > end) return null
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
if (!isNumber(cleaned)) return null
|
||||
const code = Number.parseInt(cleaned, 10)
|
||||
if (!isValidCode(code)) return null
|
||||
|
||||
return { start: code, end: code }
|
||||
}
|
||||
|
||||
function isNumber(s: string) {
|
||||
return typeof s === 'string' && /^\d+$/.test(s)
|
||||
}
|
||||
|
||||
function mergeRanges(ranges: StatusCodeRange[]): StatusCodeRange[] {
|
||||
if (ranges.length === 0) return []
|
||||
|
||||
const sorted = [...ranges].sort((a, b) =>
|
||||
a.start !== b.start ? a.start - b.start : a.end - b.end
|
||||
)
|
||||
|
||||
return sorted.reduce<StatusCodeRange[]>((merged, current) => {
|
||||
const last = merged[merged.length - 1]
|
||||
|
||||
if (!last || current.start > last.end + 1) {
|
||||
merged.push({ ...current })
|
||||
} else {
|
||||
last.end = Math.max(last.end, current.end)
|
||||
}
|
||||
|
||||
return merged
|
||||
}, [])
|
||||
}
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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 assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import { resolveLegacyRoute } from './legacy-route'
|
||||
|
||||
describe('legacy frontend route migration', () => {
|
||||
test('maps former public and console routes to their current destinations', () => {
|
||||
const routes = {
|
||||
'/login': '/sign-in',
|
||||
'/forbidden': '/403',
|
||||
'/console': '/dashboard',
|
||||
'/console/models': '/models',
|
||||
'/console/deployment': '/models/deployments',
|
||||
'/console/subscription': '/subscriptions',
|
||||
'/console/channel': '/channels',
|
||||
'/console/token': '/keys',
|
||||
'/console/playground': '/playground',
|
||||
'/console/redemption': '/redemption-codes',
|
||||
'/console/user': '/users',
|
||||
'/console/personal': '/profile',
|
||||
'/console/log': '/usage-logs',
|
||||
'/console/midjourney': '/usage-logs/drawing',
|
||||
'/console/task': '/usage-logs/task',
|
||||
'/console/chat/42': '/chat/42',
|
||||
}
|
||||
|
||||
for (const [source, target] of Object.entries(routes)) {
|
||||
assert.equal(resolveLegacyRoute(source), target)
|
||||
}
|
||||
})
|
||||
|
||||
test('preserves search and hash while applying route-specific behavior', () => {
|
||||
assert.equal(
|
||||
resolveLegacyRoute('/login?redirect=%2Fkeys#continue'),
|
||||
'/sign-in?redirect=%2Fkeys#continue'
|
||||
)
|
||||
assert.equal(
|
||||
resolveLegacyRoute('/console/topup?source=email#orders'),
|
||||
'/wallet?source=email#orders'
|
||||
)
|
||||
})
|
||||
|
||||
test('maps legacy settings tabs and retains unrelated parameters', () => {
|
||||
const settingsTabs = {
|
||||
operation: '/system-settings/operations/behavior',
|
||||
dashboard: '/system-settings/content/dashboard',
|
||||
chats: '/system-settings/content/chat',
|
||||
drawing: '/system-settings/content/drawing',
|
||||
payment: '/system-settings/billing/payment',
|
||||
ratio: '/system-settings/billing/model-pricing',
|
||||
ratelimit: '/system-settings/security/rate-limit',
|
||||
models: '/system-settings/models/global',
|
||||
'model-deployment': '/system-settings/models/model-deployment',
|
||||
performance: '/system-settings/operations/performance',
|
||||
system: '/system-settings/site/system-info',
|
||||
other: '/system-settings/site/system-info',
|
||||
}
|
||||
|
||||
for (const [tab, target] of Object.entries(settingsTabs)) {
|
||||
assert.equal(
|
||||
resolveLegacyRoute(`/console/setting?tab=${tab}&from=bookmark#form`),
|
||||
`${target}?tab=${tab}&from=bookmark#form`
|
||||
)
|
||||
}
|
||||
assert.equal(
|
||||
resolveLegacyRoute('/console/setting?tab=unknown'),
|
||||
'/system-settings?tab=unknown'
|
||||
)
|
||||
})
|
||||
|
||||
test('safely redirects unknown console locations without touching new routes', () => {
|
||||
assert.equal(
|
||||
resolveLegacyRoute('/console/removed?page=2#old'),
|
||||
'/dashboard?page=2#old'
|
||||
)
|
||||
assert.equal(resolveLegacyRoute('/dashboard'), null)
|
||||
assert.equal(resolveLegacyRoute('/api/status'), null)
|
||||
})
|
||||
})
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
const legacyOrigin = 'https://legacy-route.invalid'
|
||||
|
||||
const legacyConsoleRoutes: Record<string, string> = {
|
||||
'/console': '/dashboard',
|
||||
'/console/models': '/models',
|
||||
'/console/deployment': '/models/deployments',
|
||||
'/console/subscription': '/subscriptions',
|
||||
'/console/channel': '/channels',
|
||||
'/console/token': '/keys',
|
||||
'/console/playground': '/playground',
|
||||
'/console/redemption': '/redemption-codes',
|
||||
'/console/user': '/users',
|
||||
'/console/personal': '/profile',
|
||||
'/console/log': '/usage-logs',
|
||||
'/console/midjourney': '/usage-logs/drawing',
|
||||
'/console/task': '/usage-logs/task',
|
||||
}
|
||||
|
||||
const legacySettingsTabs: Record<string, string> = {
|
||||
operation: '/system-settings/operations/behavior',
|
||||
dashboard: '/system-settings/content/dashboard',
|
||||
chats: '/system-settings/content/chat',
|
||||
drawing: '/system-settings/content/drawing',
|
||||
payment: '/system-settings/billing/payment',
|
||||
ratio: '/system-settings/billing/model-pricing',
|
||||
ratelimit: '/system-settings/security/rate-limit',
|
||||
models: '/system-settings/models/global',
|
||||
'model-deployment': '/system-settings/models/model-deployment',
|
||||
performance: '/system-settings/operations/performance',
|
||||
system: '/system-settings/site/system-info',
|
||||
other: '/system-settings/site/system-info',
|
||||
}
|
||||
|
||||
function normalizeLegacyPath(pathname: string): string {
|
||||
if (pathname === '/') return pathname
|
||||
return pathname.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildTargetHref(targetPath: string, source: URL): string {
|
||||
const target = new URL(targetPath, legacyOrigin)
|
||||
source.searchParams.forEach((value, key) => {
|
||||
target.searchParams.append(key, value)
|
||||
})
|
||||
target.hash = source.hash
|
||||
return `${target.pathname}${target.search}${target.hash}`
|
||||
}
|
||||
|
||||
export function resolveLegacyRoute(rawHref: string): string | null {
|
||||
let source: URL
|
||||
try {
|
||||
source = new URL(rawHref, legacyOrigin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const pathname = normalizeLegacyPath(source.pathname)
|
||||
if (pathname === '/login') {
|
||||
return buildTargetHref('/sign-in', source)
|
||||
}
|
||||
if (pathname === '/forbidden') {
|
||||
return buildTargetHref('/403', source)
|
||||
}
|
||||
if (pathname === '/console/topup') {
|
||||
return buildTargetHref('/wallet', source)
|
||||
}
|
||||
if (pathname === '/console/setting') {
|
||||
const tab = source.searchParams.get('tab') ?? ''
|
||||
const target = legacySettingsTabs[tab] ?? '/system-settings'
|
||||
return buildTargetHref(target, source)
|
||||
}
|
||||
if (pathname === '/console/chat') {
|
||||
return buildTargetHref('/dashboard', source)
|
||||
}
|
||||
if (pathname.startsWith('/console/chat/')) {
|
||||
const chatID = pathname.slice('/console/chat/'.length)
|
||||
return buildTargetHref(chatID ? `/chat/${chatID}` : '/dashboard', source)
|
||||
}
|
||||
|
||||
const target = legacyConsoleRoutes[pathname]
|
||||
if (target) return buildTargetHref(target, source)
|
||||
if (pathname.startsWith('/console/')) {
|
||||
return buildTargetHref('/dashboard', source)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Vendored
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* LobeHub Icon Loader
|
||||
* Dynamically load and render icons from @lobehub/icons
|
||||
*
|
||||
* Supports:
|
||||
* - Basic: "OpenAI", "OpenAI.Color"
|
||||
* - Chained properties: "OpenAI.Avatar.type={'platform'}"
|
||||
* - Size parameter: getLobeIcon("OpenAI", 20)
|
||||
*/
|
||||
import * as LobeIcons from '@lobehub/icons'
|
||||
|
||||
/**
|
||||
* Parse a property value from string to appropriate type
|
||||
* @param raw - Raw string value
|
||||
* @returns Parsed value (boolean, number, or string)
|
||||
*/
|
||||
function parseValue(raw: string | undefined | null): string | number | boolean {
|
||||
if (raw == null) return true
|
||||
|
||||
let v = String(raw).trim()
|
||||
|
||||
// Remove curly braces
|
||||
if (v.startsWith('{') && v.endsWith('}')) {
|
||||
v = v.slice(1, -1).trim()
|
||||
}
|
||||
|
||||
// Remove quotes
|
||||
if (
|
||||
(v.startsWith('"') && v.endsWith('"')) ||
|
||||
(v.startsWith("'") && v.endsWith("'"))
|
||||
) {
|
||||
return v.slice(1, -1)
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (v === 'true') return true
|
||||
if (v === 'false') return false
|
||||
|
||||
// Number
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(v)) return Number(v)
|
||||
|
||||
// Return as string
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LobeHub icon component by name
|
||||
* @param iconName - Icon name/description (e.g., "OpenAI", "OpenAI.Color", "Claude.Avatar")
|
||||
* @param size - Icon size (default: 20)
|
||||
* @returns Icon component or fallback
|
||||
*
|
||||
* @example
|
||||
* getLobeIcon("OpenAI", 24)
|
||||
* getLobeIcon("OpenAI.Color", 20)
|
||||
* getLobeIcon("Claude.Avatar.type={'platform'}", 32)
|
||||
*/
|
||||
export function getLobeIcon(
|
||||
iconName: string | undefined | null,
|
||||
size: number = 20
|
||||
): React.ReactNode {
|
||||
if (!iconName || typeof iconName !== 'string') {
|
||||
return (
|
||||
<div
|
||||
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedName = iconName.trim()
|
||||
if (!trimmedName) {
|
||||
return (
|
||||
<div
|
||||
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse component path and chained properties
|
||||
const segments = trimmedName.split('.')
|
||||
const baseKey = segments[0]
|
||||
const BaseIcon = (LobeIcons as Record<string, unknown>)[baseKey] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
let IconComponent: React.ComponentType<Record<string, unknown>> | undefined
|
||||
let propStartIndex: number
|
||||
|
||||
if (BaseIcon && segments.length > 1 && BaseIcon[segments[1]]) {
|
||||
IconComponent = BaseIcon[segments[1]] as React.ComponentType<
|
||||
Record<string, unknown>
|
||||
>
|
||||
propStartIndex = 2
|
||||
} else {
|
||||
IconComponent = (LobeIcons as Record<string, unknown>)[baseKey] as
|
||||
| React.ComponentType<Record<string, unknown>>
|
||||
| undefined
|
||||
propStartIndex = segments.length > 1 && /^[A-Z]/.test(segments[1]) ? 2 : 1
|
||||
}
|
||||
|
||||
// Fallback if icon not found
|
||||
if (
|
||||
!IconComponent ||
|
||||
(typeof IconComponent !== 'function' && typeof IconComponent !== 'object')
|
||||
) {
|
||||
const firstLetter = trimmedName.charAt(0).toUpperCase()
|
||||
return (
|
||||
<div
|
||||
className='bg-muted text-muted-foreground flex items-center justify-center rounded-full text-xs font-medium'
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{firstLetter}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Parse chained properties (e.g., "type={'platform'}", "shape='square'")
|
||||
const props: Record<string, string | number | boolean> = {}
|
||||
|
||||
for (let i = propStartIndex; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
if (!seg) continue
|
||||
|
||||
const eqIdx = seg.indexOf('=')
|
||||
if (eqIdx === -1) {
|
||||
props[seg.trim()] = true
|
||||
continue
|
||||
}
|
||||
|
||||
const key = seg.slice(0, eqIdx).trim()
|
||||
const valRaw = seg.slice(eqIdx + 1).trim()
|
||||
props[key] = parseValue(valRaw)
|
||||
}
|
||||
|
||||
// Set size if not explicitly specified in the string
|
||||
if (props.size == null && size != null) {
|
||||
props.size = size
|
||||
}
|
||||
|
||||
return <IconComponent {...props} />
|
||||
}
|
||||
Vendored
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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 { Transition, Variants } from 'motion/react'
|
||||
|
||||
const EASE_OUT_CUBIC = [0.33, 1, 0.68, 1] as const
|
||||
|
||||
const DURATION = {
|
||||
instant: 0,
|
||||
fast: 0.15,
|
||||
normal: 0.25,
|
||||
slow: 0.35,
|
||||
} as const
|
||||
|
||||
export const MOTION_TRANSITION: Record<string, Transition> = {
|
||||
default: { duration: DURATION.normal, ease: EASE_OUT_CUBIC },
|
||||
fast: { duration: DURATION.fast, ease: EASE_OUT_CUBIC },
|
||||
slow: { duration: DURATION.slow, ease: EASE_OUT_CUBIC },
|
||||
spring: { type: 'spring', damping: 20, stiffness: 300 },
|
||||
none: { duration: DURATION.instant },
|
||||
}
|
||||
|
||||
export const MOTION_VARIANTS = {
|
||||
pageEnter: {
|
||||
initial: { opacity: 0, y: 8, filter: 'blur(4px)' },
|
||||
animate: { opacity: 1, y: 0, filter: 'blur(0px)' },
|
||||
exit: { opacity: 0, y: -4, filter: 'blur(2px)' },
|
||||
},
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
scaleIn: {
|
||||
initial: { opacity: 0, scale: 0.96 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.96 },
|
||||
},
|
||||
slideUp: {
|
||||
initial: { opacity: 0, y: 16 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 16 },
|
||||
},
|
||||
slideDown: {
|
||||
initial: { opacity: 0, y: -16 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -16 },
|
||||
},
|
||||
tableRow: {
|
||||
initial: { opacity: 0, y: 4 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
},
|
||||
cardItem: {
|
||||
initial: { opacity: 0, y: 12, scale: 0.98 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
},
|
||||
sidebarSlide: {
|
||||
initial: { opacity: 0, x: -8 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
exit: { opacity: 0, x: -8 },
|
||||
},
|
||||
} as const
|
||||
|
||||
export const STAGGER_VARIANTS: Variants = {
|
||||
initial: {},
|
||||
animate: { transition: { staggerChildren: 0.04 } },
|
||||
}
|
||||
|
||||
export const STAGGER_ITEM_VARIANTS: Variants = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0, transition: MOTION_TRANSITION.default },
|
||||
}
|
||||
|
||||
export const TABLE_STAGGER_VARIANTS: Variants = {
|
||||
initial: {},
|
||||
animate: { transition: { staggerChildren: 0.03 } },
|
||||
}
|
||||
|
||||
export const TABLE_ROW_VARIANTS: Variants = {
|
||||
initial: { opacity: 0, y: 4 },
|
||||
animate: { opacity: 1, y: 0, transition: MOTION_TRANSITION.fast },
|
||||
}
|
||||
|
||||
export const CARD_STAGGER_VARIANTS: Variants = {
|
||||
initial: {},
|
||||
animate: { transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
export const CARD_ITEM_VARIANTS: Variants = {
|
||||
initial: { opacity: 0, y: 12, scale: 0.98 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: MOTION_TRANSITION.default,
|
||||
},
|
||||
}
|
||||
|
||||
export const SIDEBAR_STAGGER_VARIANTS: Variants = {
|
||||
initial: {},
|
||||
animate: { transition: { staggerChildren: 0.03, delayChildren: 0.05 } },
|
||||
}
|
||||
|
||||
export const SIDEBAR_ITEM_VARIANTS: Variants = {
|
||||
initial: { opacity: 0, x: -8 },
|
||||
animate: { opacity: 1, x: 0, transition: MOTION_TRANSITION.fast },
|
||||
}
|
||||
Vendored
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
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 { getStatus } from '@/lib/api'
|
||||
|
||||
export type ModuleAccess = { enabled: boolean; requireAuth: boolean }
|
||||
|
||||
export type HeaderNavModule = 'rankings' | 'pricing'
|
||||
|
||||
export type HeaderNavModules = {
|
||||
home: boolean
|
||||
console: boolean
|
||||
pricing: ModuleAccess
|
||||
rankings: ModuleAccess
|
||||
docs: boolean
|
||||
about: boolean
|
||||
[key: string]: boolean | ModuleAccess
|
||||
}
|
||||
|
||||
const DEFAULT_HEADER_NAV_MODULES: HeaderNavModules = {
|
||||
home: true,
|
||||
console: true,
|
||||
pricing: { enabled: true, requireAuth: false },
|
||||
rankings: { enabled: true, requireAuth: false },
|
||||
docs: true,
|
||||
about: true,
|
||||
}
|
||||
|
||||
const DEFAULTS: Record<HeaderNavModule, ModuleAccess> = {
|
||||
pricing: DEFAULT_HEADER_NAV_MODULES.pricing,
|
||||
rankings: DEFAULT_HEADER_NAV_MODULES.rankings,
|
||||
}
|
||||
|
||||
function cloneHeaderNavDefaults(): HeaderNavModules {
|
||||
return {
|
||||
...DEFAULT_HEADER_NAV_MODULES,
|
||||
pricing: { ...DEFAULT_HEADER_NAV_MODULES.pricing },
|
||||
rankings: { ...DEFAULT_HEADER_NAV_MODULES.rankings },
|
||||
}
|
||||
}
|
||||
|
||||
export function parseHeaderNavBoolean(
|
||||
raw: unknown,
|
||||
fallback: boolean
|
||||
): boolean {
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (typeof raw === 'number') {
|
||||
if (raw === 1) return true
|
||||
if (raw === 0) return false
|
||||
return fallback
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const normalized = raw.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') return true
|
||||
if (normalized === 'false' || normalized === '0') return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function parseAccess(raw: unknown, fallback: ModuleAccess): ModuleAccess {
|
||||
if (
|
||||
typeof raw === 'boolean' ||
|
||||
typeof raw === 'number' ||
|
||||
typeof raw === 'string'
|
||||
) {
|
||||
return {
|
||||
enabled: parseHeaderNavBoolean(raw, fallback.enabled),
|
||||
requireAuth: fallback.requireAuth,
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const r = raw as Record<string, unknown>
|
||||
return {
|
||||
enabled: parseHeaderNavBoolean(r.enabled, fallback.enabled),
|
||||
requireAuth: parseHeaderNavBoolean(r.requireAuth, fallback.requireAuth),
|
||||
}
|
||||
}
|
||||
return { ...fallback }
|
||||
}
|
||||
|
||||
function parseHeaderNavRecord(raw: unknown): Record<string, unknown> | null {
|
||||
if (!raw || String(raw).trim() === '') return null
|
||||
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
|
||||
|
||||
try {
|
||||
return JSON.parse(String(raw)) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseHeaderNavModules(raw: unknown): HeaderNavModules {
|
||||
const result = cloneHeaderNavDefaults()
|
||||
const parsed = parseHeaderNavRecord(raw)
|
||||
if (!parsed) return result
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
if (key === 'pricing') {
|
||||
result.pricing = parseAccess(value, result.pricing)
|
||||
return
|
||||
}
|
||||
if (key === 'rankings') {
|
||||
result.rankings = parseAccess(value, result.rankings)
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = result[key]
|
||||
if (
|
||||
typeof fallback === 'boolean' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'string'
|
||||
) {
|
||||
result[key] = parseHeaderNavBoolean(
|
||||
value,
|
||||
typeof fallback === 'boolean' ? fallback : true
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseHeaderNavModulesFromStatus(
|
||||
status: Record<string, unknown> | null
|
||||
): HeaderNavModules {
|
||||
return parseHeaderNavModules(status?.HeaderNavModules)
|
||||
}
|
||||
|
||||
function getCachedStatus(): Record<string, unknown> | null {
|
||||
try {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = window.localStorage.getItem('status')
|
||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cacheStatus(status: Record<string, unknown> | null): void {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && status) {
|
||||
window.localStorage.setItem('status', JSON.stringify(status))
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
export function getModuleAccessFromStatus(
|
||||
status: Record<string, unknown> | null,
|
||||
module: HeaderNavModule
|
||||
): ModuleAccess {
|
||||
return parseHeaderNavModulesFromStatus(status)[module] ?? DEFAULTS[module]
|
||||
}
|
||||
|
||||
export function getModuleAccess(module: HeaderNavModule): ModuleAccess {
|
||||
return getModuleAccessFromStatus(getCachedStatus(), module)
|
||||
}
|
||||
|
||||
export async function getFreshModuleAccess(
|
||||
module: HeaderNavModule
|
||||
): Promise<ModuleAccess> {
|
||||
try {
|
||||
const status = (await getStatus()) as Record<string, unknown> | null
|
||||
cacheStatus(status)
|
||||
return getModuleAccessFromStatus(status, module)
|
||||
} catch {
|
||||
return { enabled: false, requireAuth: true }
|
||||
}
|
||||
}
|
||||
|
||||
export function isSidebarModuleEnabled(
|
||||
section: string,
|
||||
module: string
|
||||
): boolean {
|
||||
const status = getCachedStatus()
|
||||
if (!status) return true
|
||||
|
||||
const raw = status.SidebarModulesAdmin
|
||||
if (!raw || String(raw).trim() === '') return true
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw)) as Record<
|
||||
string,
|
||||
Record<string, boolean>
|
||||
>
|
||||
const sectionConfig = parsed[section]
|
||||
if (!sectionConfig) return true
|
||||
if (sectionConfig.enabled === false) return false
|
||||
if (sectionConfig[module] === false) return false
|
||||
return true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
// ============================================================================
|
||||
// OAuth URL Builders
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build GitHub OAuth URL
|
||||
*/
|
||||
export function buildGitHubOAuthUrl(clientId: string, state: string): string {
|
||||
return `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Discord OAuth URL
|
||||
*/
|
||||
export function buildDiscordOAuthUrl(clientId: string, state: string): string {
|
||||
const url = new URL('https://discord.com/oauth2/authorize')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set(
|
||||
'redirect_uri',
|
||||
`${window.location.origin}/oauth/discord`
|
||||
)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'identify+openid')
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OIDC OAuth URL
|
||||
*/
|
||||
export function buildOIDCOAuthUrl(
|
||||
authUrl: string,
|
||||
clientId: string,
|
||||
state: string
|
||||
): string {
|
||||
const url = new URL(authUrl)
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('redirect_uri', `${window.location.origin}/oauth/oidc`)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LinuxDO OAuth URL
|
||||
*/
|
||||
export function buildLinuxDOOAuthUrl(clientId: string, state: string): string {
|
||||
return `https://connect.linux.do/oauth2/authorize?response_type=code&client_id=${clientId}&state=${state}`
|
||||
}
|
||||
Vendored
+289
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Passkey helper utilities for WebAuthn credential handling.
|
||||
*
|
||||
* These helpers convert between ArrayBuffer and Base64URL encodings and
|
||||
* normalise server-provided credential options into browser-compatible types.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Convert a base64url string to an ArrayBuffer.
|
||||
*/
|
||||
type NodeBufferCtor = {
|
||||
from(input: string, encoding: string): { toString(encoding: string): string }
|
||||
}
|
||||
|
||||
export function base64UrlToArrayBuffer(value?: string | null): ArrayBuffer {
|
||||
if (!value) return new ArrayBuffer(0)
|
||||
|
||||
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
Buffer?: NodeBufferCtor
|
||||
}
|
||||
|
||||
const decode =
|
||||
typeof globalRef.atob === 'function'
|
||||
? globalRef.atob.bind(globalRef)
|
||||
: (input: string) => {
|
||||
if (typeof globalRef.Buffer !== 'undefined') {
|
||||
return globalRef.Buffer.from(input, 'base64').toString('binary')
|
||||
}
|
||||
throw new Error(
|
||||
'Base64 decoding is not supported in this environment'
|
||||
)
|
||||
}
|
||||
|
||||
const binary = decode(base64)
|
||||
const buffer = new ArrayBuffer(binary.length)
|
||||
const bytes = new Uint8Array(buffer)
|
||||
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a base64url string.
|
||||
*/
|
||||
export function arrayBufferToBase64Url(
|
||||
buffer?: ArrayBuffer | ArrayBufferLike | null
|
||||
): string {
|
||||
if (!buffer) return ''
|
||||
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
Buffer?: NodeBufferCtor
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
|
||||
const encode =
|
||||
typeof globalRef.btoa === 'function'
|
||||
? globalRef.btoa.bind(globalRef)
|
||||
: (input: string) => {
|
||||
if (typeof globalRef.Buffer !== 'undefined') {
|
||||
return globalRef.Buffer.from(input, 'binary').toString('base64')
|
||||
}
|
||||
throw new Error(
|
||||
'Base64 encoding is not supported in this environment'
|
||||
)
|
||||
}
|
||||
|
||||
return encode(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare credential creation options returned by the backend.
|
||||
*/
|
||||
export function prepareCredentialCreationOptions(
|
||||
payload: any
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const options =
|
||||
payload?.publicKey ??
|
||||
payload?.PublicKey ??
|
||||
payload?.response ??
|
||||
payload?.Response
|
||||
|
||||
if (!options) {
|
||||
throw new Error(
|
||||
'Unable to parse Passkey registration options from response'
|
||||
)
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialCreationOptions & Record<string, any> = {
|
||||
...options,
|
||||
challenge: base64UrlToArrayBuffer(options.challenge),
|
||||
user: {
|
||||
...options.user,
|
||||
id: base64UrlToArrayBuffer(options.user?.id),
|
||||
},
|
||||
}
|
||||
|
||||
if (Array.isArray(options.excludeCredentials)) {
|
||||
publicKey.excludeCredentials = options.excludeCredentials.map(
|
||||
(item: any) => ({
|
||||
...item,
|
||||
id: base64UrlToArrayBuffer(item.id),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
Array.isArray(options.attestationFormats) &&
|
||||
options.attestationFormats.length === 0
|
||||
) {
|
||||
delete publicKey.attestationFormats
|
||||
}
|
||||
|
||||
return publicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare credential request options returned by the backend.
|
||||
*/
|
||||
export function prepareCredentialRequestOptions(
|
||||
payload: any
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const options =
|
||||
payload?.publicKey ??
|
||||
payload?.PublicKey ??
|
||||
payload?.response ??
|
||||
payload?.Response
|
||||
|
||||
if (!options) {
|
||||
throw new Error('Unable to parse Passkey login options from response')
|
||||
}
|
||||
|
||||
const publicKey: PublicKeyCredentialRequestOptions & Record<string, any> = {
|
||||
...options,
|
||||
challenge: base64UrlToArrayBuffer(options.challenge),
|
||||
}
|
||||
|
||||
if (Array.isArray(options.allowCredentials)) {
|
||||
publicKey.allowCredentials = options.allowCredentials.map((item: any) => ({
|
||||
...item,
|
||||
id: base64UrlToArrayBuffer(item.id),
|
||||
}))
|
||||
}
|
||||
|
||||
return publicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload for registering a new credential.
|
||||
*/
|
||||
export function buildRegistrationResult(
|
||||
credential: PublicKeyCredential | null
|
||||
): Record<string, any> | null {
|
||||
if (!credential) return null
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse & {
|
||||
getTransports?: () => string[]
|
||||
}
|
||||
|
||||
const transports =
|
||||
typeof response.getTransports === 'function'
|
||||
? response.getTransports()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
response: {
|
||||
attestationObject: arrayBufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
||||
transports,
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload for verifying an existing credential.
|
||||
*/
|
||||
export function buildAssertionResult(
|
||||
credential: PublicKeyCredential | null
|
||||
): Record<string, any> | null {
|
||||
if (!credential) return null
|
||||
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
authenticatorAttachment: credential.authenticatorAttachment,
|
||||
response: {
|
||||
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
|
||||
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
|
||||
signature: arrayBufferToBase64Url(response.signature),
|
||||
userHandle: response.userHandle
|
||||
? arrayBufferToBase64Url(response.userHandle)
|
||||
: null,
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current environment supports Passkey/WebAuthn.
|
||||
*/
|
||||
export async function isPasskeySupported(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
const { PublicKeyCredential } = window
|
||||
if (!PublicKeyCredential) return false
|
||||
|
||||
if (
|
||||
typeof PublicKeyCredential.isConditionalMediationAvailable === 'function'
|
||||
) {
|
||||
try {
|
||||
const available =
|
||||
await PublicKeyCredential.isConditionalMediationAvailable()
|
||||
if (available) return true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
|
||||
'function'
|
||||
) {
|
||||
try {
|
||||
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async Passkey credential creation flow.
|
||||
*/
|
||||
export async function createCredential(
|
||||
options: PublicKeyCredentialCreationOptions
|
||||
) {
|
||||
return navigator.credentials.create({ publicKey: options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async Passkey credential request flow.
|
||||
*/
|
||||
export async function getCredential(
|
||||
options: PublicKeyCredentialRequestOptions
|
||||
) {
|
||||
return navigator.credentials.get({ publicKey: options })
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
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 { t } from 'i18next'
|
||||
|
||||
export const ROLE = {
|
||||
GUEST: 0, // 后续如果需要用到这个角色那就再加,同语先留一下
|
||||
USER: 1,
|
||||
ADMIN: 10,
|
||||
SUPER_ADMIN: 100,
|
||||
} as const
|
||||
|
||||
export type RoleValue = (typeof ROLE)[keyof typeof ROLE]
|
||||
|
||||
const DEFAULT_ROLE = ROLE.GUEST
|
||||
|
||||
const ROLE_LABEL_KEYS: Record<RoleValue, string> = {
|
||||
[ROLE.SUPER_ADMIN]: 'Super Admin',
|
||||
[ROLE.ADMIN]: 'Admin',
|
||||
[ROLE.USER]: 'User',
|
||||
[ROLE.GUEST]: 'Guest',
|
||||
}
|
||||
|
||||
export function getRoleLabelKey(role?: number): string {
|
||||
return ROLE_LABEL_KEYS[role as RoleValue] ?? ROLE_LABEL_KEYS[DEFAULT_ROLE]
|
||||
}
|
||||
|
||||
export function getRoleLabel(role?: number): string {
|
||||
return t(getRoleLabelKey(role))
|
||||
}
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 { AxiosError } from 'axios'
|
||||
|
||||
export interface VerificationRequiredInfo {
|
||||
code?: string
|
||||
message: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an Axios error indicates secure verification is required.
|
||||
*/
|
||||
export function isVerificationRequiredError(
|
||||
error: unknown
|
||||
): error is AxiosError {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const axiosError = error as AxiosError<{ code?: string }>
|
||||
const status = axiosError.response?.status
|
||||
if (status !== 403) return false
|
||||
|
||||
const code = axiosError.response?.data?.code
|
||||
if (!code) return false
|
||||
|
||||
const verificationCodes = new Set([
|
||||
'VERIFICATION_REQUIRED',
|
||||
'VERIFICATION_EXPIRED',
|
||||
'VERIFICATION_INVALID',
|
||||
'SECURITY_PROOF_REQUIRED',
|
||||
'SECURITY_PROOF_EXPIRED',
|
||||
'SECURITY_PROOF_INVALID',
|
||||
'SECURITY_PROOF_SCOPE_MISMATCH',
|
||||
'SECURITY_PROOF_METHOD_MISMATCH',
|
||||
])
|
||||
|
||||
return verificationCodes.has(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract verification requirement info from an Axios error.
|
||||
*/
|
||||
export function extractVerificationInfo(
|
||||
error: unknown
|
||||
): VerificationRequiredInfo {
|
||||
const axiosError = error as AxiosError<{ code?: string; message?: string }>
|
||||
const code = axiosError.response?.data?.code
|
||||
const message =
|
||||
axiosError.response?.data?.message ?? 'Secure verification is required'
|
||||
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
required: true,
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
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 assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import { getServerErrorMessageKey } from './server-error-message'
|
||||
|
||||
describe('server error message mapping', () => {
|
||||
test('maps the active-session limit to recovery instructions', () => {
|
||||
const message = getServerErrorMessageKey({ code: 'AUTH_SESSION_LIMIT' })
|
||||
|
||||
assert.match(message ?? '', /Sign out other sessions/)
|
||||
assert.match(message ?? '', /reset your password/)
|
||||
})
|
||||
|
||||
test('maps an Axios-shaped issuance limit to rolling-window guidance', () => {
|
||||
const message = getServerErrorMessageKey({
|
||||
response: { data: { code: 'AUTH_SESSION_ISSUANCE_LIMIT' } },
|
||||
})
|
||||
|
||||
assert.match(message ?? '', /rolling window/)
|
||||
assert.equal(getServerErrorMessageKey({ code: 'UNKNOWN_CODE' }), null)
|
||||
})
|
||||
|
||||
test('maps stable Telegram bind errors without exposing server text', () => {
|
||||
const expected = {
|
||||
TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.',
|
||||
TELEGRAM_BIND_INVALID_REQUEST:
|
||||
'The Telegram authorization request is invalid or expired.',
|
||||
TELEGRAM_BIND_FLOW_INVALID:
|
||||
'This Telegram binding request has expired or has already been used.',
|
||||
TELEGRAM_BIND_SESSION_INVALID:
|
||||
'The login session that started this Telegram binding is no longer valid.',
|
||||
TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.',
|
||||
TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.',
|
||||
TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.',
|
||||
TELEGRAM_BIND_INTERNAL_ERROR:
|
||||
'Telegram binding failed. Please try again.',
|
||||
}
|
||||
|
||||
for (const [code, message] of Object.entries(expected)) {
|
||||
assert.equal(getServerErrorMessageKey({ code }), message)
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
getServerErrorMessageKey({
|
||||
response: {
|
||||
data: { code: 'TELEGRAM_BIND_INTERNAL_ERROR', message: 'raw detail' },
|
||||
},
|
||||
}),
|
||||
expected.TELEGRAM_BIND_INTERNAL_ERROR
|
||||
)
|
||||
})
|
||||
})
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
const serverErrorMessageKeys = {
|
||||
AUTH_SESSION_LIMIT:
|
||||
'Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.',
|
||||
AUTH_SESSION_ISSUANCE_LIMIT:
|
||||
'Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.',
|
||||
TELEGRAM_BIND_DISABLED: 'Telegram binding is disabled.',
|
||||
TELEGRAM_BIND_INVALID_REQUEST:
|
||||
'The Telegram authorization request is invalid or expired.',
|
||||
TELEGRAM_BIND_FLOW_INVALID:
|
||||
'This Telegram binding request has expired or has already been used.',
|
||||
TELEGRAM_BIND_SESSION_INVALID:
|
||||
'The login session that started this Telegram binding is no longer valid.',
|
||||
TELEGRAM_BIND_ALREADY_BOUND: 'This Telegram account is already bound.',
|
||||
TELEGRAM_BIND_USER_DELETED: 'This user account no longer exists.',
|
||||
TELEGRAM_BIND_USER_DISABLED: 'This user account is disabled.',
|
||||
TELEGRAM_BIND_INTERNAL_ERROR: 'Telegram binding failed. Please try again.',
|
||||
} as const
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
||||
function serverErrorPayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null
|
||||
|
||||
const response = value.response
|
||||
if (isRecord(response) && isRecord(response.data)) {
|
||||
return response.data
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function getServerErrorMessageKey(value: unknown): string | null {
|
||||
const payload = serverErrorPayload(value)
|
||||
if (!payload || typeof payload.code !== 'string') return null
|
||||
|
||||
return (
|
||||
serverErrorMessageKeys[
|
||||
payload.code as keyof typeof serverErrorMessageKeys
|
||||
] ?? null
|
||||
)
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 { toast } from 'sonner'
|
||||
|
||||
export function showSubmittedData(
|
||||
data: unknown,
|
||||
title: string = 'You submitted the following values:'
|
||||
) {
|
||||
toast.message(title, {
|
||||
description: (
|
||||
// w-[340px]
|
||||
<pre className='bg-muted text-foreground mt-2 w-full overflow-x-auto rounded-md p-4'>
|
||||
<code>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
Vendored
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Theme customization constants and types.
|
||||
*
|
||||
* Lives in `lib/` (not `context/`) so it can be imported alongside the
|
||||
* provider without breaking React Fast Refresh boundaries.
|
||||
*/
|
||||
|
||||
export const THEME_PRESETS = [
|
||||
{
|
||||
value: 'default',
|
||||
name: 'Default',
|
||||
swatches: ['oklch(0.72 0.18 250)', 'oklch(0.7 0.12 280)'],
|
||||
},
|
||||
{
|
||||
// Inspired by Anthropic's official brand language: warm cream canvas
|
||||
// (#faf9f5) paired with clay/coral (#d97757) as the single accent.
|
||||
// Swatches preview the canvas → accent gradient that defines the system.
|
||||
value: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
swatches: ['oklch(0.984 0.005 95)', 'oklch(0.685 0.142 38)'],
|
||||
},
|
||||
{
|
||||
value: 'simple-large',
|
||||
name: 'Simple Large-font',
|
||||
swatches: ['oklch(0.15 0 0)', 'oklch(0.99 0 0)'],
|
||||
},
|
||||
{
|
||||
value: 'underground',
|
||||
name: 'Underground',
|
||||
swatches: ['oklch(0.5315 0.0694 156.19)', 'oklch(0.5748 0.0862 336.52)'],
|
||||
},
|
||||
{
|
||||
value: 'rose-garden',
|
||||
name: 'Rose Garden',
|
||||
swatches: ['oklch(0.5827 0.2418 12.23)', 'oklch(0.8131 0.1129 5.67)'],
|
||||
},
|
||||
{
|
||||
value: 'lake-view',
|
||||
name: 'Lake View',
|
||||
swatches: ['oklch(0.765 0.177 163.22)', 'oklch(0.551 0.0899 200.52)'],
|
||||
},
|
||||
{
|
||||
value: 'sunset-glow',
|
||||
name: 'Sunset Glow',
|
||||
swatches: ['oklch(0.5591 0.1882 25.33)', 'oklch(0.7938 0.1248 42.42)'],
|
||||
},
|
||||
{
|
||||
value: 'forest-whisper',
|
||||
name: 'Forest Whisper',
|
||||
swatches: ['oklch(0.5276 0.1072 182.22)', 'oklch(0.5236 0.0505 250.18)'],
|
||||
},
|
||||
{
|
||||
value: 'ocean-breeze',
|
||||
name: 'Ocean Breeze',
|
||||
swatches: ['oklch(0.5461 0.2152 262.88)', 'oklch(0.5854 0.2041 277.12)'],
|
||||
},
|
||||
{
|
||||
value: 'lavender-dream',
|
||||
name: 'Lavender Dream',
|
||||
swatches: ['oklch(0.5709 0.1808 306.89)', 'oklch(0.811 0.0589 201.14)'],
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ThemePreset = (typeof THEME_PRESETS)[number]['value']
|
||||
export type ThemeRadius = 'default' | 'none' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type ThemeScale = 'default' | 'sm' | 'lg' | 'xl'
|
||||
export type ContentLayout = 'full' | 'centered'
|
||||
|
||||
/**
|
||||
* Font axis for the theme.
|
||||
*
|
||||
* - `default` — resolve at runtime from the active preset
|
||||
* (see `PRESET_DEFAULT_FONT`). The shipped `default` and `anthropic`
|
||||
* presets resolve to serif; other named color presets fall back to
|
||||
* sans unless they list a different choice. Mirrors how
|
||||
* `radius: 'default'` defers to a per-preset hint.
|
||||
* - `sans` — humanist sans (Public Sans), the project's UI fallback.
|
||||
* - `serif` — editorial serif (Lora + CJK fallbacks), the project's
|
||||
* "soul" typography. Inherits across the whole UI; monospace contexts
|
||||
* keep their own family via Tailwind preflight and `.font-mono`.
|
||||
*/
|
||||
export type ThemeFont = 'default' | 'sans' | 'serif'
|
||||
|
||||
/**
|
||||
* The resolved (non-`default`) font value applied to the DOM. The provider
|
||||
* always sets `data-theme-font` to one of these concrete values so CSS only
|
||||
* needs simple attribute selectors (no `:not()` gymnastics, no per-preset
|
||||
* font branches).
|
||||
*/
|
||||
export type ResolvedThemeFont = Exclude<ThemeFont, 'default'>
|
||||
|
||||
export type ThemeCustomization = {
|
||||
preset: ThemePreset
|
||||
font: ThemeFont
|
||||
radius: ThemeRadius
|
||||
scale: ThemeScale
|
||||
contentLayout: ContentLayout
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_CUSTOMIZATION: ThemeCustomization = {
|
||||
preset: 'default',
|
||||
font: 'default',
|
||||
radius: 'default',
|
||||
scale: 'default',
|
||||
contentLayout: 'full',
|
||||
}
|
||||
|
||||
export const THEME_PRESET_VALUES = new Set(
|
||||
THEME_PRESETS.map((p) => p.value)
|
||||
) as ReadonlySet<ThemePreset>
|
||||
|
||||
export const THEME_FONT_VALUES: ReadonlySet<ThemeFont> = new Set([
|
||||
'default',
|
||||
'sans',
|
||||
'serif',
|
||||
])
|
||||
|
||||
export const THEME_RADIUS_VALUES: ReadonlySet<ThemeRadius> = new Set([
|
||||
'default',
|
||||
'none',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
])
|
||||
|
||||
export const THEME_SCALE_VALUES: ReadonlySet<ThemeScale> = new Set([
|
||||
'default',
|
||||
'sm',
|
||||
'lg',
|
||||
'xl',
|
||||
])
|
||||
|
||||
export const CONTENT_LAYOUT_VALUES: ReadonlySet<ContentLayout> = new Set([
|
||||
'full',
|
||||
'centered',
|
||||
])
|
||||
|
||||
export const THEME_COOKIE_KEYS = {
|
||||
preset: 'theme_preset',
|
||||
font: 'theme_font',
|
||||
radius: 'theme_radius',
|
||||
scale: 'theme_scale',
|
||||
contentLayout: 'theme_content_layout',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Preset → default font mapping. Used by the provider to resolve the user's
|
||||
* `font: 'default'` preference against the active preset.
|
||||
*
|
||||
* Co-located with the preset registry so a preset's signature typography
|
||||
* is declared in one place. Presets not listed here fall back to the
|
||||
* `resolveThemeFont` default of `sans`. The shipped `default` preset
|
||||
* opts into serif so the editorial Lora voice is the out-of-the-box
|
||||
* experience; vivid color presets stay on the humanist sans so their
|
||||
* accents read clearly without competing with the body type.
|
||||
*/
|
||||
export const PRESET_DEFAULT_FONT: Partial<
|
||||
Record<ThemePreset, ResolvedThemeFont>
|
||||
> = {
|
||||
default: 'sans',
|
||||
anthropic: 'serif',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user font preference + active preset into the concrete font that
|
||||
* should drive the DOM. Pure function so it's safe to call inside both the
|
||||
* effect that applies the attribute and the UI preview that hints at what
|
||||
* `default` will render as.
|
||||
*/
|
||||
export function resolveThemeFont(
|
||||
font: ThemeFont,
|
||||
preset: ThemePreset
|
||||
): ResolvedThemeFont {
|
||||
if (font === 'default') {
|
||||
return PRESET_DEFAULT_FONT[preset] ?? 'sans'
|
||||
}
|
||||
return font
|
||||
}
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
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 { useEffect, useState } from 'react'
|
||||
|
||||
export function resolveThemeRadiusPx(
|
||||
cssVariable = '--radius-md'
|
||||
): number | undefined {
|
||||
if (typeof document === 'undefined') return undefined
|
||||
|
||||
const probe = document.createElement('div')
|
||||
probe.style.borderRadius = `var(${cssVariable})`
|
||||
probe.style.pointerEvents = 'none'
|
||||
probe.style.position = 'absolute'
|
||||
probe.style.visibility = 'hidden'
|
||||
|
||||
document.documentElement.appendChild(probe)
|
||||
const resolvedRadius = getComputedStyle(probe).borderTopLeftRadius
|
||||
probe.remove()
|
||||
|
||||
const parsedRadius = Number.parseFloat(resolvedRadius)
|
||||
return Number.isFinite(parsedRadius) ? parsedRadius : undefined
|
||||
}
|
||||
|
||||
export function useThemeRadiusPx(
|
||||
cssVariable = '--radius-md',
|
||||
refreshKey?: string
|
||||
): number | undefined {
|
||||
const [radius, setRadius] = useState<number | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
setRadius(resolveThemeRadiusPx(cssVariable))
|
||||
}, [cssVariable, refreshKey])
|
||||
|
||||
return radius
|
||||
}
|
||||
Vendored
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
/**
|
||||
* Time utility functions for consistent time handling across the application
|
||||
*/
|
||||
import dayjs from '@/lib/dayjs'
|
||||
|
||||
/**
|
||||
* Time granularity type
|
||||
*/
|
||||
export type TimeGranularity = 'hour' | 'day' | 'week'
|
||||
|
||||
/**
|
||||
* Convert Date object to Unix timestamp (seconds)
|
||||
*/
|
||||
export function dateToUnixTimestamp(date: Date): number {
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of day for a Unix timestamp (seconds)
|
||||
* Sets time to 00:00:00
|
||||
*/
|
||||
export function toStartOfDay(tsSec: number): number {
|
||||
const d = new Date(tsSec * 1000)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return Math.floor(d.getTime() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start of day for a Date object
|
||||
* Returns new Date with time set to 00:00:00
|
||||
*/
|
||||
export function getStartOfDay(date: Date = new Date()): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* Get end of day for a Date object
|
||||
* Returns new Date with time set to 23:59:59.999
|
||||
*/
|
||||
export function getEndOfDay(date: Date = new Date()): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(23, 59, 59, 999)
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate date range with start and end of day normalization
|
||||
* @param days Number of days to go back
|
||||
* @param fromDate Starting point (defaults to now)
|
||||
* @returns Object with normalized start (00:00:00) and end (23:59:59) dates
|
||||
*/
|
||||
export function getNormalizedDateRange(
|
||||
days: number,
|
||||
fromDate: Date = new Date()
|
||||
): { start: Date; end: Date } {
|
||||
const end = new Date(fromDate)
|
||||
const start = new Date(fromDate)
|
||||
start.setDate(end.getDate() - days)
|
||||
|
||||
return {
|
||||
start: getStartOfDay(start),
|
||||
end: getEndOfDay(end),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a rolling date range ending at the current moment.
|
||||
* Example: 1 day means the last 24 hours, not yesterday 00:00 to today 23:59.
|
||||
*/
|
||||
export function getRollingDateRange(
|
||||
days: number,
|
||||
fromDate: Date = new Date()
|
||||
): { start: Date; end: Date } {
|
||||
const end = new Date(fromDate)
|
||||
const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute time range as Unix timestamps (seconds)
|
||||
* @param days Default number of days if no dates provided
|
||||
* @param startDate Optional start date
|
||||
* @param endDate Optional end date
|
||||
* @param useStartOfDay Whether to normalize to start/end of day
|
||||
* @returns Object with start_timestamp and end_timestamp in seconds
|
||||
*/
|
||||
export function computeTimeRange(
|
||||
days: number,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
useStartOfDay = false
|
||||
): { start_timestamp: number; end_timestamp: number } {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
if (useStartOfDay) {
|
||||
const defaultEnd = toStartOfDay(now)
|
||||
const end = endDate
|
||||
? toStartOfDay(dateToUnixTimestamp(endDate))
|
||||
: defaultEnd
|
||||
const start = startDate
|
||||
? toStartOfDay(dateToUnixTimestamp(startDate))
|
||||
: end - days * 24 * 3600
|
||||
|
||||
return {
|
||||
start_timestamp: start,
|
||||
end_timestamp: end + 24 * 3600 - 1, // End of day
|
||||
}
|
||||
}
|
||||
|
||||
// Normal mode without day normalization
|
||||
// Add 1 hour buffer to end time (matches legacy frontend behavior)
|
||||
// This ensures the current hour's data is fully included
|
||||
const end = endDate ? dateToUnixTimestamp(endDate) : now + 3600
|
||||
const start = startDate
|
||||
? dateToUnixTimestamp(startDate)
|
||||
: end - days * 24 * 3600
|
||||
|
||||
return { start_timestamp: start, end_timestamp: end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Unix timestamp (seconds) to YYYY-MM-DD
|
||||
*/
|
||||
export function formatDate(tsSec: number): string {
|
||||
return dayjs(tsSec * 1000).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Date object to YYYY-MM-DD HH:mm:ss
|
||||
*/
|
||||
export function formatDateTimeObject(date: Date): string {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for chart display based on time granularity
|
||||
* @param timestamp Unix timestamp in seconds
|
||||
* @param granularity Time granularity: 'hour', 'day', or 'week'
|
||||
* @returns Formatted string suitable for chart axis
|
||||
*/
|
||||
export function formatChartTime(
|
||||
timestamp: number,
|
||||
granularity: TimeGranularity = 'day'
|
||||
): string {
|
||||
const d = dayjs(timestamp * 1000)
|
||||
let result = d.format('MM-DD')
|
||||
|
||||
if (granularity === 'hour') {
|
||||
result += ` ${d.format('HH')}:00`
|
||||
} else if (granularity === 'week') {
|
||||
const weekEnd = d.add(6, 'day')
|
||||
result += ` - ${weekEnd.format('MM-DD')}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time duration to a date
|
||||
* @param months Number of months to add
|
||||
* @param days Number of days to add
|
||||
* @param hours Number of hours to add
|
||||
* @param baseDate Base date to add time to (defaults to now)
|
||||
* @returns New date with added time, or undefined if all parameters are 0
|
||||
*/
|
||||
export function addTimeToDate(
|
||||
months: number,
|
||||
days: number,
|
||||
hours: number,
|
||||
baseDate: Date = new Date()
|
||||
): Date | undefined {
|
||||
if (months === 0 && days === 0 && hours === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = new Date(baseDate)
|
||||
result.setMonth(result.getMonth() + months)
|
||||
result.setDate(result.getDate() + days)
|
||||
result.setHours(result.getHours() + hours)
|
||||
|
||||
return result
|
||||
}
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
|
||||
/**
|
||||
* Lazy-load VChart's `ThemeManager` and switch its theme to follow the
|
||||
* resolved app theme (light / dark). Returns flags consumers can use to
|
||||
* defer chart rendering until the theme is ready.
|
||||
*/
|
||||
let themeManagerPromise: Promise<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager']
|
||||
> | null = null
|
||||
|
||||
export function useChartTheme() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [themeReady, setThemeReady] = useState(false)
|
||||
const themeRef = useRef<
|
||||
(typeof import('@visactor/vchart'))['ThemeManager'] | null
|
||||
>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const updateTheme = async () => {
|
||||
setThemeReady(false)
|
||||
if (!themeManagerPromise) {
|
||||
themeManagerPromise = import('@visactor/vchart').then(
|
||||
(m) => m.ThemeManager
|
||||
)
|
||||
}
|
||||
const ThemeManager = await themeManagerPromise
|
||||
if (cancelled) return
|
||||
themeRef.current = ThemeManager
|
||||
ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light')
|
||||
setThemeReady(true)
|
||||
}
|
||||
updateTheme()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [resolvedTheme])
|
||||
|
||||
return { resolvedTheme, themeReady }
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
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 * as React from 'react'
|
||||
|
||||
/**
|
||||
* Local helper for components that can be either controlled or uncontrolled.
|
||||
*
|
||||
* Mirrors the original signature: pass either a controlled `prop` (with
|
||||
* optional `onChange` callback) or an uncontrolled `defaultProp`. Returns a
|
||||
* `[value, setValue]` tuple where `setValue` accepts either a new value or
|
||||
* an updater function, just like `useState`.
|
||||
*
|
||||
* The return tuple's value type is non-`undefined` whenever `defaultProp`
|
||||
* is provided, keeping existing call sites well-typed.
|
||||
*/
|
||||
type SetStateFn<T> = (prevState?: T) => T
|
||||
|
||||
type Setter<T> = (next: T | undefined | SetStateFn<T | undefined>) => void
|
||||
|
||||
export function useControllableState<T>(params: {
|
||||
prop?: T | undefined
|
||||
defaultProp: T
|
||||
onChange?: (state: T) => void
|
||||
}): [T, Setter<T>]
|
||||
export function useControllableState<T>(params: {
|
||||
prop?: T | undefined
|
||||
defaultProp?: T | undefined
|
||||
onChange?: (state: T) => void
|
||||
}): [T | undefined, Setter<T>]
|
||||
export function useControllableState<T>({
|
||||
prop,
|
||||
defaultProp,
|
||||
onChange,
|
||||
}: {
|
||||
prop?: T | undefined
|
||||
defaultProp?: T | undefined
|
||||
onChange?: (state: T) => void
|
||||
}): [T | undefined, Setter<T>] {
|
||||
const [uncontrolledProp, setUncontrolledProp] = React.useState<T | undefined>(
|
||||
defaultProp
|
||||
)
|
||||
const isControlled = prop !== undefined
|
||||
const value = isControlled ? prop : uncontrolledProp
|
||||
const handleChangeRef = React.useRef(onChange)
|
||||
|
||||
React.useEffect(() => {
|
||||
handleChangeRef.current = onChange
|
||||
})
|
||||
|
||||
const setValue = React.useCallback<Setter<T>>(
|
||||
(next) => {
|
||||
if (isControlled) {
|
||||
const nextValue =
|
||||
typeof next === 'function'
|
||||
? (next as SetStateFn<T | undefined>)(prop)
|
||||
: next
|
||||
if (nextValue !== prop) {
|
||||
handleChangeRef.current?.(nextValue as T)
|
||||
}
|
||||
} else {
|
||||
setUncontrolledProp(
|
||||
next as T | undefined | ((prev: T | undefined) => T | undefined)
|
||||
)
|
||||
}
|
||||
},
|
||||
[isControlled, prop]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
Vendored
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function sleep(ms: number = 1000) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 CSS 变量名,替换特殊字符
|
||||
* 用于将模型名称(如 gpt-3.5-turbo)转换为有效的 CSS 变量名(gpt-3-5-turbo)
|
||||
* @param name - 原始名称
|
||||
* @returns 清理后的 CSS 变量名
|
||||
*/
|
||||
export function sanitizeCssVariableName(name: string): string {
|
||||
// 将点号、空格、斜杠替换为连字符
|
||||
// 移除其他不允许在 CSS 变量名中的特殊字符
|
||||
return name.replace(/[.\s/]/g, '-').replace(/[^\w-]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates page numbers for pagination with ellipsis
|
||||
* @param currentPage - Current page number (1-based)
|
||||
* @param totalPages - Total number of pages
|
||||
* @returns Array of page numbers and ellipsis strings
|
||||
*
|
||||
* Examples:
|
||||
* - Small dataset (≤4 pages): [1, 2, 3, 4]
|
||||
* - Near beginning: [1, 2, '...', 10]
|
||||
* - In middle: [1, '...', 5, '...', 10]
|
||||
* - Near end: [1, '...', 9, 10]
|
||||
*/
|
||||
export function getPageNumbers(currentPage: number, totalPages: number) {
|
||||
const maxVisiblePages = 4
|
||||
const rangeWithDots = []
|
||||
|
||||
if (totalPages <= maxVisiblePages) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
rangeWithDots.push(i)
|
||||
}
|
||||
} else {
|
||||
rangeWithDots.push(1)
|
||||
|
||||
if (currentPage <= 2) {
|
||||
rangeWithDots.push(2)
|
||||
rangeWithDots.push('...', totalPages)
|
||||
} else if (currentPage >= totalPages - 1) {
|
||||
rangeWithDots.push('...')
|
||||
rangeWithDots.push(totalPages - 1, totalPages)
|
||||
} else {
|
||||
rangeWithDots.push('...')
|
||||
rangeWithDots.push(currentPage)
|
||||
rangeWithDots.push('...', totalPages)
|
||||
}
|
||||
}
|
||||
|
||||
return rangeWithDots
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum length with ellipsis
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (!text || text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse and pretty-print JSON, fallback to original text if invalid
|
||||
* @param text - Text that might be JSON
|
||||
* @returns Pretty-printed JSON or original text
|
||||
*/
|
||||
export function tryPrettyJson(text: string): string {
|
||||
const raw = (text ?? '').toString().trim()
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
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 VCHART_OPTION = {
|
||||
// 与老前端保持一致(浏览器环境渲染优化)
|
||||
mode: 'desktop-browser',
|
||||
} as const
|
||||
Reference in New Issue
Block a user