feat: allow admin email updates in UI (#36)

This commit is contained in:
Paul Nothaft
2025-10-13 17:21:03 +02:00
parent f20472ca26
commit 3c2a79a31a
9 changed files with 404 additions and 12 deletions
+87
View File
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
const router = express.Router();
// Change password
router.get('/profile', adminAuth, async (req, res) => {
try {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
if (!admin) {
return res.status(404).json({ error: 'Admin user not found' });
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
}
});
router.put('/profile', [
adminAuth,
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be between 3 and 50 characters'),
body('email')
.trim()
.isEmail()
.withMessage('A valid email address is required')
.normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' });
}
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email address is already in use' });
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
res.json({
message: 'Admin profile updated successfully',
user: updatedAdmin
});
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
router.post('/change-password', [
adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
error: string | null;
mustChangePassword: boolean;
updatePasswordChanged: () => void;
updateUserProfile: (updates: Partial<AdminUser>) => void;
}
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
@@ -104,6 +105,17 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
}
};
const updateUserProfile = (updates: Partial<AdminUser>) => {
setUser((prev) => {
if (!prev) {
return prev;
}
const nextUser = { ...prev, ...updates };
sessionStorage.setItem('admin_user', JSON.stringify(nextUser));
return nextUser;
});
};
return (
<AdminAuthContext.Provider
value={{
@@ -115,6 +127,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
error,
mustChangePassword,
updatePasswordChanged,
updateUserProfile,
}}
>
{children}
+12 -1
View File
@@ -791,7 +791,18 @@
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
"dateTimeFormat": "Datums- & Zeitformat",
"dateFormat": "Datumsformat",
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden",
"accountSection": "Admin-Konto",
"accountUsername": "Admin-Benutzername",
"accountUsernameHelp": "Wird im Admin-Bereich angezeigt und in Aktivitätsprotokollen verwendet.",
"accountUsernameRequired": "Benutzername ist erforderlich",
"accountUsernameLength": "Benutzername muss mindestens 3 Zeichen lang sein",
"accountEmail": "Admin-E-Mail",
"accountEmailHelp": "Wird für die Anmeldung und für Sicherheitsbenachrichtigungen verwendet.",
"accountEmailRequired": "E-Mail-Adresse ist erforderlich",
"accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben",
"accountSaveButton": "Kontodaten speichern",
"accountSaveSuccess": "Kontodaten aktualisiert"
},
"publicSite": {
"tabLabel": "Öffentliche Seite",
+12 -1
View File
@@ -471,7 +471,18 @@
"saveGeneralSettings": "Save General Settings",
"dateTimeFormat": "Date & Time Format",
"dateFormat": "Date Format",
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
"dateFormatHelp": "How dates are displayed in emails and throughout the application",
"accountSection": "Admin Account",
"accountUsername": "Admin Username",
"accountUsernameHelp": "Displayed in the admin interface and used in activity logs.",
"accountUsernameRequired": "Username is required",
"accountUsernameLength": "Username must be at least 3 characters",
"accountEmail": "Admin Email",
"accountEmailHelp": "Used for login and receiving security notifications.",
"accountEmailRequired": "Email address is required",
"accountEmailInvalid": "Enter a valid email address",
"accountSaveButton": "Save account details",
"accountSaveSuccess": "Account details updated"
},
"publicSite": {
"tabLabel": "Public Site",
+160 -1
View File
@@ -10,7 +10,9 @@ import {
CheckCircle,
Clock,
HardDrive,
Activity
Activity,
Mail,
User
} from 'lucide-react';
import { toast } from 'react-toastify';
@@ -19,7 +21,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 { adminService } from '../../services/admin.service';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
const BYTES_PER_GB = 1024 * 1024 * 1024;
@@ -56,6 +60,7 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
// Fetch settings
const { data: settings, isLoading } = useQuery({
@@ -63,6 +68,11 @@ export const SettingsPage: React.FC = () => {
queryFn: () => settingsService.getAllSettings(),
});
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
queryKey: ['admin-profile'],
queryFn: () => adminService.getAdminProfile(),
});
// Fetch storage info
const { data: storageInfo } = useQuery({
queryKey: ['admin-storage-info'],
@@ -117,6 +127,11 @@ export const SettingsPage: React.FC = () => {
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
const [overrideDirty, setOverrideDirty] = useState(false);
const [accountForm, setAccountForm] = useState({
username: '',
email: ''
});
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
React.useEffect(() => {
if (settings) {
@@ -165,6 +180,15 @@ export const SettingsPage: React.FC = () => {
}
}, [settings, i18n]);
React.useEffect(() => {
if (adminProfile) {
setAccountForm({
username: adminProfile.username || '',
email: adminProfile.email || ''
});
}
}, [adminProfile]);
React.useEffect(() => {
if (!settings || overrideDirty) {
return;
@@ -285,6 +309,83 @@ export const SettingsPage: React.FC = () => {
}
});
const updateAdminProfileMutation = useMutation({
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
onSuccess: (updatedUser) => {
toast.success(t('settings.general.accountSaveSuccess'));
setAccountErrors({});
setAccountForm({
username: updatedUser.username,
email: updatedUser.email
});
updateUserProfile(updatedUser);
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
},
onError: (error: any) => {
if (error.response?.data?.errors) {
const fieldErrors: Record<string, string> = {};
for (const err of error.response.data.errors) {
if (err.path === 'username') {
fieldErrors.username = err.msg;
}
if (err.path === 'email') {
fieldErrors.email = err.msg;
}
}
setAccountErrors(fieldErrors);
return;
}
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('toast.saveError'));
}
}
});
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setAccountForm((prev) => ({ ...prev, [field]: value }));
if (accountErrors[field]) {
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (updateAdminProfileMutation.isPending) {
return;
}
const trimmedUsername = accountForm.username.trim();
const trimmedEmail = accountForm.email.trim();
const errors: Record<string, string> = {};
if (!trimmedUsername) {
errors.username = t('settings.general.accountUsernameRequired');
} else if (trimmedUsername.length < 3) {
errors.username = t('settings.general.accountUsernameLength');
}
if (!trimmedEmail) {
errors.email = t('settings.general.accountEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
errors.email = t('settings.general.accountEmailInvalid');
}
if (Object.keys(errors).length > 0) {
setAccountErrors(errors);
return;
}
updateAdminProfileMutation.mutate({
username: trimmedUsername,
email: trimmedEmail
});
};
const saveSoftLimitMutation = useMutation({
mutationFn: async (limitBytes: number | null) => {
return settingsService.updateSettings({
@@ -466,6 +567,64 @@ export const SettingsPage: React.FC = () => {
{/* General Settings Tab */}
{activeTab === 'general' && (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
{adminProfileLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
</div>
) : (
<form className="space-y-4" onSubmit={handleAccountSubmit}>
<div>
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountUsername')}
</label>
<Input
id="admin-account-username"
type="text"
value={accountForm.username}
onChange={handleAccountChange('username')}
placeholder="admin"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
error={accountErrors.username}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountUsernameHelp')}
</p>
</div>
<div>
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountEmail')}
</label>
<Input
id="admin-account-email"
type="email"
value={accountForm.email}
onChange={handleAccountChange('email')}
placeholder="admin@example.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
error={accountErrors.email}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountEmailHelp')}
</p>
</div>
<div className="pt-2">
<Button
type="submit"
variant="primary"
leftIcon={<Save className="w-5 h-5" />}
isLoading={updateAdminProfileMutation.isPending}
>
{t('settings.general.accountSaveButton')}
</Button>
</div>
</form>
)}
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
+21
View File
@@ -47,6 +47,17 @@ export interface Activity {
createdAt: string;
}
export interface AdminProfile {
id: number;
username: string;
email: string;
mustChangePassword?: boolean;
last_login?: string | null;
last_login_ip?: string | null;
created_at?: string;
updated_at?: string;
}
export interface AnalyticsData {
chartData: Array<{
date: string;
@@ -130,5 +141,15 @@ export const adminService = {
// Change password
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
await api.post('/admin/auth/change-password', data);
},
async getAdminProfile(): Promise<AdminProfile> {
const response = await api.get<AdminProfile>('/admin/auth/profile');
return response.data;
},
async updateAdminProfile(data: { username: string; email: string }): Promise<AdminProfile> {
const response = await api.put<{ user: AdminProfile }>('/admin/auth/profile', data);
return response.data.user;
}
};
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
test('admin can update account email via settings page', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip('Account settings UI is validated on desktop viewport');
}
const newEmail = `admin+playwright-${Date.now()}@example.com`;
await page.goto('/admin/login');
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
await page.goto('/admin/settings');
const emailInput = page.getByLabel(/Admin (Email|E-Mail)/i);
const usernameInput = page.getByLabel(/Admin (Username|Benutzername)/i);
await expect(emailInput).toBeVisible();
const originalUsername = await usernameInput.inputValue();
await emailInput.fill(newEmail);
const saveButton = page.getByRole('button', { name: /(Save account details|Kontodaten speichern)/i });
await saveButton.click();
await expect(emailInput).toHaveValue(newEmail, { timeout: 10000 });
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByText(newEmail, { exact: false })).toBeVisible();
const newLoginResponse = await page.request.post('/api/auth/admin/login', {
data: {
username: newEmail,
password: ADMIN_PASSWORD,
},
});
expect(newLoginResponse.ok()).toBeTruthy();
await emailInput.fill(ADMIN_EMAIL);
await usernameInput.fill(originalUsername);
await saveButton.click();
await expect(emailInput).toHaveValue(ADMIN_EMAIL, { timeout: 10000 });
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
const revertLoginResponse = await page.request.post('/api/auth/admin/login', {
data: {
username: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
},
});
expect(revertLoginResponse.ok()).toBeTruthy();
});
+11 -1
View File
@@ -86,8 +86,18 @@ test('admin login and gallery viewing smoke test', async ({ page }) => {
// Visit gallery share link and authenticate
await page.goto(shareLink);
const passwordField = page.getByPlaceholder(/gallery password/i);
if (await passwordField.count()) {
await passwordField.fill(GALLERY_PASSWORD);
await page.getByRole('button', { name: /View Gallery/i }).click();
}
const viewButton = page.getByRole('button', { name: /View Gallery/i });
if (await viewButton.count()) {
try {
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
} catch {
// Already inside gallery view.
}
}
// Wait for photos grid to appear
const tiles = page.locator('.relative.group');
+26 -3
View File
@@ -1,10 +1,26 @@
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
async function createExternalGallery(page) {
const externalRoot = path.join(process.cwd(), 'storage', 'external-media', 'picsum-demo', 'individual');
if (!fs.existsSync(externalRoot)) {
fs.mkdirSync(externalRoot, { recursive: true });
}
const sampleImages = ['img1.png', 'img2.png'];
for (const imageName of sampleImages) {
const source = path.join(process.cwd(), 'test-assets', imageName);
const target = path.join(externalRoot, imageName);
if (!fs.existsSync(target)) {
fs.copyFileSync(source, target);
}
}
const loginResponse = await page.request.post('/api/auth/admin/login', {
data: {
username: ADMIN_EMAIL,
@@ -72,7 +88,10 @@ async function createExternalGallery(page) {
failOnStatusCode: false,
});
expect(importResponse.ok()).toBeTruthy();
if (!importResponse.ok()) {
const bodyText = await importResponse.text();
throw new Error(`Failed to import external media: ${importResponse.status()} ${bodyText}`);
}
const importBody = await importResponse.json();
expect(importBody.imported).toBeGreaterThan(0);
@@ -113,9 +132,13 @@ test.describe('External media gallery behavior', () => {
await page.waitForLoadState('domcontentloaded');
const passwordField = page.getByPlaceholder(/gallery password/i).first();
await expect(passwordField).toBeVisible();
if (await passwordField.count()) {
await passwordField.fill(GALLERY_PASSWORD);
await page.getByRole('button', { name: /View Gallery/i }).click();
const viewButton = page.getByRole('button', { name: /View Gallery/i });
if (await viewButton.count()) {
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
}
}
const tiles = page.locator('.relative.group');
await expect(tiles.first()).toBeVisible({ timeout: 20000 });