diff --git a/client/package-lock.json b/client/package-lock.json index 05a5fdf..9a80671 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.6.1", + "version": "2.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.6.1", + "version": "2.6.2", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2bec291..0421e1f 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -206,6 +206,7 @@ const de: Record = { 'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.', 'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.oidcSignIn': 'Anmelden mit {name}', + 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', 'login.demoHint': 'Demo ausprobieren — ohne Registrierung', // Register @@ -285,6 +286,8 @@ const de: Record = { 'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com', 'admin.oidcSaved': 'OIDC-Konfiguration gespeichert', + 'admin.oidcOnlyMode': 'Passwort-Authentifizierung deaktivieren', + 'admin.oidcOnlyModeHint': 'Wenn aktiviert, ist nur SSO-Login erlaubt. Passwort-Login und Registrierung werden blockiert.', // File Types 'admin.fileTypes': 'Erlaubte Dateitypen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 73c42a2..33c828a 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -206,6 +206,7 @@ const en: Record = { 'login.oidc.invalidState': 'Invalid session. Please try again.', 'login.demoFailed': 'Demo login failed', 'login.oidcSignIn': 'Sign in with {name}', + 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', 'login.demoHint': 'Try the demo — no registration needed', // Register @@ -285,6 +286,8 @@ const en: Record = { 'admin.oidcIssuer': 'Issuer URL', 'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com', 'admin.oidcSaved': 'OIDC configuration saved', + 'admin.oidcOnlyMode': 'Disable password authentication', + 'admin.oidcOnlyModeHint': 'When enabled, only SSO login is permitted. Password-based login and registration are blocked.', // File Types 'admin.fileTypes': 'Allowed File Types', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 8f72c83..44a6ce9 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -39,6 +39,7 @@ interface OidcConfig { client_secret: string client_secret_set: boolean display_name: string + oidc_only: boolean } interface UpdateInfo { @@ -72,7 +73,7 @@ export default function AdminPage(): React.ReactElement { const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' }) // OIDC config - const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false }) const [savingOidc, setSavingOidc] = useState(false) // Registration toggle @@ -246,7 +247,7 @@ export default function AdminPage(): React.ReactElement { const handleSaveUser = async () => { try { - const payload = { + const payload: { username?: string; email?: string; role: string; password?: string } = { username: editForm.username.trim() || undefined, email: editForm.email.trim() || undefined, role: editForm.role, @@ -715,11 +716,31 @@ export default function AdminPage(): React.ReactElement { className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> + {/* OIDC-only mode toggle */} +
+
+

{t('admin.oidcOnlyMode')}

+

{t('admin.oidcOnlyModeHint')}

+
+ +
+

)} + )} - {/* OIDC / SSO login button */} - {appConfig?.oidc_configured && ( + {/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */} + {appConfig?.oidc_configured && !oidcOnly && ( <>
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index bf19952..1d13881 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -71,6 +71,13 @@ export default function SettingsPage(): React.ReactElement { const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') + const [oidcOnlyMode, setOidcOnlyMode] = useState(false) + + useEffect(() => { + authApi.getAppConfig?.().then((config) => { + if (config?.oidc_only_mode) setOidcOnlyMode(true) + }).catch(() => {}) + }, []) useEffect(() => { setMapTileUrl(settings.map_tile_url || '') @@ -398,6 +405,7 @@ export default function SettingsPage(): React.ReactElement {
{/* Change Password */} + {!oidcOnlyMode && (
@@ -446,6 +454,7 @@ export default function SettingsPage(): React.ReactElement {
+ )}
diff --git a/server/package-lock.json b/server/package-lock.json index 28bc934..4f8b27d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { - "name": "nomad-server", - "version": "2.6.1", + "name": "trek-server", + "version": "2.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "nomad-server", - "version": "2.6.1", + "name": "trek-server", + "version": "2.6.2", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", @@ -30,7 +30,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", + "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^25.5.0", @@ -516,21 +516,22 @@ } }, "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "dev": true, "license": "MIT", "dependencies": { @@ -558,6 +559,13 @@ "@types/node": "*" } }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -627,13 +635,25 @@ } }, "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", "@types/node": "*" } }, diff --git a/server/package.json b/server/package.json index 39ae754..becc76e 100644 --- a/server/package.json +++ b/server/package.json @@ -29,7 +29,7 @@ "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", + "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^25.5.0", diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 53e538b..b460e77 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -94,7 +94,7 @@ router.put('/users/:id', (req: Request, res: Response) => { router.delete('/users/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (parseInt(req.params.id) === authReq.user.id) { + if (parseInt(req.params.id as string) === authReq.user.id) { return res.status(400).json({ error: 'Cannot delete own account' }); } @@ -122,16 +122,18 @@ router.get('/oidc', (_req: Request, res: Response) => { client_id: get('oidc_client_id'), client_secret_set: !!secret, display_name: get('oidc_display_name'), + oidc_only: get('oidc_only') === 'true', }); }); router.put('/oidc', (req: Request, res: Response) => { - const { issuer, client_id, client_secret, display_name } = req.body; + const { issuer, client_id, client_secret, display_name, oidc_only } = req.body; const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); if (client_secret !== undefined) set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); + set('oidc_only', oidc_only ? 'true' : 'false'); res.json({ success: true }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 2996128..7516d3a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -59,6 +59,17 @@ function rateLimiter(maxAttempts: number, windowMs: number) { } const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); +function isOidcOnlyMode(): boolean { + const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; + const enabled = get('oidc_only') === 'true'; + if (!enabled) return false; + const oidcConfigured = !!( + (process.env.OIDC_ISSUER || get('oidc_issuer')) && + (process.env.OIDC_CLIENT_ID || get('oidc_client_id')) + ); + return oidcConfigured; +} + function maskKey(key: string | null | undefined): string | null { if (!key) return null; if (key.length <= 8) return '--------'; @@ -89,6 +100,8 @@ router.get('/app-config', (_req: Request, res: Response) => { (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) ); + const oidcOnlySetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value; + const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true'; res.json({ allow_registration: isDemo ? false : allowRegistration, has_users: userCount > 0, @@ -96,6 +109,7 @@ router.get('/app-config', (_req: Request, res: Response) => { has_maps_key: hasGoogleKey, oidc_configured: oidcConfigured, oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, + oidc_only_mode: oidcOnlyMode, allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', demo_mode: isDemo, demo_email: isDemo ? 'demo@trek.app' : undefined, @@ -118,6 +132,9 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { const { username, email, password } = req.body; const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + if (userCount > 0 && isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + } if (userCount > 0) { const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; if (setting?.value === 'false') { @@ -167,6 +184,10 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { }); router.post('/login', authLimiter, (req: Request, res: Response) => { + if (isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + } + const { email, password } = req.body; if (!email || !password) { @@ -205,6 +226,9 @@ router.get('/me', authenticate, (req: Request, res: Response) => { router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; + if (isOidcOnlyMode()) { + return res.status(403).json({ error: 'Password authentication is disabled.' }); + } if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') { return res.status(403).json({ error: 'Password change is disabled in demo mode.' }); } diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 9acddb7..176aa3c 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -22,7 +22,7 @@ interface UnsplashSearchResponse { const router = express.Router({ mergeParams: true }); router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params; + const { tripId } = req.params const { search, category, tag } = req.query; let query = ` @@ -41,12 +41,12 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) = if (category) { query += ' AND p.category_id = ?'; - params.push(category); + params.push(category as string); } if (tag) { query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; - params.push(tag); + params.push(tag as string); } query += ' ORDER BY p.created_at DESC'; @@ -73,7 +73,7 @@ router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) = }); router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const { tripId } = req.params; + const { tripId } = req.params const { name, description, lat, lng, address, category_id, price, currency, @@ -107,13 +107,13 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: } } - const place = getPlaceWithTags(placeId); + const place = getPlaceWithTags(Number(placeId)); res.status(201).json({ place }); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); if (!placeCheck) { @@ -126,7 +126,7 @@ router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId, id } = req.params; + const { tripId, id } = req.params const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; if (!place) { @@ -166,7 +166,7 @@ router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, r }); router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; if (!existingPlace) { @@ -238,7 +238,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name }); router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; + const { tripId, id } = req.params const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); if (!place) { diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 0d415f3..2a8a75d 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -1,4 +1,4 @@ -import cron from 'node-cron'; +import cron, { type ScheduledTask } from 'node-cron'; import archiver from 'archiver'; import path from 'path'; import fs from 'fs'; @@ -23,7 +23,7 @@ interface BackupSettings { keep_days: number; } -let currentTask: cron.ScheduledTask | null = null; +let currentTask: ScheduledTask | null = null; function loadSettings(): BackupSettings { try { @@ -110,7 +110,7 @@ function start(): void { } // Demo mode: hourly reset of demo user data -let demoTask: cron.ScheduledTask | null = null; +let demoTask: ScheduledTask | null = null; function startDemoReset(): void { if (demoTask) { demoTask.stop(); demoTask = null; }