mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-27 09:46:26 +02:00
Add settings page with profile, notifications, and admin sections
- Add sidebar navigation to settings page - Add profile section for name management and password change - Add admin section for user management and registration toggle - Add profile API endpoints (GET/PUT /profile, PUT /profile/password) - Add admin API endpoints (users CRUD, system settings) - Add system_settings table for registration control - Add name and is_admin columns to users table - First registered user automatically becomes admin - Check registration status on register/login page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0c8ce22cc1
commit
f46c6ad9d4
8 changed files with 1129 additions and 133 deletions
|
|
@ -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<NotificationSettings | null>(null);
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('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<UserProfile | null>(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<NotificationSettings | null>(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<UserProfile[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(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 (
|
||||
<Layout>
|
||||
|
|
@ -114,8 +248,64 @@ export default function Settings() {
|
|||
return (
|
||||
<Layout>
|
||||
<style>{`
|
||||
.settings-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.settings-nav-item:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.settings-nav-item.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.settings-nav-item svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-back {
|
||||
|
|
@ -138,11 +328,6 @@ export default function Settings() {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: var(--surface);
|
||||
border-radius: 0.75rem;
|
||||
|
|
@ -231,6 +416,11 @@ export default function Settings() {
|
|||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.settings-form-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-form-group .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
|
|
@ -240,16 +430,165 @@ export default function Settings() {
|
|||
.settings-form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-help-link {
|
||||
color: var(--primary);
|
||||
.settings-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--background);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-toggle-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-toggle-title {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 26px;
|
||||
background: var(--border);
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.active::after {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-help-link:hover {
|
||||
text-decoration: underline;
|
||||
.users-table .user-email {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.users-table .user-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.users-table .user-badge.admin {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .users-table .user-badge.admin {
|
||||
background: rgba(29, 78, 216, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.users-table .actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.users-table .btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert-error {
|
||||
background: rgba(220, 38, 38, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert-success {
|
||||
background: rgba(22, 163, 74, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
position: static;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
|
|
@ -258,107 +597,335 @@ export default function Settings() {
|
|||
← Back to Dashboard
|
||||
</Link>
|
||||
<h1 className="settings-title">Settings</h1>
|
||||
<p className="settings-subtitle">Configure notifications and preferences</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||
{success && <div className="alert alert-success" style={{ marginBottom: '1rem', background: '#f0fdf4', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: '0.5rem' }}>{success}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{success && <div className="alert alert-success">{success}</div>}
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">📱</span>
|
||||
<h2 className="settings-section-title">Telegram Notifications</h2>
|
||||
<span className={`settings-section-status ${settings?.telegram_configured ? 'configured' : 'not-configured'}`}>
|
||||
{settings?.telegram_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot
|
||||
and get your chat ID.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Bot Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={telegramBotToken}
|
||||
onChange={(e) => setTelegramBotToken(e.target.value)}
|
||||
placeholder={settings?.telegram_configured ? '••••••••••••••••' : 'Enter your bot token'}
|
||||
/>
|
||||
<p className="hint">Create a bot via @BotFather on Telegram to get a token</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Chat ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramChatId}
|
||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||
placeholder="Enter your chat ID"
|
||||
/>
|
||||
<p className="hint">Send /start to @userinfobot to get your chat ID</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveTelegram}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Telegram Settings'}
|
||||
</button>
|
||||
{settings?.telegram_configured && (
|
||||
<div className="settings-container">
|
||||
<div className="settings-sidebar">
|
||||
<nav className="settings-nav">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestTelegram}
|
||||
disabled={isTesting === 'telegram'}
|
||||
className={`settings-nav-item ${activeSection === 'profile' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveSection('profile'); clearMessages(); }}
|
||||
>
|
||||
{isTesting === 'telegram' ? 'Sending...' : 'Send Test'}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-item ${activeSection === 'notifications' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveSection('notifications'); clearMessages(); }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
Notifications
|
||||
</button>
|
||||
{profile?.is_admin && (
|
||||
<button
|
||||
className={`settings-nav-item ${activeSection === 'admin' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveSection('admin'); clearMessages(); }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
Admin
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
{activeSection === 'profile' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">👤</span>
|
||||
<h2 className="settings-section-title">Profile Information</h2>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Update your display name and email preferences.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
<p className="hint">Email cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSavingProfile}
|
||||
>
|
||||
{isSavingProfile ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">🔒</span>
|
||||
<h2 className="settings-section-title">Change Password</h2>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Update your password to keep your account secure.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleChangePassword}
|
||||
disabled={isChangingPassword || !currentPassword || !newPassword}
|
||||
>
|
||||
{isChangingPassword ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">💬</span>
|
||||
<h2 className="settings-section-title">Discord Notifications</h2>
|
||||
<span className={`settings-section-status ${settings?.discord_configured ? 'configured' : 'not-configured'}`}>
|
||||
{settings?.discord_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your
|
||||
Discord server settings.
|
||||
</p>
|
||||
{activeSection === 'notifications' && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">📱</span>
|
||||
<h2 className="settings-section-title">Telegram Notifications</h2>
|
||||
<span className={`settings-section-status ${notificationSettings?.telegram_configured ? 'configured' : 'not-configured'}`}>
|
||||
{notificationSettings?.telegram_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Receive price drop and back-in-stock alerts via Telegram. You'll need to create a Telegram bot
|
||||
and get your chat ID.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input
|
||||
type="password"
|
||||
value={discordWebhookUrl}
|
||||
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
|
||||
placeholder={settings?.discord_configured ? '••••••••••••••••' : 'https://discord.com/api/webhooks/...'}
|
||||
/>
|
||||
<p className="hint">Server Settings → Integrations → Webhooks → New Webhook</p>
|
||||
</div>
|
||||
<div className="settings-form-group">
|
||||
<label>Bot Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={telegramBotToken}
|
||||
onChange={(e) => setTelegramBotToken(e.target.value)}
|
||||
placeholder={notificationSettings?.telegram_configured ? '••••••••••••••••' : 'Enter your bot token'}
|
||||
/>
|
||||
<p className="hint">Create a bot via @BotFather on Telegram to get a token</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveDiscord}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Discord Settings'}
|
||||
</button>
|
||||
{settings?.discord_configured && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestDiscord}
|
||||
disabled={isTesting === 'discord'}
|
||||
>
|
||||
{isTesting === 'discord' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
<div className="settings-form-group">
|
||||
<label>Chat ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramChatId}
|
||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||
placeholder="Enter your chat ID"
|
||||
/>
|
||||
<p className="hint">Send /start to @userinfobot to get your chat ID</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveTelegram}
|
||||
disabled={isSavingNotifications}
|
||||
>
|
||||
{isSavingNotifications ? 'Saving...' : 'Save Telegram Settings'}
|
||||
</button>
|
||||
{notificationSettings?.telegram_configured && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestTelegram}
|
||||
disabled={isTesting === 'telegram'}
|
||||
>
|
||||
{isTesting === 'telegram' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">💬</span>
|
||||
<h2 className="settings-section-title">Discord Notifications</h2>
|
||||
<span className={`settings-section-status ${notificationSettings?.discord_configured ? 'configured' : 'not-configured'}`}>
|
||||
{notificationSettings?.discord_configured ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Receive price drop and back-in-stock alerts in a Discord channel. Create a webhook in your
|
||||
Discord server settings.
|
||||
</p>
|
||||
|
||||
<div className="settings-form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input
|
||||
type="password"
|
||||
value={discordWebhookUrl}
|
||||
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
|
||||
placeholder={notificationSettings?.discord_configured ? '••••••••••••••••' : 'https://discord.com/api/webhooks/...'}
|
||||
/>
|
||||
<p className="hint">Server Settings → Integrations → Webhooks → New Webhook</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-form-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveDiscord}
|
||||
disabled={isSavingNotifications}
|
||||
>
|
||||
{isSavingNotifications ? 'Saving...' : 'Save Discord Settings'}
|
||||
</button>
|
||||
{notificationSettings?.discord_configured && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestDiscord}
|
||||
disabled={isTesting === 'discord'}
|
||||
>
|
||||
{isTesting === 'discord' ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSection === 'admin' && profile?.is_admin && (
|
||||
<>
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">⚙️</span>
|
||||
<h2 className="settings-section-title">System Settings</h2>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Configure system-wide settings for PriceGhost.
|
||||
</p>
|
||||
|
||||
<div className="settings-toggle">
|
||||
<div className="settings-toggle-label">
|
||||
<span className="settings-toggle-title">User Registration</span>
|
||||
<span className="settings-toggle-description">
|
||||
Allow new users to register accounts
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={`toggle-switch ${systemSettings?.registration_enabled === 'true' ? 'active' : ''}`}
|
||||
onClick={handleToggleRegistration}
|
||||
disabled={isSavingAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<span className="settings-section-icon">👥</span>
|
||||
<h2 className="settings-section-title">User Management</h2>
|
||||
</div>
|
||||
<p className="settings-section-description">
|
||||
Manage user accounts and permissions.
|
||||
</p>
|
||||
|
||||
{isLoadingAdmin ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
|
||||
<span className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="user-email">{user.email}</td>
|
||||
<td>{user.name || '-'}</td>
|
||||
<td>
|
||||
{user.is_admin && <span className="user-badge admin">Admin</span>}
|
||||
</td>
|
||||
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||
<td className="actions">
|
||||
{user.id !== profile?.id && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => handleToggleAdmin(user.id, user.is_admin)}
|
||||
>
|
||||
{user.is_admin ? 'Remove Admin' : 'Make Admin'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{user.id === profile?.id && (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem' }}>
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue