diff --git a/backend/src/index.ts b/backend/src/index.ts index c6867b9..2561cd3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,6 +6,8 @@ import authRoutes from './routes/auth'; import productRoutes from './routes/products'; import priceRoutes from './routes/prices'; import settingsRoutes from './routes/settings'; +import profileRoutes from './routes/profile'; +import adminRoutes from './routes/admin'; import { startScheduler } from './services/scheduler'; // Load environment variables @@ -28,6 +30,8 @@ app.use('/api/auth', authRoutes); app.use('/api/products', productRoutes); app.use('/api/products', priceRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/profile', profileRoutes); +app.use('/api/admin', adminRoutes); // Error handling middleware app.use( diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 8df98cf..98e3f7c 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -5,12 +5,22 @@ export interface User { id: number; email: string; password_hash: string; + name: string | null; + is_admin: boolean; telegram_bot_token: string | null; telegram_chat_id: string | null; discord_webhook_url: string | null; created_at: Date; } +export interface UserProfile { + id: number; + email: string; + name: string | null; + is_admin: boolean; + created_at: Date; +} + export interface NotificationSettings { telegram_bot_token: string | null; telegram_chat_id: string | null; @@ -81,6 +91,99 @@ export const userQueries = { ); return result.rows[0] || null; }, + + getProfile: async (id: number): Promise => { + const result = await pool.query( + 'SELECT id, email, name, is_admin, created_at FROM users WHERE id = $1', + [id] + ); + return result.rows[0] || null; + }, + + updateProfile: async ( + id: number, + updates: { name?: string } + ): Promise => { + const fields: string[] = []; + const values: (string | number)[] = []; + let paramIndex = 1; + + if (updates.name !== undefined) { + fields.push(`name = $${paramIndex++}`); + values.push(updates.name); + } + + if (fields.length === 0) return null; + + values.push(id); + const result = await pool.query( + `UPDATE users SET ${fields.join(', ')} WHERE id = $${paramIndex} + RETURNING id, email, name, is_admin, created_at`, + values + ); + return result.rows[0] || null; + }, + + updatePassword: async (id: number, passwordHash: string): Promise => { + const result = await pool.query( + 'UPDATE users SET password_hash = $1 WHERE id = $2', + [passwordHash, id] + ); + return (result.rowCount ?? 0) > 0; + }, + + // Admin queries + findAll: async (): Promise => { + const result = await pool.query( + 'SELECT id, email, name, is_admin, created_at FROM users ORDER BY created_at ASC' + ); + return result.rows; + }, + + delete: async (id: number): Promise => { + const result = await pool.query( + 'DELETE FROM users WHERE id = $1', + [id] + ); + return (result.rowCount ?? 0) > 0; + }, + + setAdmin: async (id: number, isAdmin: boolean): Promise => { + const result = await pool.query( + 'UPDATE users SET is_admin = $1 WHERE id = $2', + [isAdmin, id] + ); + return (result.rowCount ?? 0) > 0; + }, +}; + +// System settings queries +export const systemSettingsQueries = { + get: async (key: string): Promise => { + const result = await pool.query( + 'SELECT value FROM system_settings WHERE key = $1', + [key] + ); + return result.rows[0]?.value || null; + }, + + set: async (key: string, value: string): Promise => { + await pool.query( + `INSERT INTO system_settings (key, value, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`, + [key, value] + ); + }, + + getAll: async (): Promise> => { + const result = await pool.query('SELECT key, value FROM system_settings'); + const settings: Record = {}; + for (const row of result.rows) { + settings[row.key] = row.value; + } + return settings; + }, }; // Product types and queries diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..86a0540 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,132 @@ +import { Router, Response, NextFunction } from 'express'; +import { AuthRequest, authMiddleware } from '../middleware/auth'; +import { userQueries, systemSettingsQueries } from '../models'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Admin middleware - checks if user is admin +const adminMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.userId!; + const user = await userQueries.findById(userId); + + if (!user || !user.is_admin) { + res.status(403).json({ error: 'Admin access required' }); + return; + } + + next(); + } catch (error) { + console.error('Admin middleware error:', error); + res.status(500).json({ error: 'Failed to verify admin status' }); + } +}; + +router.use(adminMiddleware); + +// Get all users +router.get('/users', async (_req: AuthRequest, res: Response) => { + try { + const users = await userQueries.findAll(); + res.json(users); + } catch (error) { + console.error('Error fetching users:', error); + res.status(500).json({ error: 'Failed to fetch users' }); + } +}); + +// Delete a user +router.delete('/users/:id', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const targetId = parseInt(req.params.id, 10); + + if (isNaN(targetId)) { + res.status(400).json({ error: 'Invalid user ID' }); + return; + } + + // Prevent deleting yourself + if (targetId === userId) { + res.status(400).json({ error: 'Cannot delete your own account' }); + return; + } + + const deleted = await userQueries.delete(targetId); + + if (!deleted) { + res.status(404).json({ error: 'User not found' }); + return; + } + + res.json({ message: 'User deleted successfully' }); + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + +// Toggle admin status for a user +router.put('/users/:id/admin', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const targetId = parseInt(req.params.id, 10); + const { is_admin } = req.body; + + if (isNaN(targetId)) { + res.status(400).json({ error: 'Invalid user ID' }); + return; + } + + // Prevent removing your own admin status + if (targetId === userId && !is_admin) { + res.status(400).json({ error: 'Cannot remove your own admin status' }); + return; + } + + const updated = await userQueries.setAdmin(targetId, is_admin); + + if (!updated) { + res.status(404).json({ error: 'User not found' }); + return; + } + + res.json({ message: `Admin status ${is_admin ? 'granted' : 'revoked'} successfully` }); + } catch (error) { + console.error('Error updating admin status:', error); + res.status(500).json({ error: 'Failed to update admin status' }); + } +}); + +// Get system settings +router.get('/settings', async (_req: AuthRequest, res: Response) => { + try { + const settings = await systemSettingsQueries.getAll(); + res.json(settings); + } catch (error) { + console.error('Error fetching system settings:', error); + res.status(500).json({ error: 'Failed to fetch system settings' }); + } +}); + +// Update system settings +router.put('/settings', async (req: AuthRequest, res: Response) => { + try { + const { registration_enabled } = req.body; + + if (registration_enabled !== undefined) { + await systemSettingsQueries.set('registration_enabled', String(registration_enabled)); + } + + const settings = await systemSettingsQueries.getAll(); + res.json(settings); + } catch (error) { + console.error('Error updating system settings:', error); + res.status(500).json({ error: 'Failed to update system settings' }); + } +}); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index cb2a3a1..b71a301 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,15 +1,33 @@ import { Router, Request, Response } from 'express'; import bcrypt from 'bcrypt'; -import { userQueries } from '../models'; +import { userQueries, systemSettingsQueries } from '../models'; import { generateToken } from '../middleware/auth'; const router = Router(); +// Check if registration is enabled (public endpoint for login page) +router.get('/registration-status', async (_req: Request, res: Response) => { + try { + const enabled = await systemSettingsQueries.get('registration_enabled'); + res.json({ registration_enabled: enabled !== 'false' }); + } catch (error) { + console.error('Error checking registration status:', error); + res.json({ registration_enabled: true }); // Default to true on error + } +}); + // Register new user router.post('/register', async (req: Request, res: Response) => { try { const { email, password } = req.body; + // Check if registration is enabled + const registrationEnabled = await systemSettingsQueries.get('registration_enabled'); + if (registrationEnabled === 'false') { + res.status(403).json({ error: 'Registration is currently disabled' }); + return; + } + if (!email || !password) { res.status(400).json({ error: 'Email and password are required' }); return; @@ -36,6 +54,13 @@ router.post('/register', async (req: Request, res: Response) => { const passwordHash = await bcrypt.hash(password, saltRounds); const user = await userQueries.create(email, passwordHash); + + // Make first user an admin + const allUsers = await userQueries.findAll(); + if (allUsers.length === 1) { + await userQueries.setAdmin(user.id, true); + } + const token = generateToken(user.id); res.status(201).json({ diff --git a/backend/src/routes/profile.ts b/backend/src/routes/profile.ts new file mode 100644 index 0000000..8430dbf --- /dev/null +++ b/backend/src/routes/profile.ts @@ -0,0 +1,89 @@ +import { Router, Response } from 'express'; +import bcrypt from 'bcrypt'; +import { AuthRequest, authMiddleware } from '../middleware/auth'; +import { userQueries } from '../models'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// Get current user profile +router.get('/', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const profile = await userQueries.getProfile(userId); + + if (!profile) { + res.status(404).json({ error: 'User not found' }); + return; + } + + res.json(profile); + } catch (error) { + console.error('Error fetching profile:', error); + res.status(500).json({ error: 'Failed to fetch profile' }); + } +}); + +// Update profile (name) +router.put('/', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { name } = req.body; + + const profile = await userQueries.updateProfile(userId, { name }); + + if (!profile) { + res.status(400).json({ error: 'No changes to save' }); + return; + } + + res.json(profile); + } catch (error) { + console.error('Error updating profile:', error); + res.status(500).json({ error: 'Failed to update profile' }); + } +}); + +// Change password +router.put('/password', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { current_password, new_password } = req.body; + + if (!current_password || !new_password) { + res.status(400).json({ error: 'Current password and new password are required' }); + return; + } + + if (new_password.length < 6) { + res.status(400).json({ error: 'New password must be at least 6 characters' }); + return; + } + + // Verify current password + const user = await userQueries.findById(userId); + if (!user) { + res.status(404).json({ error: 'User not found' }); + return; + } + + const isValidPassword = await bcrypt.compare(current_password, user.password_hash); + if (!isValidPassword) { + res.status(401).json({ error: 'Current password is incorrect' }); + return; + } + + // Hash new password and update + const newPasswordHash = await bcrypt.hash(new_password, 10); + await userQueries.updatePassword(userId, newPasswordHash); + + res.json({ message: 'Password updated successfully' }); + } catch (error) { + console.error('Error changing password:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +export default router; diff --git a/database/init.sql b/database/init.sql index 0aa7551..dd7b250 100644 --- a/database/init.sql +++ b/database/init.sql @@ -5,12 +5,25 @@ CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255), + is_admin BOOLEAN DEFAULT false, telegram_bot_token VARCHAR(255), telegram_chat_id VARCHAR(255), discord_webhook_url TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- System settings table +CREATE TABLE IF NOT EXISTS system_settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Default system settings +INSERT INTO system_settings (key, value) VALUES ('registration_enabled', 'true') +ON CONFLICT (key) DO NOTHING; + -- Migration: Add notification columns to users if they don't exist DO $$ BEGIN @@ -24,6 +37,25 @@ BEGIN END IF; END $$; +-- Migration: Add profile columns to users if they don't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'name' + ) THEN + ALTER TABLE users ADD COLUMN name VARCHAR(255); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'is_admin' + ) THEN + ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT false; + -- Make the first user an admin + UPDATE users SET is_admin = true WHERE id = (SELECT MIN(id) FROM users); + END IF; +END $$; + -- Products table CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e15eb66..be125da 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -38,6 +38,9 @@ export const authApi = { login: (email: string, password: string) => api.post('/auth/login', { email, password }), + + getRegistrationStatus: () => + api.get<{ registration_enabled: boolean }>('/auth/registration-status'), }; // Products API @@ -139,4 +142,45 @@ export const settingsApi = { api.post<{ message: string }>('/settings/notifications/test/discord'), }; +// Profile API +export interface UserProfile { + id: number; + email: string; + name: string | null; + is_admin: boolean; + created_at: string; +} + +export const profileApi = { + get: () => api.get('/profile'), + + update: (data: { name?: string }) => + api.put('/profile', data), + + changePassword: (currentPassword: string, newPassword: string) => + api.put<{ message: string }>('/profile/password', { + current_password: currentPassword, + new_password: newPassword, + }), +}; + +// Admin API +export interface SystemSettings { + registration_enabled: string; +} + +export const adminApi = { + getUsers: () => api.get('/admin/users'), + + deleteUser: (id: number) => api.delete(`/admin/users/${id}`), + + setUserAdmin: (id: number, isAdmin: boolean) => + api.put(`/admin/users/${id}/admin`, { is_admin: isAdmin }), + + getSettings: () => api.get('/admin/settings'), + + updateSettings: (data: { registration_enabled?: boolean }) => + api.put('/admin/settings', data), +}; + export default api; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 963c6bb..81f66f6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,31 +1,61 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import Layout from '../components/Layout'; -import { settingsApi, NotificationSettings } from '../api/client'; +import { + settingsApi, + profileApi, + adminApi, + NotificationSettings, + UserProfile, + SystemSettings, +} from '../api/client'; + +type SettingsSection = 'profile' | 'notifications' | 'admin'; export default function Settings() { - const [settings, setSettings] = useState(null); + const [activeSection, setActiveSection] = useState('profile'); const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | null>(null); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); - // Form state + // Profile state + const [profile, setProfile] = useState(null); + const [profileName, setProfileName] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSavingProfile, setIsSavingProfile] = useState(false); + const [isChangingPassword, setIsChangingPassword] = useState(false); + + // Notification state + const [notificationSettings, setNotificationSettings] = useState(null); const [telegramBotToken, setTelegramBotToken] = useState(''); const [telegramChatId, setTelegramChatId] = useState(''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(''); + const [isSavingNotifications, setIsSavingNotifications] = useState(false); + const [isTesting, setIsTesting] = useState<'telegram' | 'discord' | null>(null); + + // Admin state + const [users, setUsers] = useState([]); + const [systemSettings, setSystemSettings] = useState(null); + const [isLoadingAdmin, setIsLoadingAdmin] = useState(false); + const [isSavingAdmin, setIsSavingAdmin] = useState(false); useEffect(() => { - fetchSettings(); + fetchInitialData(); }, []); - const fetchSettings = async () => { + const fetchInitialData = async () => { try { - const response = await settingsApi.getNotifications(); - setSettings(response.data); - if (response.data.telegram_chat_id) { - setTelegramChatId(response.data.telegram_chat_id); + const [profileRes, notificationsRes] = await Promise.all([ + profileApi.get(), + settingsApi.getNotifications(), + ]); + setProfile(profileRes.data); + setProfileName(profileRes.data.name || ''); + setNotificationSettings(notificationsRes.data); + if (notificationsRes.data.telegram_chat_id) { + setTelegramChatId(notificationsRes.data.telegram_chat_id); } } catch { setError('Failed to load settings'); @@ -34,73 +64,177 @@ export default function Settings() { } }; - const handleSaveTelegram = async () => { - setIsSaving(true); + const fetchAdminData = async () => { + if (!profile?.is_admin) return; + setIsLoadingAdmin(true); + try { + const [usersRes, settingsRes] = await Promise.all([ + adminApi.getUsers(), + adminApi.getSettings(), + ]); + setUsers(usersRes.data); + setSystemSettings(settingsRes.data); + } catch { + setError('Failed to load admin data'); + } finally { + setIsLoadingAdmin(false); + } + }; + + useEffect(() => { + if (activeSection === 'admin' && profile?.is_admin && users.length === 0) { + fetchAdminData(); + } + }, [activeSection, profile]); + + const clearMessages = () => { setError(''); setSuccess(''); + }; + + // Profile handlers + const handleSaveProfile = async () => { + clearMessages(); + setIsSavingProfile(true); + try { + const response = await profileApi.update({ name: profileName }); + setProfile(response.data); + setSuccess('Profile updated successfully'); + } catch { + setError('Failed to update profile'); + } finally { + setIsSavingProfile(false); + } + }; + + const handleChangePassword = async () => { + clearMessages(); + if (newPassword !== confirmPassword) { + setError('New passwords do not match'); + return; + } + if (newPassword.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + setIsChangingPassword(true); + try { + await profileApi.changePassword(currentPassword, newPassword); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setSuccess('Password changed successfully'); + } catch (err: unknown) { + const error = err as { response?: { data?: { error?: string } } }; + setError(error.response?.data?.error || 'Failed to change password'); + } finally { + setIsChangingPassword(false); + } + }; + + // Notification handlers + const handleSaveTelegram = async () => { + clearMessages(); + setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ telegram_bot_token: telegramBotToken || null, telegram_chat_id: telegramChatId || null, }); - setSettings(response.data); + setNotificationSettings(response.data); setTelegramBotToken(''); setSuccess('Telegram settings saved successfully'); - } catch (err) { - console.error('Telegram save error:', err); + } catch { setError('Failed to save Telegram settings'); } finally { - setIsSaving(false); + setIsSavingNotifications(false); } }; const handleSaveDiscord = async () => { - setIsSaving(true); - setError(''); - setSuccess(''); + clearMessages(); + setIsSavingNotifications(true); try { const response = await settingsApi.updateNotifications({ discord_webhook_url: discordWebhookUrl || null, }); - setSettings(response.data); + setNotificationSettings(response.data); setDiscordWebhookUrl(''); setSuccess('Discord settings saved successfully'); - } catch (err) { - console.error('Discord save error:', err); + } catch { setError('Failed to save Discord settings'); } finally { - setIsSaving(false); + setIsSavingNotifications(false); } }; const handleTestTelegram = async () => { + clearMessages(); setIsTesting('telegram'); - setError(''); - setSuccess(''); try { await settingsApi.testTelegram(); setSuccess('Test notification sent to Telegram!'); } catch { - setError('Failed to send test notification. Check your settings.'); + setError('Failed to send test notification'); } finally { setIsTesting(null); } }; const handleTestDiscord = async () => { + clearMessages(); setIsTesting('discord'); - setError(''); - setSuccess(''); try { await settingsApi.testDiscord(); setSuccess('Test notification sent to Discord!'); } catch { - setError('Failed to send test notification. Check your webhook URL.'); + setError('Failed to send test notification'); } finally { setIsTesting(null); } }; + // Admin handlers + const handleToggleRegistration = async () => { + clearMessages(); + setIsSavingAdmin(true); + try { + const newValue = systemSettings?.registration_enabled !== 'true'; + const response = await adminApi.updateSettings({ registration_enabled: newValue }); + setSystemSettings(response.data); + setSuccess(`Registration ${newValue ? 'enabled' : 'disabled'}`); + } catch { + setError('Failed to update settings'); + } finally { + setIsSavingAdmin(false); + } + }; + + const handleDeleteUser = async (userId: number) => { + if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) { + return; + } + clearMessages(); + try { + await adminApi.deleteUser(userId); + setUsers(users.filter(u => u.id !== userId)); + setSuccess('User deleted successfully'); + } catch { + setError('Failed to delete user'); + } + }; + + const handleToggleAdmin = async (userId: number, currentStatus: boolean) => { + clearMessages(); + try { + await adminApi.setUserAdmin(userId, !currentStatus); + setUsers(users.map(u => u.id === userId ? { ...u, is_admin: !currentStatus } : u)); + setSuccess(`Admin status ${!currentStatus ? 'granted' : 'revoked'}`); + } catch { + setError('Failed to update admin status'); + } + }; + if (isLoading) { return ( @@ -114,8 +248,64 @@ export default function Settings() { return ( @@ -258,107 +597,335 @@ export default function Settings() { ← Back to Dashboard

Settings

-

Configure notifications and preferences

- {error &&
{error}
} - {success &&
{success}
} + {error &&
{error}
} + {success &&
{success}
} -
-
- 📱 -

Telegram Notifications

- - {settings?.telegram_configured ? 'Configured' : 'Not configured'} - -
-

- Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot - and get your chat ID. -

- -
- - setTelegramBotToken(e.target.value)} - placeholder={settings?.telegram_configured ? '••••••••••••••••' : 'Enter your bot token'} - /> -

Create a bot via @BotFather on Telegram to get a token

-
- -
- - setTelegramChatId(e.target.value)} - placeholder="Enter your chat ID" - /> -

Send /start to @userinfobot to get your chat ID

-
- -
- - {settings?.telegram_configured && ( +
+
+ +
+ +
+ {activeSection === 'profile' && ( + <> +
+
+ 👤 +

Profile Information

+
+

+ Update your display name and email preferences. +

+ +
+ + +

Email cannot be changed

+
+ +
+ + setProfileName(e.target.value)} + placeholder="Enter your name" + /> +
+ +
+ +
+
+ +
+
+ 🔒 +

Change Password

+
+

+ Update your password to keep your account secure. +

+ +
+ + setCurrentPassword(e.target.value)} + placeholder="Enter current password" + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + /> +
+ +
+ +
+
+ )} -
-
-
-
- 💬 -

Discord Notifications

- - {settings?.discord_configured ? 'Configured' : 'Not configured'} - -
-

- Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your - Discord server settings. -

+ {activeSection === 'notifications' && ( + <> +
+
+ 📱 +

Telegram Notifications

+ + {notificationSettings?.telegram_configured ? 'Configured' : 'Not configured'} + +
+

+ Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot + and get your chat ID. +

-
- - setDiscordWebhookUrl(e.target.value)} - placeholder={settings?.discord_configured ? '••••••••••••••••' : 'https://discord.com/api/webhooks/...'} - /> -

Server Settings → Integrations → Webhooks → New Webhook

-
+
+ + setTelegramBotToken(e.target.value)} + placeholder={notificationSettings?.telegram_configured ? '••••••••••••••••' : 'Enter your bot token'} + /> +

Create a bot via @BotFather on Telegram to get a token

+
-
- - {settings?.discord_configured && ( - +
+ + setTelegramChatId(e.target.value)} + placeholder="Enter your chat ID" + /> +

Send /start to @userinfobot to get your chat ID

+
+ +
+ + {notificationSettings?.telegram_configured && ( + + )} +
+
+ +
+
+ 💬 +

Discord Notifications

+ + {notificationSettings?.discord_configured ? 'Configured' : 'Not configured'} + +
+

+ Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your + Discord server settings. +

+ +
+ + setDiscordWebhookUrl(e.target.value)} + placeholder={notificationSettings?.discord_configured ? '••••••••••••••••' : 'https://discord.com/api/webhooks/...'} + /> +

Server Settings → Integrations → Webhooks → New Webhook

+
+ +
+ + {notificationSettings?.discord_configured && ( + + )} +
+
+ + )} + + {activeSection === 'admin' && profile?.is_admin && ( + <> +
+
+ ⚙️ +

System Settings

+
+

+ Configure system-wide settings for PriceGhost. +

+ +
+
+ User Registration + + Allow new users to register accounts + +
+
+
+ +
+
+ 👥 +

User Management

+
+

+ Manage user accounts and permissions. +

+ + {isLoadingAdmin ? ( +
+ +
+ ) : ( + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
EmailNameRoleJoinedActions
{user.email}{user.name || '-'} + {user.is_admin && Admin} + {new Date(user.created_at).toLocaleDateString()} + {user.id !== profile?.id && ( + <> + + + + )} + {user.id === profile?.id && ( + + (You) + + )} +
+ )} +
+ )}