@@ -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: '[email protected]',
|
||||||
|
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: '[email protected]' })
|
||||||
|
.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// Logout
|
||||||
router.post('/logout', adminAuth, async (req, res) => {
|
router.post('/logout', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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;
|
module.exports = router;
|
||||||
+30
-4
@@ -3,19 +3,45 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
host="$DB_HOST"
|
host="${DB_HOST:-postgres}"
|
||||||
port="${DB_PORT:-5432}"
|
port="${DB_PORT:-5432}"
|
||||||
user="${DB_USER:-picpeak}"
|
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..."
|
echo "Waiting for PostgreSQL at $host:$port..."
|
||||||
|
|
||||||
# Wait for PostgreSQL to be ready
|
# Wait for PostgreSQL server to accept connections (using the default database)
|
||||||
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
|
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"
|
>&2 echo "PostgreSQL is unavailable - sleeping"
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
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)
|
# Run migrations (use safe runner in production)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
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_URL=${VITE_UMAMI_URL:-}
|
||||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear old notifications mutation
|
// Clear notifications mutation
|
||||||
const clearOldMutation = useMutation({
|
const clearAllMutation = useMutation({
|
||||||
mutationFn: notificationsService.clearOldNotifications,
|
mutationFn: notificationsService.clearAllNotifications,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
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<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => clearOldMutation.mutate()}
|
onClick={() => clearAllMutation.mutate()}
|
||||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||||
title={t('admin.clearOld')}
|
title={t('admin.clearAll')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
{t('admin.clearOld')}
|
{t('admin.clearAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
inferGallerySlugFromLocation,
|
inferGallerySlugFromLocation,
|
||||||
resolveSlugFromRequestUrl,
|
resolveSlugFromRequestUrl,
|
||||||
} from '../utils/galleryAuthStorage';
|
} from '../utils/galleryAuthStorage';
|
||||||
|
import { getApiBaseUrl } from '../utils/url';
|
||||||
|
|
||||||
// Maintenance mode callback
|
// Maintenance mode callback
|
||||||
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
||||||
@@ -15,7 +16,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
|
|||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
baseURL: getApiBaseUrl(),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
updatePasswordChanged: () => void;
|
updatePasswordChanged: () => void;
|
||||||
|
updateProfile: (user: AdminUser) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
||||||
@@ -104,6 +105,11 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateProfile = (updatedUser: AdminUser) => {
|
||||||
|
setUser(updatedUser);
|
||||||
|
sessionStorage.setItem('admin_user', JSON.stringify(updatedUser));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminAuthContext.Provider
|
<AdminAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -115,6 +121,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
error,
|
error,
|
||||||
mustChangePassword,
|
mustChangePassword,
|
||||||
updatePasswordChanged,
|
updatePasswordChanged,
|
||||||
|
updateProfile,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1080,7 +1080,7 @@
|
|||||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||||
"markAllRead": "Alle als gelesen markieren",
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
"clearOld": "Alte löschen",
|
"clearAll": "Alle löschen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"noNotificationsMessage": "Keine Benachrichtigungen",
|
"noNotificationsMessage": "Keine Benachrichtigungen",
|
||||||
"notificationMessages": {
|
"notificationMessages": {
|
||||||
@@ -1113,11 +1113,13 @@
|
|||||||
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
||||||
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
||||||
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
||||||
"systemActivity": "Systemaktivität: {{type}}"
|
"systemActivity": "Systemaktivität: {{type}}",
|
||||||
|
"adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
"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",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||||
@@ -1125,6 +1127,16 @@
|
|||||||
"markAllAsRead": "Alle als gelesen markieren",
|
"markAllAsRead": "Alle als gelesen markieren",
|
||||||
"notificationSettings": "Benachrichtigungseinstellungen",
|
"notificationSettings": "Benachrichtigungseinstellungen",
|
||||||
"changePassword": "Passwort ändern",
|
"changePassword": "Passwort ändern",
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Admin-Konto",
|
||||||
|
"description": "Aktualisiere die Zugangsdaten für die PicPeak-Administration.",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"usernamePlaceholder": "Admin",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"emailPlaceholder": "[email protected]",
|
||||||
|
"updateButton": "Profil aktualisieren"
|
||||||
|
},
|
||||||
|
"profileUpdateError": "Admin-Profil konnte nicht aktualisiert werden. Bitte versuche es erneut.",
|
||||||
"loadingDashboard": "Dashboard wird geladen...",
|
"loadingDashboard": "Dashboard wird geladen...",
|
||||||
"activeEvents": "Aktive Veranstaltungen",
|
"activeEvents": "Aktive Veranstaltungen",
|
||||||
"expiringSoon": "Demnächst ablaufend",
|
"expiringSoon": "Demnächst ablaufend",
|
||||||
|
|||||||
@@ -818,7 +818,7 @@
|
|||||||
"viewAllNotifications": "View all notifications",
|
"viewAllNotifications": "View all notifications",
|
||||||
"noNotifications": "No new notifications",
|
"noNotifications": "No new notifications",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"clearOld": "Clear old",
|
"clearAll": "Clear all",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"noNotificationsMessage": "No notifications",
|
"noNotificationsMessage": "No notifications",
|
||||||
"notificationMessages": {
|
"notificationMessages": {
|
||||||
@@ -851,16 +851,28 @@
|
|||||||
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
||||||
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
||||||
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
||||||
"systemActivity": "System activity: {{type}}"
|
"systemActivity": "System activity: {{type}}",
|
||||||
|
"adminProfileUpdated": "Admin profile updated by {{actorName}}"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "All notifications marked as read",
|
"markedAllRead": "All notifications marked as read",
|
||||||
"clearedOld": "Cleared {{count}} old notifications"
|
"clearedAll": "Cleared {{count}} notifications",
|
||||||
|
"profileUpdated": "Admin profile updated"
|
||||||
},
|
},
|
||||||
"markAsRead": "Mark as read",
|
"markAsRead": "Mark as read",
|
||||||
"markAllAsRead": "Mark all as read",
|
"markAllAsRead": "Mark all as read",
|
||||||
"notificationSettings": "Notification Settings",
|
"notificationSettings": "Notification Settings",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Admin account",
|
||||||
|
"description": "Update the credentials used to sign in to PicPeak.",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Admin",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "[email protected]",
|
||||||
|
"updateButton": "Update profile"
|
||||||
|
},
|
||||||
|
"profileUpdateError": "Unable to update admin profile. Please try again.",
|
||||||
"loadingDashboard": "Loading dashboard...",
|
"loadingDashboard": "Loading dashboard...",
|
||||||
"activeEvents": "Active Events",
|
"activeEvents": "Active Events",
|
||||||
"expiringSoon": "Expiring Soon",
|
"expiringSoon": "Expiring Soon",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Database,
|
Database,
|
||||||
@@ -19,7 +19,9 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
|
|||||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
|
import { authService } from '../../services/auth.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
|
||||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
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 [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { user, updateProfile: updateAuthProfile } = useAdminAuth();
|
||||||
|
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
username: user?.username ?? '',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
});
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(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
|
// Fetch settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
@@ -78,6 +97,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
refetchInterval: 30000 // Refresh every 30 seconds
|
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
|
// General settings state
|
||||||
const [generalSettings, setGeneralSettings] = useState({
|
const [generalSettings, setGeneralSettings] = useState({
|
||||||
site_url: '',
|
site_url: '',
|
||||||
@@ -343,6 +376,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleProfileSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isProfileDirty || updateProfileMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileError(null);
|
||||||
|
updateProfileMutation.mutate({
|
||||||
|
username: profileForm.username.trim(),
|
||||||
|
email: profileForm.email.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveCapacityOverride = () => {
|
const handleSaveCapacityOverride = () => {
|
||||||
if (saveCapacityOverrideMutation.isPending) {
|
if (saveCapacityOverrideMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
@@ -466,6 +512,46 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{/* General Settings Tab */}
|
{/* General Settings Tab */}
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('admin.accountSettings.title')}</h2>
|
||||||
|
<p className="text-sm text-neutral-500 mb-4">{t('admin.accountSettings.description')}</p>
|
||||||
|
<form className="space-y-4" onSubmit={handleProfileSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('admin.accountSettings.username')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={profileForm.username}
|
||||||
|
onChange={(e) => setProfileForm(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
placeholder={t('admin.accountSettings.usernamePlaceholder')}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('admin.accountSettings.email')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={profileForm.email}
|
||||||
|
onChange={(e) => setProfileForm(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder={t('admin.accountSettings.emailPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{profileError && (
|
||||||
|
<p className="text-sm text-red-600">{profileError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isProfileDirty || updateProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending ? t('common.saving') : t('admin.accountSettings.updateButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types';
|
||||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||||
@@ -61,4 +61,9 @@ export const authService = {
|
|||||||
// Ignore; cookie will naturally expire if removal fails
|
// Ignore; cookie will naturally expire if removal fails
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateAdminProfile(profile: { username: string; email: string }): Promise<AdminUser> {
|
||||||
|
const response = await api.put<{ user: AdminUser }>('/auth/admin/profile', profile);
|
||||||
|
return response.data.user;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export const notificationsService = {
|
|||||||
await api.put('/admin/notifications/read-all');
|
await api.put('/admin/notifications/read-all');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear old notifications
|
// Clear all notifications
|
||||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
async clearAllNotifications(): Promise<{ deletedCount: number }> {
|
||||||
const response = await api.delete('/admin/notifications/clear-old');
|
const response = await api.delete('/admin/notifications/clear-all');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,6 +133,10 @@ export const notificationsService = {
|
|||||||
return t('admin.notificationMessages.generalSettingsUpdated');
|
return t('admin.notificationMessages.generalSettingsUpdated');
|
||||||
case 'security_settings_updated':
|
case 'security_settings_updated':
|
||||||
return t('admin.notificationMessages.securitySettingsUpdated');
|
return t('admin.notificationMessages.securitySettingsUpdated');
|
||||||
|
case 'admin_profile_updated':
|
||||||
|
return t('admin.notificationMessages.adminProfileUpdated', {
|
||||||
|
actorName: notification.actorName,
|
||||||
|
});
|
||||||
case 'theme_updated':
|
case 'theme_updated':
|
||||||
return t('admin.notificationMessages.themeUpdated');
|
return t('admin.notificationMessages.themeUpdated');
|
||||||
case 'archive_downloaded':
|
case 'archive_downloaded':
|
||||||
@@ -184,6 +188,8 @@ export const notificationsService = {
|
|||||||
case 'security_settings_updated':
|
case 'security_settings_updated':
|
||||||
case 'theme_updated':
|
case 'theme_updated':
|
||||||
return { icon: 'Settings', color: 'text-gray-600' };
|
return { icon: 'Settings', color: 'text-gray-600' };
|
||||||
|
case 'admin_profile_updated':
|
||||||
|
return { icon: 'User', color: 'text-primary-600' };
|
||||||
case 'email_template_updated':
|
case 'email_template_updated':
|
||||||
case 'email_config_updated':
|
case 'email_config_updated':
|
||||||
return { icon: 'Mail', color: 'text-teal-600' };
|
return { icon: 'Mail', color: 'text-teal-600' };
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+105
-17
@@ -2,39 +2,126 @@
|
|||||||
* Utility functions for URL handling in production environments
|
* 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
|
* @returns The API base URL
|
||||||
*/
|
*/
|
||||||
export const getApiBaseUrl = (): string => {
|
export const getApiBaseUrl = (): string => {
|
||||||
// If VITE_API_URL is explicitly set, use it
|
const envUrl = getEnvApiUrl();
|
||||||
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
|
|
||||||
return import.meta.env.VITE_API_URL;
|
if (envUrl && envUrl !== '/api') {
|
||||||
|
if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
return envUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, use relative URL
|
|
||||||
return '/api';
|
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.)
|
* 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
|
* @param path - The resource path
|
||||||
* @returns The full URL
|
* @returns The full URL
|
||||||
*/
|
*/
|
||||||
export const buildResourceUrl = (path: string): string => {
|
export const buildResourceUrl = (path: string): string => {
|
||||||
// Remove leading slash if present
|
if (!path) {
|
||||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
return '';
|
||||||
|
|
||||||
// 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production (relative API), use current origin
|
// Absolute paths (http/https) should generally be respected,
|
||||||
return `${window.location.origin}/${cleanPath}`;
|
// 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
|
* @returns True if in production mode
|
||||||
*/
|
*/
|
||||||
export const isProductionMode = (): boolean => {
|
export const isProductionMode = (): boolean => {
|
||||||
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
|
const apiBase = getApiBaseUrl();
|
||||||
|
return !ABSOLUTE_URL_REGEX.test(apiBase);
|
||||||
};
|
};
|
||||||
+12
-2
@@ -630,7 +630,11 @@ setup_native_installation() {
|
|||||||
apt-get install -y build-essential python3
|
apt-get install -y build-essential python3
|
||||||
;;
|
;;
|
||||||
dnf|yum)
|
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
|
$PACKAGE_MANAGER install -y python3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -1006,7 +1010,13 @@ print_success_message() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
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
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
|
|||||||
Reference in New Issue
Block a user