diff --git a/backend/src/routes/__tests__/adminAuth.test.js b/backend/src/routes/__tests__/adminAuth.test.js new file mode 100644 index 0000000..1e0934f --- /dev/null +++ b/backend/src/routes/__tests__/adminAuth.test.js @@ -0,0 +1,99 @@ +const request = require('supertest'); +const express = require('express'); + +const buildChain = ({ firstResult, updateResult } = {}) => { + const chain = { + where: jest.fn().mockReturnThis(), + whereNot: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + update: jest.fn().mockResolvedValue(updateResult ?? 1), + first: jest.fn().mockResolvedValue(firstResult), + }; + return chain; +}; + +jest.mock('../../database/db', () => { + const dbMock = jest.fn(); + dbMock.raw = jest.fn(); + dbMock.__setImplementations = (...chains) => { + dbMock.mockReset(); + chains.forEach((chain) => { + dbMock.mockImplementationOnce(() => chain); + }); + }; + return { + db: dbMock, + logActivity: jest.fn().mockResolvedValue(undefined), + }; +}); + +jest.mock('../../middleware/auth-enhanced-v2', () => ({ + adminAuth: (_req, _res, next) => { + _req.admin = { id: 1, username: 'admin' }; + next(); + }, +})); + +const { db, logActivity } = require('../../database/db'); +const adminAuthRouter = require('../adminAuth'); + +describe('adminAuth profile updates', () => { + const app = express(); + app.use(express.json()); + app.use('/auth/admin', adminAuthRouter); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('updates the admin profile', async () => { + const updatedUser = { + id: 1, + username: 'newadmin', + email: 'newadmin@example.com', + must_change_password: false, + }; + + db.__setImplementations( + buildChain({ firstResult: null }), // email check + buildChain({ firstResult: null }), // username check + buildChain({ updateResult: 1 }), // update + buildChain({ firstResult: updatedUser }), // fetch updated user + ); + + const response = await request(app) + .put('/auth/admin/profile') + .send({ username: updatedUser.username, email: updatedUser.email }) + .expect(200); + + expect(response.body).toEqual({ user: updatedUser }); + expect(logActivity).toHaveBeenCalledWith( + 'admin_profile_updated', + { admin_id: 1, updated_fields: ['username', 'email'] }, + null, + { type: 'admin', id: 1, name: updatedUser.username } + ); + }); + + it('rejects email conflicts', async () => { + db.__setImplementations( + buildChain({ firstResult: { id: 2 } }) + ); + + const response = await request(app) + .put('/auth/admin/profile') + .send({ username: 'newadmin', email: 'taken@example.com' }) + .expect(409); + + expect(response.body).toEqual({ error: 'Email is already in use by another admin' }); + }); + + it('validates input', async () => { + const response = await request(app) + .put('/auth/admin/profile') + .send({ username: '', email: 'not-an-email' }) + .expect(400); + + expect(response.body.errors).toBeDefined(); + }); +}); diff --git a/backend/src/routes/__tests__/adminNotifications.test.js b/backend/src/routes/__tests__/adminNotifications.test.js new file mode 100644 index 0000000..ae956f4 --- /dev/null +++ b/backend/src/routes/__tests__/adminNotifications.test.js @@ -0,0 +1,67 @@ +const request = require('supertest'); +const express = require('express'); + +jest.mock('../../database/db', () => { + const deleteMock = jest.fn().mockResolvedValue(5); + const chain = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + whereNull: jest.fn().mockReturnThis(), + whereNotNull: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: deleteMock, + count: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue({ count: 0 }), + }; + + const dbMock = jest.fn(() => chain); + dbMock.raw = jest.fn(); + dbMock.__chain = chain; + dbMock.__deleteMock = deleteMock; + return { db: dbMock }; +}); + +jest.mock('../../middleware/auth-enhanced-v2', () => ({ + adminAuth: (_req, _res, next) => next(), +})); + +const { db } = require('../../database/db'); +const notificationsRouter = require('../adminNotifications'); + +describe('adminNotifications routes', () => { + const app = express(); + app.use(express.json()); + app.use('/admin/notifications', notificationsRouter); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('clears all notifications', async () => { + db.__deleteMock.mockResolvedValueOnce(8); + + const response = await request(app) + .delete('/admin/notifications/clear-all') + .expect(200); + + expect(db).toHaveBeenCalledWith('activity_logs'); + expect(db.__deleteMock).toHaveBeenCalledTimes(1); + expect(response.body).toEqual({ + message: 'All notifications cleared', + deletedCount: 8, + }); + }); + + it('handles database errors when clearing notifications', async () => { + db.__deleteMock.mockRejectedValueOnce(new Error('boom')); + + const response = await request(app) + .delete('/admin/notifications/clear-all') + .expect(500); + + expect(response.body).toEqual({ error: 'Failed to clear notifications' }); + }); +}); diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index 15813f5..eaa09e0 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -72,6 +72,68 @@ router.post('/change-password', [ } }); +// Update admin profile +router.put('/profile', [ + adminAuth, + body('username').trim().notEmpty().withMessage('Username is required'), + body('email').trim().isEmail().withMessage('Valid email is required') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, email } = req.body; + const userId = req.admin.id; + + // Check for email conflicts + const existingEmail = await db('admin_users') + .where('email', email) + .whereNot('id', userId) + .first(); + + if (existingEmail) { + return res.status(409).json({ error: 'Email is already in use by another admin' }); + } + + // Check username conflict (if multiple admins are supported) + const existingUsername = await db('admin_users') + .where('username', username) + .whereNot('id', userId) + .first(); + + if (existingUsername) { + return res.status(409).json({ error: 'Username is already in use by another admin' }); + } + + await db('admin_users') + .where('id', userId) + .update({ + username, + email, + updated_at: new Date() + }); + + const updatedUser = await db('admin_users') + .select('id', 'username', 'email', 'must_change_password') + .where('id', userId) + .first(); + + await logActivity( + 'admin_profile_updated', + { admin_id: userId, updated_fields: ['username', 'email'] }, + null, + { type: 'admin', id: userId, name: username } + ); + + res.json({ user: updatedUser }); + } catch (error) { + console.error('Admin profile update error:', error); + res.status(500).json({ error: 'Failed to update admin profile' }); + } +}); + // Logout router.post('/logout', adminAuth, async (req, res) => { try { @@ -96,4 +158,4 @@ router.post('/logout', adminAuth, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 865117b..8cdbcb9 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +// Delete all notifications +router.delete('/clear-all', adminAuth, async (req, res) => { + try { + const deletedCount = await db('activity_logs').delete(); + res.json({ + message: 'All notifications cleared', + deletedCount + }); + } catch (error) { + console.error('Clear all notifications error:', error); + res.status(500).json({ error: 'Failed to clear notifications' }); + } +}); + +module.exports = router; diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 764ca01..24be434 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -3,19 +3,45 @@ set -e -host="$DB_HOST" +host="${DB_HOST:-postgres}" port="${DB_PORT:-5432}" user="${DB_USER:-picpeak}" +target_db="${DB_NAME:-picpeak}" +default_db="${DB_CHECK_DB:-postgres}" + +sanitize_identifier() { + printf '%s' "$1" | sed "s/'/''/g" +} echo "Waiting for PostgreSQL at $host:$port..." -# Wait for PostgreSQL to be ready -until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do +# Wait for PostgreSQL server to accept connections (using the default database) +until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do >&2 echo "PostgreSQL is unavailable - sleeping" sleep 2 done ->&2 echo "PostgreSQL is up - executing command" +>&2 echo "PostgreSQL is up - verifying target database \"$target_db\"" + +# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised) +db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0) + +if [ "$db_exists" != "1" ]; then + >&2 echo "Database \"$target_db\" not found. Attempting to create..." + if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then + >&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible." + exit 1 + fi + >&2 echo "Database \"$target_db\" created successfully." +fi + +# Wait until the target database itself is ready to accept connections +until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do + >&2 echo "Waiting for database \"$target_db\" to accept connections..." + sleep 2 +done + +>&2 echo "Target database \"$target_db\" is ready." # Run migrations (use safe runner in production) echo "Running database migrations..." @@ -26,4 +52,4 @@ else fi # Execute the main command -exec "$@" \ No newline at end of file +exec "$@" diff --git a/docker-compose.yml b/docker-compose.yml index b67a585..b424fc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,7 +101,7 @@ services: context: ./frontend dockerfile: Dockerfile args: - - VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api} + - VITE_API_URL=${VITE_API_URL:-/api} - VITE_UMAMI_URL=${VITE_UMAMI_URL:-} - VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-} - VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-} diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 3c62d7e..157c77d 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -55,12 +55,12 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { }, }); - // Clear old notifications mutation - const clearOldMutation = useMutation({ - mutationFn: notificationsService.clearOldNotifications, + // Clear notifications mutation + const clearAllMutation = useMutation({ + mutationFn: notificationsService.clearAllNotifications, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['notifications'] }); - toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount })); + toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount })); }, }); @@ -128,12 +128,12 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { )} diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 3697c88..80e8494 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -5,6 +5,7 @@ import { inferGallerySlugFromLocation, resolveSlugFromRequestUrl, } from '../utils/galleryAuthStorage'; +import { getApiBaseUrl } from '../utils/url'; // Maintenance mode callback let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; @@ -15,7 +16,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) // Create axios instance export const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || '/api', + baseURL: getApiBaseUrl(), headers: { 'Content-Type': 'application/json', }, diff --git a/frontend/src/contexts/AdminAuthContext.tsx b/frontend/src/contexts/AdminAuthContext.tsx index dce42ea..96f0140 100644 --- a/frontend/src/contexts/AdminAuthContext.tsx +++ b/frontend/src/contexts/AdminAuthContext.tsx @@ -13,6 +13,7 @@ interface AdminAuthContextType { error: string | null; mustChangePassword: boolean; updatePasswordChanged: () => void; + updateProfile: (user: AdminUser) => void; } const AdminAuthContext = createContext(undefined); @@ -104,6 +105,11 @@ export const AdminAuthProvider: React.FC = ({ children } } }; + const updateProfile = (updatedUser: AdminUser) => { + setUser(updatedUser); + sessionStorage.setItem('admin_user', JSON.stringify(updatedUser)); + }; + return ( = ({ children } error, mustChangePassword, updatePasswordChanged, + updateProfile, }} > {children} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4e2b9e6..02370b4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1080,7 +1080,7 @@ "viewAllNotifications": "Alle Benachrichtigungen anzeigen", "noNotifications": "Keine neuen Benachrichtigungen", "markAllRead": "Alle als gelesen markieren", - "clearOld": "Alte löschen", + "clearAll": "Alle löschen", "close": "Schließen", "noNotificationsMessage": "Keine Benachrichtigungen", "notificationMessages": { @@ -1113,11 +1113,13 @@ "archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen", "archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht", "archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt", - "systemActivity": "Systemaktivität: {{type}}" + "systemActivity": "Systemaktivität: {{type}}", + "adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}" }, "notificationToasts": { "markedAllRead": "Alle Benachrichtigungen als gelesen markiert", - "clearedOld": "{{count}} alte Benachrichtigungen gelöscht" + "clearedAll": "{{count}} Benachrichtigungen gelöscht", + "profileUpdated": "Admin-Profil aktualisiert" }, "viewAllNotifications": "Alle Benachrichtigungen anzeigen", "noNotifications": "Keine neuen Benachrichtigungen", @@ -1125,6 +1127,16 @@ "markAllAsRead": "Alle als gelesen markieren", "notificationSettings": "Benachrichtigungseinstellungen", "changePassword": "Passwort ändern", + "accountSettings": { + "title": "Admin-Konto", + "description": "Aktualisiere die Zugangsdaten für die PicPeak-Administration.", + "username": "Benutzername", + "usernamePlaceholder": "Admin", + "email": "E-Mail", + "emailPlaceholder": "admin@example.com", + "updateButton": "Profil aktualisieren" + }, + "profileUpdateError": "Admin-Profil konnte nicht aktualisiert werden. Bitte versuche es erneut.", "loadingDashboard": "Dashboard wird geladen...", "activeEvents": "Aktive Veranstaltungen", "expiringSoon": "Demnächst ablaufend", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0b75324..cca43f8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -818,7 +818,7 @@ "viewAllNotifications": "View all notifications", "noNotifications": "No new notifications", "markAllRead": "Mark all read", - "clearOld": "Clear old", + "clearAll": "Clear all", "close": "Close", "noNotificationsMessage": "No notifications", "notificationMessages": { @@ -851,16 +851,28 @@ "archiveDownloaded": "Archive downloaded for \"{{eventName}}\"", "archiveDeleted": "Archive deleted for \"{{eventName}}\"", "archiveRestored": "Archive restored for \"{{eventName}}\"", - "systemActivity": "System activity: {{type}}" + "systemActivity": "System activity: {{type}}", + "adminProfileUpdated": "Admin profile updated by {{actorName}}" }, "notificationToasts": { "markedAllRead": "All notifications marked as read", - "clearedOld": "Cleared {{count}} old notifications" + "clearedAll": "Cleared {{count}} notifications", + "profileUpdated": "Admin profile updated" }, "markAsRead": "Mark as read", "markAllAsRead": "Mark all as read", "notificationSettings": "Notification Settings", "changePassword": "Change Password", + "accountSettings": { + "title": "Admin account", + "description": "Update the credentials used to sign in to PicPeak.", + "username": "Username", + "usernamePlaceholder": "Admin", + "email": "Email", + "emailPlaceholder": "admin@example.com", + "updateButton": "Update profile" + }, + "profileUpdateError": "Unable to update admin profile. Please try again.", "loadingDashboard": "Loading dashboard...", "activeEvents": "Active Events", "expiringSoon": "Expiring Soon", diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 976515f..c8625ea 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Save, Database, @@ -19,7 +19,9 @@ import { CategoryManager } from '../../components/admin/CategoryManager'; import { WordFilterManager } from '../../components/admin/WordFilterManager'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { settingsService } from '../../services/settings.service'; +import { authService } from '../../services/auth.service'; import { useTranslation } from 'react-i18next'; +import { useAdminAuth } from '../../contexts'; const BYTES_PER_GB = 1024 * 1024 * 1024; @@ -56,6 +58,23 @@ export const SettingsPage: React.FC = () => { const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general'); const queryClient = useQueryClient(); const { t, i18n } = useTranslation(); + const { user, updateProfile: updateAuthProfile } = useAdminAuth(); + + const [profileForm, setProfileForm] = useState({ + username: user?.username ?? '', + email: user?.email ?? '', + }); + const [profileError, setProfileError] = useState(null); + + useEffect(() => { + if (user) { + setProfileForm({ username: user.username, email: user.email }); + } + }, [user]); + + const isProfileDirty = user + ? (profileForm.username !== user.username || profileForm.email !== user.email) + : Boolean(profileForm.username.trim() || profileForm.email.trim()); // Fetch settings const { data: settings, isLoading } = useQuery({ @@ -78,6 +97,20 @@ export const SettingsPage: React.FC = () => { refetchInterval: 30000 // Refresh every 30 seconds }); + const updateProfileMutation = useMutation({ + mutationFn: authService.updateAdminProfile, + onSuccess: (updatedUser) => { + updateAuthProfile(updatedUser); + setProfileError(null); + toast.success(t('admin.notificationToasts.profileUpdated')); + }, + onError: (error: any) => { + const message = error?.response?.data?.error || t('admin.profileUpdateError'); + setProfileError(message); + toast.error(message); + }, + }); + // General settings state const [generalSettings, setGeneralSettings] = useState({ site_url: '', @@ -343,6 +376,19 @@ export const SettingsPage: React.FC = () => { } }); + const handleProfileSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!isProfileDirty || updateProfileMutation.isPending) { + return; + } + + setProfileError(null); + updateProfileMutation.mutate({ + username: profileForm.username.trim(), + email: profileForm.email.trim(), + }); + }; + const handleSaveCapacityOverride = () => { if (saveCapacityOverrideMutation.isPending) { return; @@ -466,6 +512,46 @@ export const SettingsPage: React.FC = () => { {/* General Settings Tab */} {activeTab === 'general' && (
+ +

{t('admin.accountSettings.title')}

+

{t('admin.accountSettings.description')}

+
+
+ + setProfileForm(prev => ({ ...prev, username: e.target.value }))} + placeholder={t('admin.accountSettings.usernamePlaceholder')} + maxLength={120} + /> +
+
+ + setProfileForm(prev => ({ ...prev, email: e.target.value }))} + placeholder={t('admin.accountSettings.emailPlaceholder')} + /> +
+ {profileError && ( +

{profileError}

+ )} +
+ +
+
+
+

{t('settings.general.siteConfiguration')}

diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 8aa1c23..1dc15a4 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -1,5 +1,5 @@ import { api } from '../config/api'; -import type { LoginResponse, GalleryAuthResponse } from '../types'; +import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({ @@ -61,4 +61,9 @@ export const authService = { // Ignore; cookie will naturally expire if removal fails } }, + + async updateAdminProfile(profile: { username: string; email: string }): Promise { + const response = await api.put<{ user: AdminUser }>('/auth/admin/profile', profile); + return response.data.user; + }, }; diff --git a/frontend/src/services/notifications.service.ts b/frontend/src/services/notifications.service.ts index d80465c..0f352e0 100644 --- a/frontend/src/services/notifications.service.ts +++ b/frontend/src/services/notifications.service.ts @@ -38,9 +38,9 @@ export const notificationsService = { await api.put('/admin/notifications/read-all'); }, - // Clear old notifications - async clearOldNotifications(): Promise<{ deletedCount: number }> { - const response = await api.delete('/admin/notifications/clear-old'); + // Clear all notifications + async clearAllNotifications(): Promise<{ deletedCount: number }> { + const response = await api.delete('/admin/notifications/clear-all'); return response.data; }, @@ -133,6 +133,10 @@ export const notificationsService = { return t('admin.notificationMessages.generalSettingsUpdated'); case 'security_settings_updated': return t('admin.notificationMessages.securitySettingsUpdated'); + case 'admin_profile_updated': + return t('admin.notificationMessages.adminProfileUpdated', { + actorName: notification.actorName, + }); case 'theme_updated': return t('admin.notificationMessages.themeUpdated'); case 'archive_downloaded': @@ -184,6 +188,8 @@ export const notificationsService = { case 'security_settings_updated': case 'theme_updated': return { icon: 'Settings', color: 'text-gray-600' }; + case 'admin_profile_updated': + return { icon: 'User', color: 'text-primary-600' }; case 'email_template_updated': case 'email_config_updated': return { icon: 'Mail', color: 'text-teal-600' }; @@ -209,4 +215,4 @@ export const notificationsService = { return { icon: 'Bell', color: 'text-gray-600' }; } } -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/__tests__/url.test.ts b/frontend/src/utils/__tests__/url.test.ts new file mode 100644 index 0000000..8c93e66 --- /dev/null +++ b/frontend/src/utils/__tests__/url.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { getApiBaseUrl, buildResourceUrl } from '../url'; +const originalLocation = window.location; + +const setLocation = (origin: string) => { + const parsed = new URL(origin); + Object.defineProperty(window, 'location', { + value: { + origin: parsed.origin, + hostname: parsed.hostname, + href: parsed.href, + }, + configurable: true, + }); +}; + +describe('url utilities', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + setLocation('https://example.com'); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + configurable: true, + }); + }); + + it('returns relative API base by default', () => { + vi.unstubAllEnvs(); + expect(getApiBaseUrl()).toBe('/api'); + expect(buildResourceUrl('/api/gallery/test')).toBe('https://example.com/api/gallery/test'); + }); + + it('honours absolute API URLs for non-local hosts', () => { + vi.stubEnv('VITE_API_URL', 'https://api.picpeak.cloud/api'); + expect(getApiBaseUrl()).toBe('https://api.picpeak.cloud/api'); + expect(buildResourceUrl('/api/gallery/test')).toBe('https://api.picpeak.cloud/api/gallery/test'); + expect(buildResourceUrl('/uploads/logo.png')).toBe('https://api.picpeak.cloud/uploads/logo.png'); + }); + + it('falls back to relative when build-time URL is localhost but browser host is remote', () => { + vi.stubEnv('VITE_API_URL', 'http://localhost:3001/api'); + setLocation('https://photos.example.com'); + expect(getApiBaseUrl()).toBe('/api'); + expect(buildResourceUrl('/api/gallery/test')).toBe('https://photos.example.com/api/gallery/test'); + expect(buildResourceUrl('uploads/logo.png')).toBe('https://photos.example.com/uploads/logo.png'); + }); + + it('keeps localhost API URL when browser is also localhost', () => { + vi.stubEnv('VITE_API_URL', 'http://127.0.0.1:3001/api'); + setLocation('http://127.0.0.1:3000'); + expect(getApiBaseUrl()).toBe('http://127.0.0.1:3001/api'); + expect(buildResourceUrl('/api/gallery/test')).toBe('http://127.0.0.1:3001/api/gallery/test'); + }); +}); diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts index 52af87a..5054df8 100644 --- a/frontend/src/utils/url.ts +++ b/frontend/src/utils/url.ts @@ -2,39 +2,126 @@ * Utility functions for URL handling in production environments */ +const ABSOLUTE_URL_REGEX = /^https?:\/\//i; +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +const isBrowser = typeof window !== 'undefined' && typeof window.location !== 'undefined'; + +const normalizeBase = (value: string): string => value.replace(/\/+$/, ''); + +const getEnvApiUrl = (): string | undefined => { + const raw = import.meta.env?.VITE_API_URL; + if (!raw || raw === '') { + return undefined; + } + if (raw === '/') { + return '/api'; + } + return raw; +}; + +const isLocalHostname = (hostname: string): boolean => LOCAL_HOSTNAMES.has(hostname.toLowerCase()); + +const shouldFallbackToRelative = (url: string): boolean => { + if (!ABSOLUTE_URL_REGEX.test(url)) { + return false; + } + + if (!isBrowser) { + return false; + } + + try { + const parsed = new URL(url); + const envHostIsLocal = isLocalHostname(parsed.hostname); + const browserHost = window.location.hostname?.toLowerCase?.() ?? ''; + const browserHostIsLocal = isLocalHostname(browserHost); + + // Only fallback when the build-time URL points to localhost/loopback + // but the runtime browser location is remote (non-local). + return envHostIsLocal && !browserHostIsLocal; + } catch { + return false; + } +}; + +const buildFromOrigin = (path: string): string => { + if (!isBrowser) { + return path; + } + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${window.location.origin}${normalizedPath}`; +}; + /** - * Get the base API URL, preferring relative URLs for production + * Get the base API URL, preferring relative URLs for production and + * falling back to relative when the build was created with localhost + * endpoints but is being accessed from a remote browser. * @returns The API base URL */ export const getApiBaseUrl = (): string => { - // If VITE_API_URL is explicitly set, use it - if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') { - return import.meta.env.VITE_API_URL; + const envUrl = getEnvApiUrl(); + + if (envUrl && envUrl !== '/api') { + if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) { + return '/api'; + } + return envUrl; } - - // In production, use relative URL + return '/api'; }; +const buildFromAbsoluteApi = (base: string, path: string): string => { + const trimmedBase = normalizeBase(base); + + // When the path already targets /api we want to preserve the suffix + if (path.startsWith('/api')) { + const pathWithoutLeadingApi = path.replace(/^\/api/, ''); + return `${trimmedBase}${pathWithoutLeadingApi}`; + } + + // For non-API assets (uploads, thumbnails, etc.) drop any /api suffix + const origin = trimmedBase.replace(/\/api$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${origin}${normalizedPath}`; +}; + /** * Build a full URL for resources (images, files, etc.) - * In production, this will use the current origin + * In production, this will prefer the current origin unless an absolute + * API URL is explicitly configured and applicable. * @param path - The resource path * @returns The full URL */ export const buildResourceUrl = (path: string): string => { - // Remove leading slash if present - const cleanPath = path.startsWith('/') ? path.slice(1) : path; - - // If we have an explicit API URL that's not relative, use it - const apiUrl = import.meta.env.VITE_API_URL; - if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) { - const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present - return `${baseUrl}/${cleanPath}`; + if (!path) { + return ''; } - - // In production (relative API), use current origin - return `${window.location.origin}/${cleanPath}`; + + // Absolute paths (http/https) should generally be respected, + // except when they point to localhost but we're running remotely. + if (ABSOLUTE_URL_REGEX.test(path)) { + if (!shouldFallbackToRelative(path)) { + return path; + } + + try { + const parsed = new URL(path); + return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`); + } catch { + return path; + } + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const apiBase = getApiBaseUrl(); + + if (ABSOLUTE_URL_REGEX.test(apiBase)) { + return buildFromAbsoluteApi(apiBase, normalizedPath); + } + + return buildFromOrigin(normalizedPath); }; /** @@ -42,5 +129,6 @@ export const buildResourceUrl = (path: string): string => { * @returns True if in production mode */ export const isProductionMode = (): boolean => { - return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api'; -}; \ No newline at end of file + const apiBase = getApiBaseUrl(); + return !ABSOLUTE_URL_REGEX.test(apiBase); +}; diff --git a/scripts/setup.sh b/scripts/setup.sh index 743162a..ab19fe5 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -630,7 +630,11 @@ setup_native_installation() { apt-get install -y build-essential python3 ;; dnf|yum) - $PACKAGE_MANAGER groupinstall -y "Development Tools" + if "$PACKAGE_MANAGER" --version 2>/dev/null | grep -Ei 'dnf( |-)5' >/dev/null; then + $PACKAGE_MANAGER install -y @development-tools + else + $PACKAGE_MANAGER groupinstall -y "Development Tools" + fi $PACKAGE_MANAGER install -y python3 ;; esac @@ -1006,7 +1010,13 @@ print_success_message() { fi else echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}" - echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}" + local reset_hint + if [[ "$INSTALL_METHOD" == "docker" ]]; then + reset_hint="docker compose exec backend node scripts/reset-admin-password.js" + else + reset_hint="cd $NATIVE_APP_DIR/app/backend && node scripts/reset-admin-password.js" + fi + echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run $reset_hint)${NC}" fi echo echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"