admin login working

This commit is contained in:
1dabread 2026-08-12 13:24:00 -05:00
parent 09d5639ab9
commit 0ecd62e796
16 changed files with 397 additions and 109 deletions

View File

@ -8,6 +8,9 @@ LOG_LEVEL=info
APP_KEY=some_random_key
NODE_ENV=development
SESSION_DRIVER=cookie
# Admin Login credentials. ADMIN_PASS must be non-empty before admin login works.
ADMIN_USER=admin
ADMIN_PASS=replaceme
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
@ -27,4 +30,4 @@ NOMAD_STORAGE_PATH=/opt/project-nomad/storage
# CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install
# packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin.
# CREATOR_PACKS_APP_KEY=
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev

View File

@ -0,0 +1,48 @@
import AdminAuthService from '#services/admin_auth_service'
import type { HttpContext } from '@adonisjs/core/http'
export default class AdminAuthController {
async login({ request, response, session }: HttpContext) {
const user = String(request.input('user', '')).trim()
const password = String(request.input('password', ''))
const redirectTo = this.safeRedirect(request.input('redirect'))
if (!AdminAuthService.authenticate(user, password)) {
session.flash('errors', {
password: AdminAuthService.isConfigured()
? 'The admin user or password was incorrect.'
: 'Admin login is not configured. Set ADMIN_USER and ADMIN_PASS in Docker Compose.',
})
return response.redirect().back()
}
await session.regenerate()
session.put('admin.isLoggedIn', true)
return response.redirect().toPath(redirectTo)
}
async logout({ response, session }: HttpContext) {
session.forget('admin.isLoggedIn')
await session.regenerate()
return response.redirect().toPath('/home')
}
/**
* Restrict redirects to local paths and keep auth routes from looping.
*/
private safeRedirect(value: unknown): string {
const redirectTo = typeof value === 'string' ? value : '/home'
if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return '/home'
}
if (redirectTo.startsWith('/admin/login') || redirectTo.startsWith('/admin/logout')) {
return '/home'
}
return redirectTo
}
}

View File

@ -10,6 +10,12 @@ import env from '#start/env'
@inject()
export default class SettingsController {
private static publicWritableSettings = new Set([
'chat.lastModel',
'rag.defaultIngestPolicy',
'ui.theme',
])
constructor(
private systemService: SystemService,
private mapService: MapService,
@ -140,8 +146,15 @@ export default class SettingsController {
return response.status(200).send({ key, value });
}
async updateSetting({ request, response }: HttpContext) {
async updateSetting({ request, response, session }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
if (
!session.get('admin.isLoggedIn') &&
!SettingsController.publicWritableSettings.has(reqData.key)
) {
return response.status(403).send({ success: false, message: 'Admin login is required.' })
}
const valueError = validateSettingValue(reqData.key, reqData.value)
if (valueError) {
return response.status(422).send({ success: false, message: valueError })

View File

@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class RequireAdminMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
if (ctx.session.get('admin.isLoggedIn')) {
return next()
}
if (ctx.request.accepts(['html', 'json']) === 'json') {
return ctx.response.status(403).send({
success: false,
message: 'Admin login is required.',
})
}
const redirectTo = encodeURIComponent(ctx.request.url(true))
return ctx.response.redirect().toPath(`/home?adminLogin=1&redirect=${redirectTo}`)
}
}

View File

@ -0,0 +1,48 @@
import { timingSafeEqual } from 'node:crypto'
import env from '#start/env'
const FALLBACK_ADMIN_USER = 'admin'
export default class AdminAuthService {
/**
* Resolve the configured admin username. The password must still be provided
* with ADMIN_PASS before login is enabled.
*/
static user(): string {
return env.get('ADMIN_USER', FALLBACK_ADMIN_USER).trim() || FALLBACK_ADMIN_USER
}
/**
* Admin login is disabled until ADMIN_PASS has a non-empty value.
*/
static isConfigured(): boolean {
return Boolean(env.get('ADMIN_PASS')?.trim())
}
/**
* Compare supplied credentials to environment-controlled credentials.
*/
static authenticate(user: string, password: string): boolean {
const configuredPassword = env.get('ADMIN_PASS')?.trim()
if (!configuredPassword) {
return false
}
return (
this.secureCompare(user, this.user()) &&
this.secureCompare(password, configuredPassword)
)
}
private static secureCompare(input: string, expected: string): boolean {
const inputBuffer = Buffer.from(input)
const expectedBuffer = Buffer.from(expected)
if (inputBuffer.length !== expectedBuffer.length) {
return false
}
return timingSafeEqual(inputBuffer, expectedBuffer)
}
}

View File

@ -1,6 +1,8 @@
import KVStore from '#models/kv_store'
import AdminAuthService from '#services/admin_auth_service'
import { SystemService } from '#services/system_service'
import { defineConfig } from '@adonisjs/inertia'
import type { HttpContext } from '@adonisjs/core/http'
import type { InferSharedProps } from '@adonisjs/inertia/types'
let _assistantNameCache: { value: string; expiresAt: number } | null = null
@ -21,6 +23,11 @@ const inertiaConfig = defineConfig({
sharedData: {
appVersion: () => SystemService.getAppVersion(),
environment: process.env.NODE_ENV || 'production',
admin: ({ session }: HttpContext) => ({
isConfigured: AdminAuthService.isConfigured(),
isLoggedIn: Boolean(session?.get('admin.isLoggedIn')),
user: AdminAuthService.user(),
}),
aiAssistantName: async () => {
const now = Date.now()
if (_assistantNameCache && now < _assistantNameCache.expiresAt) {
@ -46,4 +53,4 @@ export default inertiaConfig
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {}
}
}

View File

@ -1,48 +1,42 @@
// import env from '#start/env'
// import app from '@adonisjs/core/services/app'
// import { defineConfig, stores } from '@adonisjs/session'
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
// const sessionConfig = defineConfig({
// enabled: false,
// cookieName: 'adonis-session',
const sessionConfig = defineConfig({
enabled: true,
cookieName: 'nomad-admin-session',
// /**
// * When set to true, the session id cookie will be deleted
// * once the user closes the browser.
// */
// clearWithBrowser: false,
/**
* Keep the browser session available until it expires or the admin logs out.
*/
clearWithBrowser: false,
// /**
// * Define how long to keep the session data alive without
// * any activity.
// */
// age: '2h',
/**
* Define how long to keep session data alive without activity.
*/
age: '2h',
// /**
// * Configuration for session cookie and the
// * cookie store
// */
// cookie: {
// path: '/',
// httpOnly: true,
// secure: app.inProduction,
// sameSite: 'lax',
// },
/**
* HTTP-only cookies keep the admin session marker out of client JavaScript.
*/
cookie: {
path: '/',
httpOnly: true,
secure: app.inProduction,
sameSite: 'lax',
},
// /**
// * The store to use. Make sure to validate the environment
// * variable in order to infer the store name without any
// * errors.
// */
// store: env.get('SESSION_DRIVER'),
/**
* Cookie storage avoids adding a users table for a single local admin gate.
*/
store: env.get('SESSION_DRIVER', 'cookie'),
// /**
// * List of configured stores. Refer documentation to see
// * list of available stores and their config.
// */
// stores: {
// cookie: stores.cookie(),
// },
// })
/**
* List of configured stores.
*/
stores: {
cookie: stores.cookie(),
},
})
// export default sessionConfig
export default sessionConfig

View File

@ -7,7 +7,8 @@ import {
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
@ -21,6 +22,8 @@ import {
import { useQueryClient } from '@tanstack/react-query'
import api from '~/lib/api'
import Alert from '~/components/Alert'
import Input from '~/components/inputs/Input'
import StyledModal from '~/components/StyledModal'
import WhatsNewBanner from '~/components/WhatsNewBanner'
import { SERVICE_NAMES } from '../../constants/service_names'
@ -95,6 +98,8 @@ const SYSTEM_ITEMS = [
},
]
const ADMIN_ONLY_LABELS = new Set(['Easy Setup', 'Supply Depot', 'Settings'])
interface DashboardItem {
label: string
to: string
@ -119,7 +124,52 @@ export default function Home(props: {
const updateInfo = useUpdateAvailable();
const rerunBanner = useBenchmarkRerunBanner()
const queryClient = useQueryClient()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { admin, aiAssistantName } = usePage<{
admin: { isConfigured: boolean; isLoggedIn: boolean; user: string }
aiAssistantName: string
}>().props
const [adminLoginOpen, setAdminLoginOpen] = useState(false)
const adminLoginRedirect = useMemo(() => {
const params = new URLSearchParams(window.location.search)
const redirectTo = params.get('redirect') || '/home'
if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return '/home'
}
return redirectTo
}, [])
const adminLoginForm = useForm({
user: admin.user || 'admin',
password: '',
redirect: adminLoginRedirect,
})
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get('adminLogin') === '1' && !admin.isLoggedIn) {
setAdminLoginOpen(true)
}
}, [admin.isLoggedIn])
const handleAdminLogin = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault()
adminLoginForm.post('/admin/login', {
preserveScroll: true,
onSuccess: () => setAdminLoginOpen(false),
onFinish: () => adminLoginForm.reset('password'),
})
}
const openAdminLogin = () => {
adminLoginForm.clearErrors()
adminLoginForm.setData('redirect', '/home')
setAdminLoginOpen(true)
}
const handleAdminLogout = () => {
router.post('/admin/logout', {}, { preserveScroll: true })
}
const handleDismissRerunBanner = async () => {
await api.updateSetting('benchmark.rerunBannerDismissed', true)
@ -168,8 +218,10 @@ export default function Home(props: {
items.push(DRUG_REFERENCE_ITEM)
}
// Add system items
items.push(...SYSTEM_ITEMS)
// Add system items, hiding admin-only controls until the admin logs in.
items.push(
...SYSTEM_ITEMS.filter((item) => admin.isLoggedIn || !ADMIN_ONLY_LABELS.has(item.label))
)
// Sort all items by display order
items.sort((a, b) => a.displayOrder - b.displayOrder)
@ -177,8 +229,72 @@ export default function Home(props: {
return (
<AppLayout>
<Head title="Command Center" />
<div className="fixed left-4 top-4 z-40">
<button
type="button"
onClick={admin.isLoggedIn ? handleAdminLogout : openAdminLogin}
className="inline-flex h-12 items-center gap-2 rounded-md border-2 border-desert-green bg-surface-primary px-3 text-sm font-semibold text-desert-green shadow-sm transition-colors hover:bg-desert-green hover:text-white focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand"
>
<span
aria-hidden="true"
className="h-6 w-6 bg-current"
style={{
maskImage: "url('/admin-profile.png')",
maskPosition: 'center',
maskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskImage: "url('/admin-profile.png')",
WebkitMaskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
}}
/>
<span>{admin.isLoggedIn ? 'Admin Logout' : 'Admin Login'}</span>
</button>
</div>
<StyledModal
open={adminLoginOpen}
title="Admin Login"
cancelText="Cancel"
confirmText="Log In"
confirmIcon="IconShieldLock"
confirmLoading={adminLoginForm.processing}
confirmDisabled={!admin.isConfigured}
onCancel={() => setAdminLoginOpen(false)}
onClose={() => setAdminLoginOpen(false)}
onConfirm={() => handleAdminLogin()}
>
<form id="admin-login-form" className="space-y-4 text-left" onSubmit={handleAdminLogin}>
<Input
name="user"
label="User"
value={adminLoginForm.data.user}
onChange={(event) => adminLoginForm.setData('user', event.target.value)}
autoComplete="username"
required
/>
<Input
name="password"
label="Password"
type="password"
value={adminLoginForm.data.password}
onChange={(event) => adminLoginForm.setData('password', event.target.value)}
autoComplete="current-password"
error={Boolean(adminLoginForm.errors.password)}
required
/>
{adminLoginForm.errors.password && (
<p className="text-sm font-medium text-desert-red">{adminLoginForm.errors.password}</p>
)}
{!admin.isConfigured && !adminLoginForm.errors.password && (
<p className="text-sm font-medium text-desert-orange-dark">
Admin login is not configured.
</p>
)}
</form>
</StyledModal>
{
updateInfo?.updateAvailable && (
admin.isLoggedIn && updateInfo?.updateAvailable && (
<div className='flex justify-center items-center p-4 w-full'>
<Alert
title="An update is available for Project NOMAD!"
@ -197,7 +313,7 @@ export default function Home(props: {
}
<WhatsNewBanner />
{
rerunBanner?.show && (
admin.isLoggedIn && rerunBanner?.show && (
<div className='flex justify-center items-center px-4 pt-4 w-full'>
<Alert
title="Your benchmark can be re-scored with Score v2"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -33,7 +33,15 @@ export default await Env.create(new URL('../', import.meta.url), {
| Variables for configuring session package
|----------------------------------------------------------
*/
//SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),
SESSION_DRIVER: Env.schema.enum.optional(['cookie'] as const),
/*
|----------------------------------------------------------
| Variables for configuring the built-in admin login
|----------------------------------------------------------
*/
ADMIN_USER: Env.schema.string.optional(),
ADMIN_PASS: Env.schema.string.optional(),
/*
|----------------------------------------------------------

View File

@ -37,7 +37,7 @@ server.use([
*/
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
// () => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('#middleware/compression_middleware'),
])
@ -46,4 +46,6 @@ router.use([
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({})
export const middleware = router.named({
admin: () => import('#middleware/require_admin_middleware'),
})

View File

@ -7,6 +7,7 @@
|
*/
import BenchmarkController from '#controllers/benchmark_controller'
import AdminAuthController from '#controllers/admin_auth_controller'
import ChatsController from '#controllers/chats_controller'
import ConditionsController from '#controllers/conditions_controller'
import DocsController from '#controllers/docs_controller'
@ -28,6 +29,7 @@ import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
import { documented } from '#start/openapi/documented'
import { middleware } from '#start/kernel'
import {
remoteDownloadValidator,
remoteDownloadWithMetadataValidator,
@ -101,22 +103,29 @@ router.get('/home', [HomeController, 'home'])
router.on('/about').renderInertia('about')
router.get('/chat', [ChatsController, 'inertia'])
router.get('/maps', [MapsController, 'index'])
router.get('/supply-depot', [SupplyDepotController, 'index'])
router.post('/admin/login', [AdminAuthController, 'login'])
router.post('/admin/logout', [AdminAuthController, 'logout'])
router.get('/supply-depot', [SupplyDepotController, 'index']).use(middleware.admin())
router.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redirect for legacy knowledge-base links
router.get('/easy-setup', [EasySetupController, 'index'])
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
router.get('/easy-setup', [EasySetupController, 'index']).use(middleware.admin())
router.get('/easy-setup/complete', [EasySetupController, 'complete']).use(middleware.admin())
documented(
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']),
router
.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
.use(middleware.admin()),
{
summary: 'List curated easy-setup categories',
tags: ['easy-setup'],
}
)
documented(router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']), {
summary: 'Refresh content manifests',
tags: ['easy-setup'],
})
documented(
router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']).use(middleware.admin()),
{
summary: 'Refresh content manifests',
tags: ['easy-setup'],
}
)
router
.group(() => {
documented(router.post('/check', [CollectionUpdatesController, 'checkForUpdates']), {
@ -135,6 +144,7 @@ router
})
})
.prefix('/api/content-updates')
.use(middleware.admin())
router
.group(() => {
@ -152,6 +162,7 @@ router
router.get('/advanced', [SettingsController, 'advanced'])
})
.prefix('/settings')
.use(middleware.admin())
router
.group(() => {
@ -181,26 +192,26 @@ router
summary: 'Fetch the latest map collections',
tags: ['maps'],
})
documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']), {
documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']).use(middleware.admin()), {
summary: 'Download base map assets',
tags: ['maps'],
request: remoteDownloadValidatorOptional,
})
documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']), {
documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']).use(middleware.admin()), {
summary: 'Provision the world base map',
tags: ['maps'],
})
documented(router.post('/download-remote', [MapsController, 'downloadRemote']), {
documented(router.post('/download-remote', [MapsController, 'downloadRemote']).use(middleware.admin()), {
summary: 'Queue a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']), {
documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']).use(middleware.admin()), {
summary: 'Preflight a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-collection', [MapsController, 'downloadCollection']), {
documented(router.post('/download-collection', [MapsController, 'downloadCollection']).use(middleware.admin()), {
summary: 'Download a map collection',
tags: ['maps'],
request: downloadCollectionValidator,
@ -209,7 +220,7 @@ router
summary: 'Get global map information',
tags: ['maps'],
})
documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']), {
documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']).use(middleware.admin()), {
summary: 'Download the global map',
tags: ['maps'],
})
@ -221,12 +232,12 @@ router
summary: 'List country groups',
tags: ['maps'],
})
documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']), {
documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']).use(middleware.admin()), {
summary: 'Preflight a map region extraction',
tags: ['maps'],
request: mapExtractPreflightValidator,
})
documented(router.post('/extract', [MapsController, 'extractRegion']), {
documented(router.post('/extract', [MapsController, 'extractRegion']).use(middleware.admin()), {
summary: 'Extract a map region',
tags: ['maps'],
request: mapExtractValidator,
@ -247,7 +258,7 @@ router
summary: 'Delete a map marker',
tags: ['maps'],
})
documented(router.delete('/:filename', [MapsController, 'delete']), {
documented(router.delete('/:filename', [MapsController, 'delete']).use(middleware.admin()), {
summary: 'Delete a map file',
tags: ['maps'],
params: filenameParamValidator,
@ -534,110 +545,110 @@ router
summary: 'List services',
tags: ['system'],
})
documented(router.post('/services/affect', [SystemController, 'affectService']), {
documented(router.post('/services/affect', [SystemController, 'affectService']).use(middleware.admin()), {
summary: 'Start, stop, or restart a service',
tags: ['system'],
request: affectServiceValidator,
})
documented(router.post('/services/install', [SystemController, 'installService']), {
documented(router.post('/services/install', [SystemController, 'installService']).use(middleware.admin()), {
summary: 'Install a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']), {
documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']).use(middleware.admin()), {
summary: 'Force reinstall a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/uninstall', [SystemController, 'uninstallService']), {
documented(router.post('/services/uninstall', [SystemController, 'uninstallService']).use(middleware.admin()), {
summary: 'Uninstall a service',
tags: ['system'],
request: uninstallServiceValidator,
})
documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']), {
documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']).use(middleware.admin()), {
summary: 'Check for service updates',
tags: ['system'],
})
documented(router.get('/services/preflight', [SystemController, 'preflightCheck']), {
documented(router.get('/services/preflight', [SystemController, 'preflightCheck']).use(middleware.admin()), {
summary: 'Preflight a service install',
tags: ['system'],
query: preflightValidator,
})
documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']), {
documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']).use(middleware.admin()), {
summary: 'Suggest an available custom port',
tags: ['system'],
})
documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']), {
documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']).use(middleware.admin()), {
summary: 'Preflight a custom app install',
tags: ['system'],
request: preflightCustomValidator,
})
documented(router.post('/services/custom', [SystemController, 'createCustomApp']), {
documented(router.post('/services/custom', [SystemController, 'createCustomApp']).use(middleware.admin()), {
summary: 'Create a custom app',
tags: ['system'],
request: customAppValidator,
})
documented(router.post('/services/existing', [SystemController, 'createExistingApp']), {
documented(router.post('/services/existing', [SystemController, 'createExistingApp']).use(middleware.admin()), {
summary: 'Add an existing Docker container as an app',
tags: ['system'],
request: existingAppValidator,
})
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']), {
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']).use(middleware.admin()), {
summary: 'Update a custom app',
tags: ['system'],
request: updateCustomAppValidator,
})
documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']), {
documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']).use(middleware.admin()), {
summary: 'Pull the latest version of a custom app',
tags: ['system'],
request: installServiceValidator,
})
documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']), {
documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']).use(middleware.admin()), {
summary: 'Delete a custom app',
tags: ['system'],
request: deleteCustomAppValidator,
})
documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']), {
documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']).use(middleware.admin()), {
summary: 'Get a custom app',
tags: ['system'],
})
documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']), {
documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']).use(middleware.admin()), {
summary: 'Set a service custom URL',
tags: ['system'],
request: setServiceCustomUrlValidator,
})
documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']), {
documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']).use(middleware.admin()), {
summary: 'Get service logs',
tags: ['system'],
query: serviceLogsValidator,
})
documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']), {
documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']).use(middleware.admin()), {
summary: 'Get service stats',
tags: ['system'],
})
documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']), {
documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']).use(middleware.admin()), {
summary: 'List available service versions',
tags: ['system'],
})
documented(router.post('/services/update', [SystemController, 'updateService']), {
documented(router.post('/services/update', [SystemController, 'updateService']).use(middleware.admin()), {
summary: 'Update a service',
tags: ['system'],
request: updateServiceValidator,
})
documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']), {
documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']).use(middleware.admin()), {
summary: 'Set service auto-update',
tags: ['system'],
request: setServiceAutoUpdateValidator,
})
documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']), {
documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get app auto-update status',
tags: ['system'],
})
documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']), {
documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get content auto-update status',
tags: ['system'],
})
documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']), {
documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']).use(middleware.admin()), {
summary: 'Subscribe to release notes',
tags: ['system'],
request: subscribeToReleaseNotesValidator,
@ -647,19 +658,19 @@ router
tags: ['system'],
query: checkLatestVersionValidator,
})
documented(router.post('/update', [SystemController, 'requestSystemUpdate']), {
documented(router.post('/update', [SystemController, 'requestSystemUpdate']).use(middleware.admin()), {
summary: 'Request a system update',
tags: ['system'],
})
documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']), {
documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']).use(middleware.admin()), {
summary: 'Get system update status',
tags: ['system'],
})
documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']), {
documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']).use(middleware.admin()), {
summary: 'Get system update logs',
tags: ['system'],
})
documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']), {
documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get system auto-update status',
tags: ['system'],
})
@ -691,18 +702,18 @@ router
summary: 'List curated ZIM categories',
tags: ['zim'],
})
documented(router.post('/download-remote', [ZimController, 'downloadRemote']), {
documented(router.post('/download-remote', [ZimController, 'downloadRemote']).use(middleware.admin()), {
summary: 'Queue a remote ZIM download',
tags: ['zim'],
request: remoteDownloadWithMetadataValidator,
})
documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']), {
documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']).use(middleware.admin()), {
summary: 'Download a ZIM category tier',
tags: ['zim'],
request: downloadCategoryTierValidator,
})
documented(router.post('/upload', [ZimController, 'upload']), {
documented(router.post('/upload', [ZimController, 'upload']).use(middleware.admin()), {
summary: 'Upload a ZIM file',
tags: ['zim'],
})
@ -710,7 +721,7 @@ router
summary: 'Get Wikipedia ZIM state',
tags: ['zim'],
})
documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']), {
documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']).use(middleware.admin()), {
summary: 'Select a Wikipedia ZIM edition',
tags: ['zim'],
request: selectWikipediaValidator,
@ -720,12 +731,12 @@ router
summary: 'List custom ZIM libraries',
tags: ['zim'],
})
documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']), {
documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']).use(middleware.admin()), {
summary: 'Add a custom ZIM library',
tags: ['zim'],
request: addCustomLibraryValidator,
})
documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']), {
documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']).use(middleware.admin()), {
summary: 'Remove a custom ZIM library',
tags: ['zim'],
params: idParamValidator,
@ -736,12 +747,12 @@ router
query: browseLibraryValidator,
})
documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']), {
documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']).use(middleware.admin()), {
summary: 'Rescan the ZIM library',
tags: ['zim'],
})
documented(router.delete('/:filename', [ZimController, 'delete']), {
documented(router.delete('/:filename', [ZimController, 'delete']).use(middleware.admin()), {
summary: 'Delete a ZIM file',
tags: ['zim'],
params: filenameParamValidator,
@ -755,11 +766,11 @@ router
summary: 'List creator packs',
tags: ['creator-packs'],
})
documented(router.post('/:id/install', [CreatorPacksController, 'install']), {
documented(router.post('/:id/install', [CreatorPacksController, 'install']).use(middleware.admin()), {
summary: 'Install a creator pack',
tags: ['creator-packs'],
})
documented(router.delete('/:id', [CreatorPacksController, 'uninstall']), {
documented(router.delete('/:id', [CreatorPacksController, 'uninstall']).use(middleware.admin()), {
summary: 'Uninstall a creator pack',
tags: ['creator-packs'],
})

View File

@ -24,6 +24,12 @@ export type SystemInformationResponse = {
export type UsePageProps = {
appVersion: string
environment: string
admin: {
isConfigured: boolean
isLoggedIn: boolean
user: string
}
aiAssistantName: string
}
export type LSBlockDevice = {
@ -160,4 +166,4 @@ export type ContentAutoUpdateStatus = {
lastError: string | null
autoDisabledReason: string | null
resources: ContentAutoUpdateResourceStatus[]
}
}

View File

@ -420,6 +420,7 @@ download_management_compose_file() {
local app_key=$(generateRandomPass)
local db_root_password=$(generateRandomPass)
local db_user_password=$(generateRandomPass)
admin_password=$(generateRandomPass)
# If MySQL data directory exists from a previous install attempt, remove it.
# MySQL only initializes credentials on first startup when the data dir is empty.
@ -434,6 +435,7 @@ download_management_compose_file() {
echo -e "${YELLOW}#${RESET} Configuring docker-compose file env variables...\\n"
sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path"
sed -i "s|APP_KEY=replaceme|APP_KEY=${app_key}|g" "$compose_file_path"
sed -i "s|ADMIN_PASS=replaceme|ADMIN_PASS=${admin_password}|g" "$compose_file_path"
sed -i "s|DB_PASSWORD=replaceme|DB_PASSWORD=${db_user_password}|g" "$compose_file_path"
sed -i "s|MYSQL_ROOT_PASSWORD=replaceme|MYSQL_ROOT_PASSWORD=${db_root_password}|g" "$compose_file_path"
@ -605,6 +607,8 @@ success_message() {
echo -e "${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\n\n"
echo -e "${GREEN}#${RESET} Project NOMAD's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${NOMAD_DIR}/start_nomad.sh${RESET}\\n"
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n"
echo -e "${GREEN}#${RESET} Admin Login user: ${WHITE_R}admin${RESET}"
echo -e "${GREEN}#${RESET} Admin Login password: ${WHITE_R}${admin_password}${RESET}\\n"
echo -e "${GREEN}#${RESET} Thank you for supporting Project NOMAD!\\n"
}

View File

@ -34,6 +34,7 @@ services:
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
- SESSION_DRIVER=cookie
# NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin
# normally auto-detects that mount, so this is a fallback used only if the container can't be
# inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place.
@ -43,6 +44,9 @@ services:
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=1q2w3e4r5t6y7u8i9o0p
# Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine.
- ADMIN_USER=admin
- ADMIN_PASS=replaceme
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)

View File

@ -9,7 +9,7 @@
name: project-nomad
services:
admin:
image: nomad:1.0
image: nomad:1.1
container_name: nomad_admin
restart: unless-stopped
extra_hosts:
@ -33,6 +33,7 @@ services:
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
- SESSION_DRIVER=cookie
# NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin
# normally auto-detects that mount, so this is a fallback used only if the container can't be
# inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place.
@ -42,6 +43,9 @@ services:
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=1q2w3e4r5t6y7u8i9o0p
# Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine.
- ADMIN_USER=admin
- ADMIN_PASS=1q2w3e4r5t6y7u8i9o0p
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)