From b714f50fa3753524dc1524813bc1c9bf4720ccb7 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:01:03 +0200 Subject: [PATCH 01/17] Add allowed users management with mode-specific tier defaults - Implement free, paid, and exclusive modes with distinct tier configurations - Add mode-specific default tiers (free: 100MB-1GB, paid: 1-10GB with pricing, exclusive: 2-25GB) - Create comprehensive allowed users page with permissions and tier management - Add API integration for allowed users settings and NPUB management - Implement tier validation and automatic price handling per mode - Add navigation and routing for allowed users functionality --- .claude/settings.local.json | 3 + src/api/allowedUsers.api.ts | 241 ++++++++++++++ .../MigrationHelper/MigrationHelper.tsx | 6 + .../ModeSelector/ModeSelector.styles.ts | 60 ++++ .../components/ModeSelector/ModeSelector.tsx | 66 ++++ .../NPubManagement/NPubManagement.styles.ts | 65 ++++ .../NPubManagement/NPubManagement.tsx | 295 ++++++++++++++++++ .../PermissionsConfig.styles.ts | 63 ++++ .../PermissionsConfig/PermissionsConfig.tsx | 155 +++++++++ .../TiersConfig/TiersConfig.styles.ts | 40 +++ .../components/TiersConfig/TiersConfig.tsx | 291 +++++++++++++++++ src/components/allowed-users/index.ts | 6 + .../layouts/AllowedUsersLayout.styles.ts | 67 ++++ .../layouts/AllowedUsersLayout.tsx | 238 ++++++++++++++ .../layouts/main/sider/sidebarNavigation.tsx | 8 +- src/components/router/AppRouter.tsx | 3 + src/hooks/useAllowedUsers.ts | 276 ++++++++++++++++ src/pages/AllowedUsersPage.tsx | 8 + src/store/slices/allowedUsersSlice.ts | 74 +++++ src/store/slices/index.ts | 2 + src/types/allowedUsers.types.ts | 112 +++++++ 21 files changed, 2078 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json create mode 100644 src/api/allowedUsers.api.ts create mode 100644 src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx create mode 100644 src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts create mode 100644 src/components/allowed-users/components/ModeSelector/ModeSelector.tsx create mode 100644 src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts create mode 100644 src/components/allowed-users/components/NPubManagement/NPubManagement.tsx create mode 100644 src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts create mode 100644 src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx create mode 100644 src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts create mode 100644 src/components/allowed-users/components/TiersConfig/TiersConfig.tsx create mode 100644 src/components/allowed-users/index.ts create mode 100644 src/components/allowed-users/layouts/AllowedUsersLayout.styles.ts create mode 100644 src/components/allowed-users/layouts/AllowedUsersLayout.tsx create mode 100644 src/hooks/useAllowedUsers.ts create mode 100644 src/pages/AllowedUsersPage.tsx create mode 100644 src/store/slices/allowedUsersSlice.ts create mode 100644 src/types/allowedUsers.types.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e8f289d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,3 @@ +{ + "enableAllProjectMcpServers": false +} \ No newline at end of file diff --git a/src/api/allowedUsers.api.ts b/src/api/allowedUsers.api.ts new file mode 100644 index 0000000..c87a14a --- /dev/null +++ b/src/api/allowedUsers.api.ts @@ -0,0 +1,241 @@ +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; +import { + AllowedUsersSettings, + AllowedUsersApiResponse, + AllowedUsersNpubsResponse, + BulkImportRequest, + AllowedUsersNpub +} from '@app/types/allowedUsers.types'; + +// Settings Management +export const getAllowedUsersSettings = async (): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data: AllowedUsersApiResponse = JSON.parse(text); + + // Transform tiers from backend format to frontend format + const transformedSettings = { + ...data.allowed_users, + tiers: data.allowed_users.tiers.map(tier => ({ + data_limit: (tier as any).datalimit || tier.data_limit || '', + price: tier.price + })) + }; + + return transformedSettings; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + + // Transform to nested format as expected by backend + const nestedSettings = { + "allowed_users": { + "mode": settings.mode, + "read_access": { + "enabled": settings.read_access.enabled, + "scope": settings.read_access.scope + }, + "write_access": { + "enabled": settings.write_access.enabled, + "scope": settings.write_access.scope + }, + "tiers": settings.tiers.map(tier => ({ + "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values + "price": tier.price || "0" + })) + } + }; + + console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2)); + + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(nestedSettings), + }); + + const text = await response.text(); + console.log('Backend response:', response.status, text); + + if (!response.ok) { + console.error('Backend error:', response.status, text); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } + + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Read NPUBs Management +export const getReadNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addReadNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeReadNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Write NPUBs Management +export const getWriteNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addWriteNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeWriteNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Bulk Import +export const bulkImportNpubs = async (importData: BulkImportRequest): Promise<{ success: boolean, message: string, imported: number, failed: number }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/bulk-import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(importData), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx b/src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx new file mode 100644 index 0000000..92ceea4 --- /dev/null +++ b/src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx @@ -0,0 +1,6 @@ +import React from 'react'; + +export const MigrationHelper: React.FC = () => { + // No migration needed since system is not live yet + return null; +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts new file mode 100644 index 0000000..191eaf7 --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts @@ -0,0 +1,60 @@ +import styled from 'styled-components'; +import { Button } from 'antd'; +import { media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const ModeGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; + + ${media.md} { + grid-template-columns: 1fr; + gap: 0.75rem; + } +`; + +interface ModeButtonProps { + $isActive: boolean; + $color: string; +} + +export const ModeButton = styled(Button)` + height: 60px; + border-radius: 8px; + font-weight: 600; + transition: all 0.3s ease; + + ${({ $isActive, $color }) => $isActive && ` + background-color: ${$color} !important; + border-color: ${$color} !important; + box-shadow: 0 4px 12px ${$color}33; + `} + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + } + + ${media.md} { + height: 50px; + } +`; + +export const ModeDescription = styled.div` + padding: 1rem; + background: var(--background-color-secondary); + border-radius: 8px; + border: 1px solid var(--border-color-base); +`; + +export const DescriptionText = styled.p` + margin: 0; + color: var(--text-main-color); + font-size: 14px; + line-height: 1.5; +`; \ No newline at end of file diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx new file mode 100644 index 0000000..58f3047 --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button, Space, Tooltip } from 'antd'; +import { AllowedUsersMode } from '@app/types/allowedUsers.types'; +import * as S from './ModeSelector.styles'; + +interface ModeSelectorProps { + currentMode: AllowedUsersMode; + onModeChange: (mode: AllowedUsersMode) => void; + disabled?: boolean; +} + +const MODE_INFO = { + free: { + label: 'Free Mode', + description: 'Open access with optional free tiers', + color: '#1890ff' + }, + paid: { + label: 'Paid Mode', + description: 'Subscription-based access control', + color: '#52c41a' + }, + exclusive: { + label: 'Exclusive Mode', + description: 'Invite-only access with manual NPUB management', + color: '#722ed1' + } +}; + +export const ModeSelector: React.FC = ({ + currentMode, + onModeChange, + disabled = false +}) => { + return ( + + + {(Object.keys(MODE_INFO) as AllowedUsersMode[]).map((mode) => { + const info = MODE_INFO[mode]; + const isActive = currentMode === mode; + + return ( + + onModeChange(mode)} + disabled={disabled} + $isActive={isActive} + $color={info.color} + > + {info.label} + + + ); + })} + + + + + {MODE_INFO[currentMode].label}: {MODE_INFO[currentMode].description} + + + + ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts b/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts new file mode 100644 index 0000000..e466b00 --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts @@ -0,0 +1,65 @@ +import styled from 'styled-components'; +import { media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const TabContent = styled.div` + padding: 1rem 0; +`; + +export const TabHeader = styled.div` + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + + ${media.md} { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +`; + +export const NpubText = styled.code` + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + background: var(--background-color-secondary); + padding: 2px 6px; + border-radius: 4px; + color: var(--text-main-color); +`; + +export const TierTag = styled.span` + background: var(--primary-color); + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +`; + +export const BulkImportContainer = styled.div` + p { + margin-bottom: 0.5rem; + color: var(--text-main-color); + } + + ul { + margin-bottom: 1rem; + padding-left: 1.5rem; + + li { + margin-bottom: 0.25rem; + color: var(--text-secondary-color); + + code { + background: var(--background-color-secondary); + padding: 1px 4px; + border-radius: 3px; + font-size: 12px; + } + } + } +`; \ No newline at end of file diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx new file mode 100644 index 0000000..98ed2e6 --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -0,0 +1,295 @@ +import React, { useState } from 'react'; +import { Tabs, Card, Button, Input, Table, Space, Modal, Form, Select, Upload, message, Popconfirm } from 'antd'; +import { PlusOutlined, UploadOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; +import { useAllowedUsersNpubs, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; +import { AllowedUsersSettings, AllowedUsersMode } from '@app/types/allowedUsers.types'; +import * as S from './NPubManagement.styles'; + +interface NPubManagementProps { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; +} + +interface AddNpubFormData { + npub: string; + tier: string; +} + +export const NPubManagement: React.FC = ({ + settings, + mode +}) => { + const [activeTab, setActiveTab] = useState('read'); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [isBulkModalVisible, setIsBulkModalVisible] = useState(false); + const [bulkText, setBulkText] = useState(''); + const [addForm] = Form.useForm(); + + const readNpubs = useAllowedUsersNpubs('read'); + const writeNpubs = useAllowedUsersNpubs('write'); + const { validateNpub } = useAllowedUsersValidation(); + + const currentNpubs = activeTab === 'read' ? readNpubs : writeNpubs; + const tierOptions = settings.tiers.map(tier => ({ + label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, + value: tier.data_limit + })); + + const handleAddNpub = async () => { + try { + const values = await addForm.validateFields(); + await currentNpubs.addNpub(values.npub, values.tier); + setIsAddModalVisible(false); + addForm.resetFields(); + } catch (error) { + // Form validation failed or API error + } + }; + + const handleBulkImport = async () => { + if (!bulkText.trim()) { + message.error('Please enter NPUBs to import'); + return; + } + + const lines = bulkText.split('\n').filter(line => line.trim()); + const npubsData: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine.includes(':')) { + // Format: npub:tier + npubsData.push(trimmedLine); + } else { + // Just npub, use first tier as default + const defaultTier = settings.tiers[0]?.data_limit || 'basic'; + npubsData.push(`${trimmedLine}:${defaultTier}`); + } + } + + await currentNpubs.bulkImport(npubsData); + setIsBulkModalVisible(false); + setBulkText(''); + }; + + const handleExport = () => { + const data = currentNpubs.npubs.map(npub => `${npub.npub}:${npub.tier}`).join('\n'); + const blob = new Blob([data], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${activeTab}-npubs.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const columns = [ + { + title: 'NPUB', + dataIndex: 'npub', + key: 'npub', + render: (npub: string) => ( + {npub.slice(0, 16)}...{npub.slice(-8)} + ) + }, + { + title: 'Tier', + dataIndex: 'tier', + key: 'tier', + render: (tier: string) => {tier} + }, + { + title: 'Added', + dataIndex: 'added_at', + key: 'added_at', + render: (date: string) => new Date(date).toLocaleDateString() + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: any) => ( + currentNpubs.removeNpub(record.npub)} + > + + + + + + + + + ) + }, + { + key: 'write', + label: `Write Access (${writeNpubs.total})`, + children: ( + + + + + + + + + +
+ + ) + } + ]; + + return ( + + + + {/* Add NPUB Modal */} + { + setIsAddModalVisible(false); + addForm.resetFields(); + }} + destroyOnClose + > +
+ { + const error = validateNpub(value); + return error ? Promise.reject(error) : Promise.resolve(); + }} + ]} + > + + + + + + {modeConfig.readOptions.map(option => ( + + {option.label} + + ))} + + )} + + + + +
+ + Write: + + + {settings.write_access.enabled && ( + + )} + + + + + + + + Read Access: Controls who can read events from your relay + + + Write Access: Controls who can publish events to your relay + + {mode === 'paid' && ( + + Paid Mode: Write access is automatically limited to paid users + + )} + {mode === 'exclusive' && ( + + Exclusive Mode: Write access is automatically limited to allowed users + + )} + + + ); +}; diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts b/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts new file mode 100644 index 0000000..a278f93 --- /dev/null +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts @@ -0,0 +1,40 @@ +import styled from 'styled-components'; +import { media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const TiersHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + ${media.md} { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +`; + +export const TiersTitle = styled.h3` + margin: 0; + color: var(--text-main-color); + font-size: 16px; + font-weight: 600; +`; + +export const DataLimit = styled.span` + font-weight: 500; + color: var(--text-main-color); +`; + +interface PriceProps { + $isFree: boolean; +} + +export const Price = styled.span` + font-weight: 600; + color: ${({ $isFree }) => $isFree ? 'var(--success-color)' : 'var(--primary-color)'}; +`; \ No newline at end of file diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx new file mode 100644 index 0000000..8c6f70e --- /dev/null +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -0,0 +1,291 @@ +import React, { useState } from 'react'; +import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; +import * as S from './TiersConfig.styles'; + +interface TiersConfigProps { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; + onSettingsChange: (settings: AllowedUsersSettings) => void; + disabled?: boolean; +} + +interface TierFormData { + data_limit: string; + price: string; +} + +export const TiersConfig: React.FC = ({ + settings, + mode, + onSettingsChange, + disabled = false +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [form] = Form.useForm(); + + const isPaidMode = mode === 'paid'; + const isFreeMode = mode === 'free'; + + const handleAddTier = () => { + setEditingIndex(null); + form.resetFields(); + setIsModalVisible(true); + }; + + const handleEditTier = (index: number) => { + setEditingIndex(index); + const tier = settings.tiers[index]; + form.setFieldsValue({ + data_limit: tier.data_limit, + price: tier.price + }); + setIsModalVisible(true); + }; + + const handleDeleteTier = (index: number) => { + const newTiers = settings.tiers.filter((_, i) => i !== index); + const updatedSettings = { + ...settings, + tiers: newTiers + }; + onSettingsChange(updatedSettings); + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + + // Validate price for paid mode + if (isPaidMode && values.price === '0') { + form.setFields([{ + name: 'price', + errors: ['Paid mode cannot have free tiers'] + }]); + return; + } + + // Force price to "0" only for free mode, ensure it's always a string + const tierPrice = isFreeMode ? '0' : String(values.price || '0'); + + const newTier: AllowedUsersTier = { + data_limit: values.data_limit, + price: tierPrice + }; + + let newTiers: AllowedUsersTier[]; + if (editingIndex !== null) { + newTiers = [...settings.tiers]; + newTiers[editingIndex] = newTier; + } else { + newTiers = [...settings.tiers, newTier]; + } + + const updatedSettings = { + ...settings, + tiers: newTiers + }; + + onSettingsChange(updatedSettings); + setIsModalVisible(false); + form.resetFields(); + } catch (error) { + // Form validation failed + } + }; + + const handleModalCancel = () => { + setIsModalVisible(false); + form.resetFields(); + setEditingIndex(null); + }; + + const columns = [ + { + title: 'Data Limit', + dataIndex: 'data_limit', + key: 'data_limit', + render: (text: string) => {text} + }, + { + title: 'Price (sats)', + dataIndex: 'price', + key: 'price', + render: (price: string) => ( + + {price === '0' ? 'Free' : `${price} sats`} + + ) + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, __: any, index: number) => ( + + + + +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + + + + + + + + { + if (isPaidMode && value === '0') { + return Promise.reject('Paid mode cannot have free tiers'); + } + return Promise.resolve(); + } + } + ]} + > + + + + {isPaidMode && ( + + )} + + {isFreeMode && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/index.ts b/src/components/allowed-users/index.ts new file mode 100644 index 0000000..da437b8 --- /dev/null +++ b/src/components/allowed-users/index.ts @@ -0,0 +1,6 @@ +export { AllowedUsersLayout } from './layouts/AllowedUsersLayout'; +export { ModeSelector } from './components/ModeSelector/ModeSelector'; +export { PermissionsConfig } from './components/PermissionsConfig/PermissionsConfig'; +export { TiersConfig } from './components/TiersConfig/TiersConfig'; +export { NPubManagement } from './components/NPubManagement/NPubManagement'; +export { MigrationHelper } from './components/MigrationHelper/MigrationHelper'; \ No newline at end of file diff --git a/src/components/allowed-users/layouts/AllowedUsersLayout.styles.ts b/src/components/allowed-users/layouts/AllowedUsersLayout.styles.ts new file mode 100644 index 0000000..42ed95f --- /dev/null +++ b/src/components/allowed-users/layouts/AllowedUsersLayout.styles.ts @@ -0,0 +1,67 @@ +import styled from 'styled-components'; +import { FONT_SIZE, FONT_WEIGHT, media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + padding: 1.5rem; + max-width: 1200px; + margin: 0 auto; + + ${media.md} { + padding: 1rem; + } +`; + +export const Header = styled.div` + margin-bottom: 2rem; + text-align: center; + + ${media.md} { + margin-bottom: 1.5rem; + } +`; + +export const Title = styled.h1` + font-size: ${FONT_SIZE.xxl}; + font-weight: ${FONT_WEIGHT.semibold}; + margin-bottom: 0.5rem; + color: var(--text-main-color); + + ${media.md} { + font-size: ${FONT_SIZE.xl}; + } +`; + +export const Subtitle = styled.p` + font-size: ${FONT_SIZE.md}; + color: var(--text-secondary-color); + margin: 0; +`; + +export const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +`; + +export const ErrorContainer = styled.div` + padding: 2rem; + max-width: 600px; + margin: 0 auto; +`; + +export const SaveSection = styled.div` + padding: 1.5rem; + background: var(--background-color-secondary); + border-radius: 8px; + border: 1px solid var(--border-color-base); + display: flex; + justify-content: center; + align-items: center; +`; + +export const ChangesIndicator = styled.span` + color: var(--warning-color); + font-size: 14px; + font-style: italic; +`; \ No newline at end of file diff --git a/src/components/allowed-users/layouts/AllowedUsersLayout.tsx b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx new file mode 100644 index 0000000..215246e --- /dev/null +++ b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { Card, Row, Col, Spin, Alert, Button, Space } from 'antd'; +import { SaveOutlined } from '@ant-design/icons'; +import { useAllowedUsersSettings } from '@app/hooks/useAllowedUsers'; +import { ModeSelector } from '../components/ModeSelector/ModeSelector'; +import { PermissionsConfig } from '../components/PermissionsConfig/PermissionsConfig'; +import { TiersConfig } from '../components/TiersConfig/TiersConfig'; +import { NPubManagement } from '../components/NPubManagement/NPubManagement'; +import { AllowedUsersMode, MODE_CONFIGURATIONS, AllowedUsersSettings, DEFAULT_TIERS } from '@app/types/allowedUsers.types'; +import * as S from './AllowedUsersLayout.styles'; + +export const AllowedUsersLayout: React.FC = () => { + const { settings, loading, error, updateSettings } = useAllowedUsersSettings(); + const [currentMode, setCurrentMode] = useState('free'); + const [localSettings, setLocalSettings] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + const [saving, setSaving] = useState(false); + + React.useEffect(() => { + if (settings) { + setCurrentMode(settings.mode); + setLocalSettings(settings); + setHasChanges(false); + } + }, [settings]); + + const handleModeChange = (mode: AllowedUsersMode) => { + if (!localSettings) return; + + const modeConfig = MODE_CONFIGURATIONS[mode]; + + // Use mode-specific default tiers or existing tiers if they're compatible + let tiers = localSettings.tiers; + + // Check if current tiers are compatible with the new mode + const isCompatibleTiers = (currentTiers: typeof tiers, targetMode: AllowedUsersMode): boolean => { + if (currentTiers.length === 0) return false; + + return currentTiers.every(tier => { + const hasValidDataLimit = tier.data_limit && tier.data_limit.trim() !== ''; + + if (targetMode === 'paid') { + // Paid mode requires at least one tier with non-zero price + return hasValidDataLimit && tier.price && tier.price !== '0'; + } else if (targetMode === 'free') { + // Free mode should have price "0" + return hasValidDataLimit && tier.price === '0'; + } else if (targetMode === 'exclusive') { + // Exclusive mode can have any price + return hasValidDataLimit; + } + + return hasValidDataLimit; + }); + }; + + // Each mode should use its own defaults when switching modes + // Only preserve existing tiers if we're already in the target mode (backend data) + const currentMode = localSettings.mode; + + if (currentMode === mode) { + // We're already in this mode (from backend), keep existing tiers if compatible + if (isCompatibleTiers(localSettings.tiers, mode)) { + tiers = localSettings.tiers; + if (mode === 'free') { + // Ensure all prices are "0" for free mode + tiers = tiers.map(tier => ({ + ...tier, + price: '0' + })); + } + } else { + // Backend data isn't compatible with mode, use defaults + tiers = DEFAULT_TIERS[mode]; + } + } else { + // Switching between different modes, always use mode-specific defaults + tiers = DEFAULT_TIERS[mode]; + } + + const updatedSettings = { + ...localSettings, + mode, + tiers, + // Adjust scopes based on mode constraints + read_access: { + ...localSettings.read_access, + scope: modeConfig.readOptions[0].value // Default to first available option + }, + write_access: { + ...localSettings.write_access, + scope: modeConfig.writeOptions[0].value // Default to first available option + } + }; + + setLocalSettings(updatedSettings); + setCurrentMode(mode); + setHasChanges(true); + }; + + const handleSettingsUpdate = (newSettings: AllowedUsersSettings) => { + setLocalSettings(newSettings); + setHasChanges(true); + }; + + const handleSave = async () => { + if (!localSettings) return; + + setSaving(true); + try { + await updateSettings(localSettings); + setHasChanges(false); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (settings) { + setLocalSettings(settings); + setCurrentMode(settings.mode); + setHasChanges(false); + } + }; + + if (loading && !settings) { + return ( + + + + ); + } + + if (error && !settings) { + return ( + + + + ); + } + + if (!settings || !localSettings) { + return null; + } + + const modeConfig = MODE_CONFIGURATIONS[currentMode]; + const showNpubManagement = modeConfig.requiresNpubManagement || + (localSettings.read_access.scope === 'allowed_users' || localSettings.write_access.scope === 'allowed_users'); + const showTiers = currentMode === 'paid' || currentMode === 'free' || currentMode === 'exclusive'; + + return ( + + + H.O.R.N.E.T Allowed Users + Centralized user permission management + + + + + + + + + + + + + + + + {showTiers && ( + + + + + + )} + + {showNpubManagement && ( + + + + + + )} + + + + + + + {hasChanges && ( + + You have unsaved changes + + )} + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/layouts/main/sider/sidebarNavigation.tsx b/src/components/layouts/main/sider/sidebarNavigation.tsx index a617916..76c897d 100644 --- a/src/components/layouts/main/sider/sidebarNavigation.tsx +++ b/src/components/layouts/main/sider/sidebarNavigation.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { DashboardOutlined, TableOutlined, StopOutlined, FlagOutlined, SettingOutlined } from '@ant-design/icons'; +import { DashboardOutlined, TableOutlined, StopOutlined, FlagOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'; import { ReactComponent as NestIcon } from '@app/assets/icons/hive.svg'; import { ReactComponent as BtcIcon } from '@app/assets/icons/btc.svg'; import { ReactComponent as StatsIcon } from '@app/assets/icons/stats.svg'; @@ -37,6 +37,12 @@ export const useSidebarNavigation = (): SidebarNavigationItem[] => { url: '/settings', icon: , }, + { + title: 'Allowed Users', + key: 'allowed-users', + url: '/allowed-users', + icon: , + }, { title: 'common.access-control', key: 'blocked-pubkeys', diff --git a/src/components/router/AppRouter.tsx b/src/components/router/AppRouter.tsx index b9e4d61..64afd01 100644 --- a/src/components/router/AppRouter.tsx +++ b/src/components/router/AppRouter.tsx @@ -39,6 +39,7 @@ const NotificationsPage = React.lazy(() => import('@app/pages/NotificationsPage' const PaymentNotificationsPage = React.lazy(() => import('@app/pages/PaymentNotificationsPage')); const ReportNotificationsPage = React.lazy(() => import('@app/pages/ReportNotificationsPage')); const PaymentsPage = React.lazy(() => import('@app/pages/PaymentsPage')); +const AllowedUsersPage = React.lazy(() => import('@app/pages/AllowedUsersPage')); const ButtonsPage = React.lazy(() => import('@app/pages/uiComponentsPages/ButtonsPage')); const SpinnersPage = React.lazy(() => import('@app/pages/uiComponentsPages/SpinnersPage')); const AvatarsPage = React.lazy(() => import('@app/pages/uiComponentsPages/dataDisplay/AvatarsPage')); @@ -135,6 +136,7 @@ const Notifications = withLoading(NotificationsPage); const PaymentNotifications = withLoading(PaymentNotificationsPage); const ReportNotifications = withLoading(ReportNotificationsPage); const Payments = withLoading(PaymentsPage); +const AllowedUsers = withLoading(AllowedUsersPage); const AuthLayoutFallback = withLoading(AuthLayout); const LogoutFallback = withLoading(Logout); @@ -159,6 +161,7 @@ export const AppRouter: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/hooks/useAllowedUsers.ts b/src/hooks/useAllowedUsers.ts new file mode 100644 index 0000000..d1bbc17 --- /dev/null +++ b/src/hooks/useAllowedUsers.ts @@ -0,0 +1,276 @@ +import { useState, useEffect, useCallback } from 'react'; +import { message } from 'antd'; +import { + getAllowedUsersSettings, + updateAllowedUsersSettings, + getReadNpubs, + getWriteNpubs, + addReadNpub, + addWriteNpub, + removeReadNpub, + removeWriteNpub, + bulkImportNpubs +} from '@app/api/allowedUsers.api'; +import { + AllowedUsersSettings, + AllowedUsersNpub, + AllowedUsersMode, + BulkImportRequest +} from '@app/types/allowedUsers.types'; + +export const useAllowedUsersSettings = () => { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchSettings = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getAllowedUsersSettings(); + setSettings(data); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch settings'; + setError(errorMessage); + + // Don't show error message if it's just that the endpoint doesn't exist yet + if (!errorMessage.includes('404') && !errorMessage.includes('not valid JSON')) { + message.error(errorMessage); + } + + // Set default settings if API is not available yet + setSettings({ + mode: 'free', + read_access: { + enabled: true, + scope: 'all_users' + }, + write_access: { + enabled: true, + scope: 'all_users' + }, + tiers: [ + { data_limit: '1 GB per month', price: '0' } + ] + }); + } finally { + setLoading(false); + } + }, []); + + const updateSettings = useCallback(async (newSettings: AllowedUsersSettings) => { + setLoading(true); + setError(null); + try { + const result = await updateAllowedUsersSettings(newSettings); + if (result.success) { + setSettings(newSettings); + message.success('Settings updated successfully'); + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update settings'; + setError(errorMessage); + if (errorMessage.includes('access control not initialized')) { + message.error('Please restart the relay after configuration changes'); + } else { + message.error(errorMessage); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + return { + settings, + loading, + error, + updateSettings, + refetch: fetchSettings + }; +}; + +export const useAllowedUsersNpubs = (type: 'read' | 'write') => { + const [npubs, setNpubs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + + const fetchNpubs = useCallback(async (pageNum: number = page) => { + setLoading(true); + setError(null); + try { + const data = type === 'read' + ? await getReadNpubs(pageNum, pageSize) + : await getWriteNpubs(pageNum, pageSize); + + setNpubs(data.npubs); + setTotal(data.total); + setPage(data.page); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to fetch ${type} NPUBs`; + setError(errorMessage); + + // Don't show error message if it's just that the endpoint doesn't exist yet + if (!errorMessage.includes('404') && !errorMessage.includes('not valid JSON')) { + message.error(errorMessage); + } + + // Set empty data if API is not available yet + setNpubs([]); + setTotal(0); + setPage(1); + } finally { + setLoading(false); + } + }, [type, page, pageSize]); + + const addNpub = useCallback(async (npub: string, tier: string) => { + setLoading(true); + try { + const result = type === 'read' + ? await addReadNpub(npub, tier) + : await addWriteNpub(npub, tier); + + if (result.success) { + message.success(`NPUB added to ${type} list successfully`); + await fetchNpubs(1); // Refresh the list + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to add NPUB to ${type} list`; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, fetchNpubs]); + + const removeNpub = useCallback(async (npub: string) => { + setLoading(true); + try { + const result = type === 'read' + ? await removeReadNpub(npub) + : await removeWriteNpub(npub); + + if (result.success) { + message.success(`NPUB removed from ${type} list successfully`); + await fetchNpubs(page); // Refresh current page + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to remove NPUB from ${type} list`; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, page, fetchNpubs]); + + const bulkImport = useCallback(async (npubsData: string[]) => { + setLoading(true); + try { + const importData: BulkImportRequest = { + type, + npubs: npubsData + }; + + const result = await bulkImportNpubs(importData); + if (result.success) { + message.success(`Bulk import completed: ${result.imported} imported, ${result.failed} failed`); + await fetchNpubs(1); // Refresh the list + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Bulk import failed'; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, fetchNpubs]); + + const changePage = useCallback((newPage: number) => { + setPage(newPage); + fetchNpubs(newPage); + }, [fetchNpubs]); + + useEffect(() => { + fetchNpubs(); + }, [fetchNpubs]); + + return { + npubs, + total, + loading, + error, + page, + pageSize, + addNpub, + removeNpub, + bulkImport, + changePage, + refetch: fetchNpubs + }; +}; + +// Validation hook +export const useAllowedUsersValidation = () => { + const validateSettings = useCallback((settings: AllowedUsersSettings): string[] => { + const errors: string[] = []; + + // Mode validation + if (!['free', 'paid', 'exclusive'].includes(settings.mode)) { + errors.push('Invalid mode selected'); + } + + // Tier validation + if (settings.mode === 'paid' && settings.tiers.some(t => t.price === '0')) { + errors.push('Paid mode cannot have free tiers'); + } + + // Scope validation + if (settings.mode === 'paid' && settings.write_access.scope !== 'paid_users') { + errors.push('Paid mode write access must be limited to paid users'); + } + + if (settings.mode === 'exclusive' && settings.write_access.scope !== 'allowed_users') { + errors.push('Exclusive mode write access must be limited to allowed users'); + } + + // Tiers validation + if (settings.tiers.length === 0) { + errors.push('At least one tier must be configured'); + } + + return errors; + }, []); + + const validateNpub = useCallback((npub: string): string | null => { + if (!npub.trim()) { + return 'NPUB cannot be empty'; + } + + if (!npub.startsWith('npub1')) { + return 'NPUB must start with "npub1"'; + } + + if (npub.length !== 63) { + return 'NPUB must be 63 characters long'; + } + + return null; + }, []); + + return { + validateSettings, + validateNpub + }; +}; \ No newline at end of file diff --git a/src/pages/AllowedUsersPage.tsx b/src/pages/AllowedUsersPage.tsx new file mode 100644 index 0000000..b44366f --- /dev/null +++ b/src/pages/AllowedUsersPage.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { AllowedUsersLayout } from '@app/components/allowed-users'; + +const AllowedUsersPage: React.FC = () => { + return ; +}; + +export default AllowedUsersPage; \ No newline at end of file diff --git a/src/store/slices/allowedUsersSlice.ts b/src/store/slices/allowedUsersSlice.ts new file mode 100644 index 0000000..1c587a3 --- /dev/null +++ b/src/store/slices/allowedUsersSlice.ts @@ -0,0 +1,74 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { AllowedUsersSettings, AllowedUsersNpub } from '@app/types/allowedUsers.types'; + +interface AllowedUsersState { + settings: AllowedUsersSettings | null; + readNpubs: AllowedUsersNpub[]; + writeNpubs: AllowedUsersNpub[]; + loading: boolean; + error: string | null; +} + +const initialState: AllowedUsersState = { + settings: null, + readNpubs: [], + writeNpubs: [], + loading: false, + error: null, +}; + +const allowedUsersSlice = createSlice({ + name: 'allowedUsers', + initialState, + reducers: { + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + setSettings: (state, action: PayloadAction) => { + state.settings = action.payload; + }, + setReadNpubs: (state, action: PayloadAction) => { + state.readNpubs = action.payload; + }, + setWriteNpubs: (state, action: PayloadAction) => { + state.writeNpubs = action.payload; + }, + addReadNpub: (state, action: PayloadAction) => { + state.readNpubs.push(action.payload); + }, + addWriteNpub: (state, action: PayloadAction) => { + state.writeNpubs.push(action.payload); + }, + removeReadNpub: (state, action: PayloadAction) => { + state.readNpubs = state.readNpubs.filter(npub => npub.npub !== action.payload); + }, + removeWriteNpub: (state, action: PayloadAction) => { + state.writeNpubs = state.writeNpubs.filter(npub => npub.npub !== action.payload); + }, + clearState: (state) => { + state.settings = null; + state.readNpubs = []; + state.writeNpubs = []; + state.loading = false; + state.error = null; + }, + }, +}); + +export const { + setLoading, + setError, + setSettings, + setReadNpubs, + setWriteNpubs, + addReadNpub, + addWriteNpub, + removeReadNpub, + removeWriteNpub, + clearState, +} = allowedUsersSlice.actions; + +export default allowedUsersSlice.reducer; \ No newline at end of file diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts index b5a73b9..d7f35d1 100644 --- a/src/store/slices/index.ts +++ b/src/store/slices/index.ts @@ -5,6 +5,7 @@ import nightModeReducer from '@app/store/slices/nightModeSlice'; import themeReducer from '@app/store/slices/themeSlice'; import pwaReducer from '@app/store/slices/pwaSlice'; import serverModeReducer from '@app/store/slices/modeSlice'; +import allowedUsersReducer from '@app/store/slices/allowedUsersSlice'; // Combine all slice reducers into a single root reducer const rootReducer = combineReducers({ @@ -14,6 +15,7 @@ const rootReducer = combineReducers({ theme: themeReducer, pwa: pwaReducer, mode: serverModeReducer, // Make sure this name matches what you use in your selectors + allowedUsers: allowedUsersReducer, }); export default rootReducer; diff --git a/src/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts new file mode 100644 index 0000000..8a46718 --- /dev/null +++ b/src/types/allowedUsers.types.ts @@ -0,0 +1,112 @@ +export type AllowedUsersMode = 'free' | 'paid' | 'exclusive'; + +export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; + +export interface AllowedUsersTier { + data_limit: string; + price: string; +} + +// Backend expects this format +export interface AllowedUsersTierBackend { + datalimit: string; + price: string; +} + +export interface AllowedUsersAccessConfig { + enabled: boolean; + scope: AccessScope; +} + +export interface AllowedUsersSettings { + mode: AllowedUsersMode; + read_access: AllowedUsersAccessConfig; + write_access: AllowedUsersAccessConfig; + tiers: AllowedUsersTier[]; +} + +export interface AllowedUsersNpub { + npub: string; + tier: string; + added_at: string; +} + +export interface AllowedUsersNpubsResponse { + npubs: AllowedUsersNpub[]; + total: number; + page: number; + pageSize: number; +} + +export interface BulkImportRequest { + type: 'read' | 'write'; + npubs: string[]; // Format: "npub1...:tier" +} + +export interface AllowedUsersApiResponse { + allowed_users: AllowedUsersSettings; +} + +// Mode-specific option configurations +export interface ModeOptions { + readOptions: { value: AccessScope; label: string }[]; + writeOptions: { value: AccessScope; label: string }[]; + allowsFreeTiers: boolean; + requiresNpubManagement: boolean; +} + +export const MODE_CONFIGURATIONS: Record = { + free: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + writeOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: false + }, + paid: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'paid_users', label: 'Paid Users' } + ], + writeOptions: [ + { value: 'paid_users', label: 'Paid Users' } + ], + allowsFreeTiers: false, + requiresNpubManagement: false + }, + exclusive: { + readOptions: [ + { value: 'allowed_users', label: 'Allowed Users' }, + { value: 'all_users', label: 'All Users' } + ], + writeOptions: [ + { value: 'allowed_users', label: 'Allowed Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true + } +}; + +// Default tier configurations for each mode +export const DEFAULT_TIERS: Record = { + free: [ + { data_limit: '100 MB per month', price: '0' }, + { data_limit: '500 MB per month', price: '0' }, + { data_limit: '1 GB per month', price: '0' } + ], + paid: [ + { data_limit: '1 GB per month', price: '1000' }, + { data_limit: '5 GB per month', price: '5000' }, + { data_limit: '10 GB per month', price: '10000' } + ], + exclusive: [ + { data_limit: '5 GB per month', price: '0' }, + { data_limit: '50 GB per month', price: '0' }, + { data_limit: 'unlimited', price: '0' } + ] +}; \ No newline at end of file From ad7f481c8dfc3970d481e0f6625d5b25bdaf5bd0 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:17:21 +0200 Subject: [PATCH 02/17] Update alert styling to use consistent dark theme colors - Apply dark background (#25284B) with light text (#d9d9d9) for better readability - Maintain visual consistency across all alert components - Improve contrast and accessibility for notification messages --- .../PermissionsConfig/PermissionsConfig.tsx | 7 ++++- .../components/TiersConfig/TiersConfig.tsx | 29 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx index 35b3dea..02315b7 100644 --- a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx +++ b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx @@ -72,7 +72,12 @@ export const PermissionsConfig: React.FC = ({ description="Your relay is configured to allow read access to all users. This means anyone can read events from your relay." type="warning" showIcon - style={{ marginBottom: '1rem' }} + style={{ + marginBottom: '1rem', + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' + }} /> )} diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index 8c6f70e..baef2b4 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -157,9 +157,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #ffd666 !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -172,9 +172,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #52c41a !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -187,9 +187,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #1890ff !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -269,6 +269,11 @@ export const TiersConfig: React.FC = ({ message="Note: Free tiers (price = 0) are not allowed in paid mode" type="warning" showIcon + style={{ + backgroundColor: '#fafafa', + border: '1px solid #d9d9d9', + color: '#262626' + }} /> )} @@ -278,9 +283,9 @@ export const TiersConfig: React.FC = ({ type="success" showIcon style={{ - backgroundColor: '#ffffff !important', - border: '1px solid #52c41a !important', - color: '#1f1f1f !important' + backgroundColor: '#fafafa', + border: '1px solid #d9d9d9', + color: '#262626' }} /> )} From 32ed6c8aef111661946565f0c61b9771f5eb5b31 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:50:32 +0200 Subject: [PATCH 03/17] Redesign user management with unified interface and toggle controls - Replace separate read/write tabs with single unified table - Add read/write access toggle switches for instant permission management - Enhance add user modal with permission selection toggles - Improve bulk import to support permission flags (r/w/rw) - Update export format to include permission information - Streamline user experience - manage all permissions from one view --- .../NPubManagement/NPubManagement.tsx | 353 +++++++++++------- 1 file changed, 223 insertions(+), 130 deletions(-) diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx index 98ed2e6..9821d4a 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Tabs, Card, Button, Input, Table, Space, Modal, Form, Select, Upload, message, Popconfirm } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { Button, Input, Table, Space, Modal, Form, Select, message, Popconfirm, Switch } from 'antd'; import { PlusOutlined, UploadOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; import { useAllowedUsersNpubs, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; import { AllowedUsersSettings, AllowedUsersMode } from '@app/types/allowedUsers.types'; @@ -13,23 +13,65 @@ interface NPubManagementProps { interface AddNpubFormData { npub: string; tier: string; + readAccess: boolean; + writeAccess: boolean; +} + +interface UnifiedUser { + npub: string; + tier: string; + readAccess: boolean; + writeAccess: boolean; + added_at: string; } export const NPubManagement: React.FC = ({ settings, mode }) => { - const [activeTab, setActiveTab] = useState('read'); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isBulkModalVisible, setIsBulkModalVisible] = useState(false); const [bulkText, setBulkText] = useState(''); + const [unifiedUsers, setUnifiedUsers] = useState([]); const [addForm] = Form.useForm(); const readNpubs = useAllowedUsersNpubs('read'); const writeNpubs = useAllowedUsersNpubs('write'); const { validateNpub } = useAllowedUsersValidation(); - const currentNpubs = activeTab === 'read' ? readNpubs : writeNpubs; + // Merge read and write NPUBs into unified list + useEffect(() => { + const allNpubs = new Map(); + + // Add read NPUBs + readNpubs.npubs.forEach(npub => { + allNpubs.set(npub.npub, { + npub: npub.npub, + tier: npub.tier, + readAccess: true, + writeAccess: false, + added_at: npub.added_at + }); + }); + + // Add write NPUBs (merge with existing or create new) + writeNpubs.npubs.forEach(npub => { + const existing = allNpubs.get(npub.npub); + if (existing) { + existing.writeAccess = true; + } else { + allNpubs.set(npub.npub, { + npub: npub.npub, + tier: npub.tier, + readAccess: false, + writeAccess: true, + added_at: npub.added_at + }); + } + }); + + setUnifiedUsers(Array.from(allNpubs.values())); + }, [readNpubs.npubs, writeNpubs.npubs]); const tierOptions = settings.tiers.map(tier => ({ label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, value: tier.data_limit @@ -38,7 +80,17 @@ export const NPubManagement: React.FC = ({ const handleAddNpub = async () => { try { const values = await addForm.validateFields(); - await currentNpubs.addNpub(values.npub, values.tier); + + // Add to read list if read access is enabled + if (values.readAccess) { + await readNpubs.addNpub(values.npub, values.tier); + } + + // Add to write list if write access is enabled + if (values.writeAccess) { + await writeNpubs.addNpub(values.npub, values.tier); + } + setIsAddModalVisible(false); addForm.resetFields(); } catch (error) { @@ -46,6 +98,41 @@ export const NPubManagement: React.FC = ({ } }; + const handleToggleAccess = async (npub: string, type: 'read' | 'write', enabled: boolean) => { + const user = unifiedUsers.find(u => u.npub === npub); + if (!user) return; + + try { + if (type === 'read') { + if (enabled) { + await readNpubs.addNpub(npub, user.tier); + } else { + await readNpubs.removeNpub(npub); + } + } else { + if (enabled) { + await writeNpubs.addNpub(npub, user.tier); + } else { + await writeNpubs.removeNpub(npub); + } + } + } catch (error) { + message.error(`Failed to update ${type} access`); + } + }; + + const handleRemoveUser = async (npub: string) => { + try { + // Remove from both lists + await Promise.all([ + readNpubs.removeNpub(npub).catch(() => {}), // Ignore errors if not in list + writeNpubs.removeNpub(npub).catch(() => {}) // Ignore errors if not in list + ]); + } catch (error) { + message.error('Failed to remove user'); + } + }; + const handleBulkImport = async () => { if (!bulkText.trim()) { message.error('Please enter NPUBs to import'); @@ -53,32 +140,60 @@ export const NPubManagement: React.FC = ({ } const lines = bulkText.split('\n').filter(line => line.trim()); - const npubsData: string[] = []; + const defaultTier = settings.tiers[0]?.data_limit || 'basic'; - for (const line of lines) { - const trimmedLine = line.trim(); - if (trimmedLine.includes(':')) { - // Format: npub:tier - npubsData.push(trimmedLine); - } else { - // Just npub, use first tier as default - const defaultTier = settings.tiers[0]?.data_limit || 'basic'; - npubsData.push(`${trimmedLine}:${defaultTier}`); + try { + for (const line of lines) { + const trimmedLine = line.trim(); + const parts = trimmedLine.split(':'); + + const npub = parts[0]; + const tier = parts[1] || defaultTier; + const permissions = parts[2] || 'r'; // default to read only + + const hasReadAccess = permissions.includes('r'); + const hasWriteAccess = permissions.includes('w'); + + // Add to read list if read access + if (hasReadAccess) { + try { + await readNpubs.addNpub(npub, tier); + } catch (error) { + // Might already exist, continue + } + } + + // Add to write list if write access + if (hasWriteAccess) { + try { + await writeNpubs.addNpub(npub, tier); + } catch (error) { + // Might already exist, continue + } + } } + + message.success('Bulk import completed'); + setIsBulkModalVisible(false); + setBulkText(''); + } catch (error) { + message.error('Bulk import failed'); } - - await currentNpubs.bulkImport(npubsData); - setIsBulkModalVisible(false); - setBulkText(''); }; const handleExport = () => { - const data = currentNpubs.npubs.map(npub => `${npub.npub}:${npub.tier}`).join('\n'); + const data = unifiedUsers.map(user => { + let permissions = ''; + if (user.readAccess) permissions += 'r'; + if (user.writeAccess) permissions += 'w'; + return `${user.npub}:${user.tier}:${permissions}`; + }).join('\n'); + const blob = new Blob([data], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${activeTab}-npubs.txt`; + a.download = 'allowed-users.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -100,6 +215,32 @@ export const NPubManagement: React.FC = ({ key: 'tier', render: (tier: string) => {tier} }, + { + title: 'Read Access', + key: 'readAccess', + align: 'center' as const, + render: (_: any, record: UnifiedUser) => ( + handleToggleAccess(record.npub, 'read', checked)} + loading={readNpubs.loading} + size="small" + /> + ) + }, + { + title: 'Write Access', + key: 'writeAccess', + align: 'center' as const, + render: (_: any, record: UnifiedUser) => ( + handleToggleAccess(record.npub, 'write', checked)} + loading={writeNpubs.loading} + size="small" + /> + ) + }, { title: 'Added', dataIndex: 'added_at', @@ -109,10 +250,10 @@ export const NPubManagement: React.FC = ({ { title: 'Actions', key: 'actions', - render: (_: any, record: any) => ( + render: (_: any, record: UnifiedUser) => ( currentNpubs.removeNpub(record.npub)} + title="Are you sure you want to remove this user completely?" + onConfirm={() => handleRemoveUser(record.npub)} > - - - - - -
- - ) - }, - { - key: 'write', - label: `Write Access (${writeNpubs.total})`, - children: ( - - - - - - - - - -
- - ) - } - ]; - return ( - + + + + + + + +
`Total ${total} users` + }} + rowKey="npub" /> - {/* Add NPUB Modal */} + {/* Add User Modal */} { @@ -239,7 +316,7 @@ export const NPubManagement: React.FC = ({ }} destroyOnClose > -
+ = ({ > } - /> - - - - )} - - {currentTiers.map((tier, index) => { - // Determine tier title based on index - const tierTitles = ['Standard', 'Premium', 'Professional']; - const tierTitle = tierTitles[index] || `Tier ${index + 1}`; - - return ( - - -
- {tierTitle} Tier - {index === 1 && Popular} -
- removeTier(index)}> - - Remove - -
- -
- - - - Data Limit - - - handleUpdateTierAmount(index, value)} - prefix={} - /> - handleUpdateTierUnit(index, value)} - options={[ - { value: 'MB', label: 'MB' }, - { value: 'GB', label: 'GB' } - ]} - /> - - - - - - - Price (sats) - - handleUpdateTierPrice(index, e.target.value)} - placeholder="Price in sats" - style={{ - width: '100%', - backgroundColor: '#1b1b38', - borderColor: '#313131', - color: 'white', - height: '48px', - borderRadius: '8px' - }} - prefix={} - /> - -
-
- ); - })} - - = 3} - > - - Add Tier - - - - - - Configure subscription tiers to define data limits and pricing for your relay service. - {freeTierEnabled && " A free tier can help attract new users to your service."} - - - - ); -}; - -export default SubscriptionTiersManager; diff --git a/src/components/SubscriptionTiersManager/index.ts b/src/components/SubscriptionTiersManager/index.ts deleted file mode 100644 index 66b1992..0000000 --- a/src/components/SubscriptionTiersManager/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SubscriptionTiersManager'; \ No newline at end of file diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx index af9e03c..1720347 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -125,8 +125,8 @@ export const NPubManagement: React.FC = ({ try { // Remove from both lists await Promise.all([ - readNpubs.removeNpub(npub).catch(() => {}), // Ignore errors if not in list - writeNpubs.removeNpub(npub).catch(() => {}) // Ignore errors if not in list + readNpubs.removeNpub(npub).catch(() => { /* Ignore errors if not in list */ }), + writeNpubs.removeNpub(npub).catch(() => { /* Ignore errors if not in list */ }) ]); } catch (error) { message.error('Failed to remove user'); diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index baef2b4..21c20db 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert } from 'antd'; +import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert, Radio, Card } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; import * as S from './TiersConfig.styles'; @@ -29,6 +29,20 @@ export const TiersConfig: React.FC = ({ const isPaidMode = mode === 'paid'; const isFreeMode = mode === 'free'; + const handleFreeTierChange = (dataLimit: string) => { + const updatedTiers = settings.tiers.map(tier => ({ + ...tier, + active: tier.data_limit === dataLimit + })); + + const updatedSettings = { + ...settings, + tiers: updatedTiers + }; + + onSettingsChange(updatedSettings); + }; + const handleAddTier = () => { setEditingIndex(null); form.resetFields(); @@ -195,24 +209,57 @@ export const TiersConfig: React.FC = ({ )} - Subscription Tiers - + + {isFreeMode ? 'Free Tier Selection' : 'Subscription Tiers'} + + {!isFreeMode && ( + + )} -
({ ...tier, key: index }))} - pagination={false} - size="small" - locale={{ emptyText: 'No tiers configured' }} - /> + {isFreeMode ? ( + tier.active)?.data_limit} + onChange={(e) => handleFreeTierChange(e.target.value)} + disabled={disabled} + > + + {settings.tiers.map((tier, index) => ( + !disabled && handleFreeTierChange(tier.data_limit)} + > + + + {tier.data_limit} + Free + + + + ))} + + + ) : ( +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + )} void; onAddBucket: (bucket: string) => void; onRemoveBucket: (bucket: string) => void; - // Subscription section props - subscriptionTiers: SubscriptionTier[]; - onSubscriptionChange: (newTiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean, - freeTierLimit: string, - onFreeTierChange: (enabled: boolean, limit: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -91,12 +83,6 @@ export const DesktopLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -142,13 +128,6 @@ export const DesktopLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; onAddBucket: (bucket: string) => void; onRemoveBucket: (bucket: string) => void; - // Subscription section props - subscriptionTiers: SubscriptionTier[]; - onSubscriptionChange: (newTiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean, - freeTierLimit: string, - onFreeTierChange: (enabled: boolean, limit: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -88,12 +80,6 @@ export const MobileLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -137,13 +123,6 @@ export const MobileLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; - freeTierEnabled: boolean; - freeTierLimit: string; - onFreeTierChange: (enabled: boolean, limit: string) => void; -} - -export const SubscriptionSection: React.FC = ({ - tiers, - onChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, -}) => { - return ( - - - - - - - - ); -}; - -export default SubscriptionSection; \ No newline at end of file diff --git a/src/components/relay-settings/sections/SubscriptionSection/index.ts b/src/components/relay-settings/sections/SubscriptionSection/index.ts deleted file mode 100644 index 4c5ca3d..0000000 --- a/src/components/relay-settings/sections/SubscriptionSection/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// src/components/relay-settings/sections/SubscriptionSection/index.ts - -import SubscriptionSection from './SubscriptionSection'; -export { SubscriptionSection }; \ No newline at end of file diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index ffb96fb..c642feb 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -15,17 +15,9 @@ export type Settings = { isGitNestrActive: boolean; isAudioActive: boolean; isFileStorageActive: boolean; - subscription_tiers: SubscriptionTier[]; - freeTierEnabled: boolean; // New field - freeTierLimit: string; // New field - e.g. "100 MB per month moderationMode: string; // "strict" or "passive" } -export type SubscriptionTier = { - data_limit: string; - price: string; -} - export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'appBuckets' | 'dynamicAppBuckets'; export const noteOptions = [ { kind: 0, kindString: 'kind0', description: 'Metadata', category: 1 }, @@ -114,8 +106,3 @@ export const mimeTypeOptions: FormatOption[] = [ { value: 'audio/midi', label: 'MIDI Audio' }, ]; -export const defaultTiers: SubscriptionTier[] = [ - { data_limit: '1 GB per month', price: '8000' }, - { data_limit: '5 GB per month', price: '10000' }, - { data_limit: '10 GB per month', price: '15000' } -]; diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 4aa6d01..8dfdd58 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -3,12 +3,7 @@ import { CheckboxValueType } from 'antd/es/checkbox/Group'; import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; -import { Settings, noteOptions, mimeTypeOptions, SubscriptionTier } from '@app/constants/relaySettings'; - -interface BackendSubscriptionTier { - datalimit: string; - price: string; -} +import { Settings, noteOptions, mimeTypeOptions } from '@app/constants/relaySettings'; interface BackendRelaySettings { mode: string; @@ -17,9 +12,6 @@ interface BackendRelaySettings { chunksize: string; maxFileSize: number; maxFileSizeUnit: string; - subscription_tiers: BackendSubscriptionTier[]; - freeTierEnabled: boolean; // New field - freeTierLimit: string; // New field - e.g. "100 MB per month" moderationMode: string; // "strict" or "passive" MimeTypeGroups: { images: string[]; @@ -32,12 +24,6 @@ interface BackendRelaySettings { isFileStorageActive?: boolean; } -const defaultTiers: SubscriptionTier[] = [ - { data_limit: '1 GB per month', price: '8000' }, - { data_limit: '5 GB per month', price: '10000' }, - { data_limit: '10 GB per month', price: '15000' } -]; - const getInitialSettings = (): Settings => ({ mode: 'whitelist', protocol: ['WebSocket'], @@ -55,9 +41,6 @@ const getInitialSettings = (): Settings => ({ isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - subscription_tiers: defaultTiers, - freeTierEnabled: false, - freeTierLimit: '100 MB per month', moderationMode: 'strict' // Default to strict mode }); @@ -135,12 +118,6 @@ const useRelaySettings = () => { chunksize: '2', maxFileSize: 10, maxFileSizeUnit: 'MB', - subscription_tiers: settings.subscription_tiers.map(tier => ({ - datalimit: tier.data_limit, - price: tier.price - })), - freeTierEnabled: settings.freeTierEnabled, - freeTierLimit: settings.freeTierLimit, moderationMode: settings.moderationMode, MimeTypeGroups: mimeGroups, isFileStorageActive: settings.isFileStorageActive, @@ -162,27 +139,8 @@ const useRelaySettings = () => { const settings = getInitialSettings(); settings.mode = backendSettings.mode; settings.protocol = backendSettings.protocol as string[]; - settings.freeTierEnabled = backendSettings.freeTierEnabled ?? false; - settings.freeTierLimit = backendSettings.freeTierLimit ?? '100 MB per month'; settings.moderationMode = backendSettings.moderationMode ?? 'strict'; - // Handle subscription tiers - if (Array.isArray(backendSettings.subscription_tiers)) { - settings.subscription_tiers = backendSettings.subscription_tiers.map(tier => ({ - data_limit: tier.datalimit, - price: tier.price - })); - console.log('Transformed tiers:', settings.subscription_tiers); - } else { - console.log('No backend tiers, using defaults'); - settings.subscription_tiers = defaultTiers; - } - - if (!settings.subscription_tiers.length || - settings.subscription_tiers.every(tier => !tier.data_limit)) { - settings.subscription_tiers = defaultTiers; - } - if (backendSettings.mode === 'blacklist') { // In blacklist mode, start with empty selections settings.photos = []; diff --git a/src/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index 767e29f..e4b70a8 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -10,7 +10,7 @@ import { useResponsive } from '@app/hooks/useResponsive'; import useRelaySettings from '@app/hooks/useRelaySettings'; import { DesktopLayout } from '@app/components/relay-settings/layouts/DesktopLayout'; import { MobileLayout } from '@app/components/relay-settings/layouts/MobileLayout'; -import { Settings, Category, defaultTiers, SubscriptionTier } from '@app/constants/relaySettings'; +import { Settings, Category } from '@app/constants/relaySettings'; const RelaySettingsPage: React.FC = () => { const { t } = useTranslation(); @@ -41,9 +41,6 @@ const RelaySettingsPage: React.FC = () => { isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - subscription_tiers: [], - freeTierEnabled: false, - freeTierLimit: '100 MB per month', moderationMode: 'strict' // Default to strict mode }); @@ -75,17 +72,9 @@ const RelaySettingsPage: React.FC = () => { if (relaySettings) { console.log('Raw relay settings:', relaySettings); // For debugging - // Only set defaults if there are no tiers or if they are invalid - const tiers = Array.isArray(relaySettings.subscription_tiers) && - relaySettings.subscription_tiers.length > 0 && - relaySettings.subscription_tiers.every(tier => tier.data_limit && tier.price) - ? relaySettings.subscription_tiers - : defaultTiers; - setSettings(prev => ({ ...relaySettings, - protocol: Array.isArray(relaySettings.protocol) ? relaySettings.protocol : [relaySettings.protocol], - subscription_tiers: tiers + protocol: Array.isArray(relaySettings.protocol) ? relaySettings.protocol : [relaySettings.protocol] })); setDynamicAppBuckets(relaySettings.dynamicAppBuckets); } @@ -136,9 +125,6 @@ const RelaySettingsPage: React.FC = () => { updateSettings('isFileStorageActive', settings.isFileStorageActive), updateSettings('appBuckets', settings.appBuckets), updateSettings('dynamicAppBuckets', settings.dynamicAppBuckets), - updateSettings('freeTierEnabled', settings.freeTierEnabled), - updateSettings('freeTierLimit', settings.freeTierLimit), - updateSettings('subscription_tiers', settings.subscription_tiers), updateSettings('moderationMode', settings.moderationMode), ]); @@ -257,26 +243,6 @@ const RelaySettingsPage: React.FC = () => { onDynamicAppBucketsChange: handleDynamicAppBucketsChange, onAddBucket: handleAddBucket, onRemoveBucket: handleRemoveBucket, - // Subscription props - subscriptionTiers: settings.subscription_tiers || defaultTiers, - freeTierEnabled: settings.freeTierEnabled, - freeTierLimit: settings.freeTierLimit, - onSubscriptionChange: (tiers: SubscriptionTier[]) => { - setSettings(prev => ({ - ...prev, - subscription_tiers: tiers - })); - updateSettings('subscription_tiers', tiers); - }, - onFreeTierChange: (enabled: boolean, limit: string) => { // Combined function - setSettings(prev => ({ - ...prev, - freeTierEnabled: enabled, - freeTierLimit: limit - })); - updateSettings('freeTierEnabled', enabled); - updateSettings('freeTierLimit', limit); - }, // Kinds props isKindsActive: settings.isKindsActive, selectedKinds: settings.kinds, diff --git a/src/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts index 951c160..0aeafee 100644 --- a/src/types/allowedUsers.types.ts +++ b/src/types/allowedUsers.types.ts @@ -5,6 +5,7 @@ export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; export interface AllowedUsersTier { data_limit: string; price: string; + active?: boolean; // For free mode - only one tier can be active at a time } // Backend expects this format @@ -96,9 +97,9 @@ export const MODE_CONFIGURATIONS: Record = { // Default tier configurations for each mode export const DEFAULT_TIERS: Record = { free: [ - { data_limit: '100 MB per month', price: '0' }, - { data_limit: '500 MB per month', price: '0' }, - { data_limit: '1 GB per month', price: '0' } + { data_limit: '100 MB per month', price: '0', active: false }, + { data_limit: '500 MB per month', price: '0', active: true }, // Default active tier + { data_limit: '1 GB per month', price: '0', active: false } ], paid: [ { data_limit: '1 GB per month', price: '1000' }, From d852a029652e375b7aaebc3abe9849db41b1ef95 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 12:28:05 +0200 Subject: [PATCH 06/17] Fix API endpoint in useChartData hook Update relay count endpoint from /api/relaycount to /api/relay/count and remove commented code. --- src/hooks/useChartData.ts | 88 +-------------------------------------- 1 file changed, 1 insertion(+), 87 deletions(-) diff --git a/src/hooks/useChartData.ts b/src/hooks/useChartData.ts index daf94c1..e14aa5c 100644 --- a/src/hooks/useChartData.ts +++ b/src/hooks/useChartData.ts @@ -27,7 +27,7 @@ const useChartData = () => { throw new Error('No authentication token found'); } - const response = await fetch(`${config.baseURL}/api/relaycount`, { + const response = await fetch(`${config.baseURL}/api/relay/count`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -71,89 +71,3 @@ const useChartData = () => { }; export default useChartData; - - -// import { useState, useEffect } from 'react'; -// import { useTranslation } from 'react-i18next'; -// import { useDispatch } from 'react-redux'; -// import config from '@app/config/config'; -// import { readToken, deleteToken, deleteUser } from '@app/services/localStorage.service'; -// import { setUser } from '@app/store/slices/userSlice'; -// import { message } from 'antd'; - -// interface ChartDataItem { -// value: number; -// name: string; -// } - -// const useChartData = () => { -// const [chartData, setChartData] = useState(null); -// const [isLoading, setIsLoading] = useState(true); -// const { t } = useTranslation(); -// const dispatch = useDispatch(); - -// const handleLogout = () => { -// deleteToken(); -// deleteUser(); -// dispatch(setUser(null)); -// console.log('Token deleted, user logged out'); -// message.info('You have been logged out. Please login again.'); -// }; - -// useEffect(() => { -// console.log('Component mounted, starting data fetch...'); -// const fetchData = async () => { -// console.log('Preparing to fetch data...'); -// setIsLoading(true); -// try { -// const token = readToken(); -// if (!token) { -// throw new Error('No authentication token found'); -// } -// console.log('Sending request to server...'); -// const response = await fetch(`${config.baseURL}/api/relaycount`, { -// method: 'GET', -// headers: { -// 'Content-Type': 'application/json', -// 'Authorization': `Bearer ${token}`, -// }, -// }); -// if (!response.ok) { -// if (response.status === 401) { -// handleLogout(); -// throw new Error('Authentication failed. You have been logged out.'); -// } -// throw new Error(`Network response was not ok (status: ${response.status})`); -// } -// const data = await response.json(); -// console.log('Response Data:', data); -// // Process the data into chartDataItems using translated names -// const newChartData: ChartDataItem[] = [ -// { value: data.kinds, name: t('categories.kinds') }, -// { value: data.photos, name: t('categories.photos') }, -// { value: data.videos, name: t('categories.videos') }, -// { value: data.audio, name: t('categories.audio') }, -// { value: data.misc, name: t('categories.misc') }, -// ]; -// setChartData(newChartData); -// } catch (error) { -// console.error('Error:', error); -// message.error(error instanceof Error ? error.message : 'An error occurred'); -// setChartData(null); -// } finally { -// console.log('Fetching process complete.'); -// setIsLoading(false); -// } -// }; - -// fetchData(); - -// return () => { -// console.log('Cleanup called; Component unmounting...'); -// }; -// }, [t, dispatch]); - -// return { chartData, isLoading }; -// }; - -// export default useChartData; \ No newline at end of file From a8439d9b164c3a43c355d5a5b80f41634c22452e Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 15:36:55 +0200 Subject: [PATCH 07/17] Implement backend API migration and file size limit controls - Migrate useRelaySettings and useChartData hooks to new backend API structure - Add core kinds protection system to prevent removal of essential event types - Implement file size limit controls for photos, videos, and audio - Add FileSizeLimitInput component with proper theme integration - Update MediaSection to include configurable file size inputs - Create transformation functions for new backend settings format - Support dynamic media types and chart data from backend configuration - Add TypeScript interfaces for new API structure --- .../relay-settings/layouts/DesktopLayout.tsx | 6 + .../relay-settings/layouts/MobileLayout.tsx | 6 + .../sections/MediaSection/MediaSection.tsx | 34 +++ .../components/FileSizeLimitInput.tsx | 107 +++++++++ src/constants/coreKinds.ts | 25 +++ src/constants/relaySettings.ts | 12 +- src/hooks/useChartData.ts | 53 ++++- src/hooks/useRelaySettings.ts | 191 ++++++++-------- src/pages/RelaySettingsPage.tsx | 18 +- src/types/newSettings.types.ts | 208 ++++++++++++++++++ 10 files changed, 562 insertions(+), 98 deletions(-) create mode 100644 src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx create mode 100644 src/constants/coreKinds.ts create mode 100644 src/types/newSettings.types.ts diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 016e65a..8457973 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -46,20 +46,26 @@ interface DesktopLayoutProps { photos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; videos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; audio: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; // Moderation section props moderationMode: string; diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index a8f3d8b..542242b 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -43,20 +43,26 @@ interface MobileLayoutProps { photos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; videos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; audio: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; // Moderation section props moderationMode: string; diff --git a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx index 0b93df2..380adc2 100644 --- a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx +++ b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx @@ -5,26 +5,33 @@ import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; import { MediaTypeList } from './components/MediaTypeList'; import { MediaToggle } from './components/MediaToggle'; +import { FileSizeLimitInput } from './components/FileSizeLimitInput'; export interface MediaSectionProps { mode: string; photos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; videos: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; audio: { selected: string[]; isActive: boolean; + maxSizeMB: number; onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; + onMaxSizeChange: (size: number) => void; }; } @@ -87,6 +94,15 @@ export const MediaSection: React.FC = ({ onChange={photos.onToggle} mode={mode} /> + = ({ onChange={videos.onToggle} mode={mode} /> + = ({ onChange={audio.onToggle} mode={mode} /> + void; + min?: number; + max?: number; + step?: number; + description?: string; + disabled?: boolean; +} + +export const FileSizeLimitInput: React.FC = ({ + label, + value, + onChange, + min = 1, + max = 5000, + step = 1, + description, + disabled = false, +}) => { + const handleChange = (newValue: number | null) => { + if (newValue !== null && newValue >= min && newValue <= max) { + onChange(newValue); + } + }; + + return ( + + {label} + {description && {description}} + + + + MB + + + + ); +}; + +export default FileSizeLimitInput; \ No newline at end of file diff --git a/src/constants/coreKinds.ts b/src/constants/coreKinds.ts new file mode 100644 index 0000000..9fb173b --- /dev/null +++ b/src/constants/coreKinds.ts @@ -0,0 +1,25 @@ +// Core kinds that are essential for relay operation and cannot be removed +export const CORE_KINDS = [ + 'kind0', // User profiles - Required for user management (CRITICAL - relay unusable without) + 'kind22242', // Auth events - Required for NIP-42 authentication + 'kind10010', // Mute list - Required for content filtering/mute words + 'kind19841', // Storage manifest - Required for file tracking + 'kind19842', // Storage metadata - Required for file info + 'kind19843', // Storage delete - Required for file cleanup +]; + +// Optional kinds that can be toggled (kind1 can be removed if needed) +export const OPTIONAL_KINDS = [ + 'kind1', // Text notes - Core functionality but can be disabled if needed +]; + +// Helper function to ensure core kinds are always included +export const ensureCoreKinds = (kindList: string[]): string[] => { + const combined = [...kindList, ...CORE_KINDS]; + return Array.from(new Set(combined)); +}; + +// Helper function to check if a kind is protected +export const isCoreKind = (kind: string): boolean => { + return CORE_KINDS.includes(kind); +}; \ No newline at end of file diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index c642feb..09dd09f 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -16,9 +16,13 @@ export type Settings = { isAudioActive: boolean; isFileStorageActive: boolean; moderationMode: string; // "strict" or "passive" + // File size limits in MB + photoMaxSizeMB: number; + videoMaxSizeMB: number; + audioMaxSizeMB: number; } -export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'appBuckets' | 'dynamicAppBuckets'; +export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'appBuckets' | 'dynamicAppBuckets' | 'photoMaxSizeMB' | 'videoMaxSizeMB' | 'audioMaxSizeMB'; export const noteOptions = [ { kind: 0, kindString: 'kind0', description: 'Metadata', category: 1 }, { kind: 1, kindString: 'kind1', description: 'Text Note', category: 1 }, @@ -43,6 +47,12 @@ export const noteOptions = [ { kind: 10011, kindString: 'kind10011', description: 'Issue Notes', category: 3 }, { kind: 10022, kindString: 'kind10022', description: 'PR Notes', category: 3 }, { kind: 9803, kindString: 'kind9803', description: 'Commit Notes', category: 3 }, + // Core kinds essential for relay operation + { kind: 10010, kindString: 'kind10010', description: 'Content Filtering', category: 1 }, + { kind: 22242, kindString: 'kind22242', description: 'NIP-42 Auth Events', category: 1 }, + { kind: 19841, kindString: 'kind19841', description: 'Storage Manifest', category: 1 }, + { kind: 19842, kindString: 'kind19842', description: 'Storage Metadata', category: 1 }, + { kind: 19843, kindString: 'kind19843', description: 'Storage Delete', category: 1 }, ]; export const appBuckets = [ { id: 'nostr', label: 'Nostr' }, diff --git a/src/hooks/useChartData.ts b/src/hooks/useChartData.ts index e14aa5c..bbc8fc9 100644 --- a/src/hooks/useChartData.ts +++ b/src/hooks/useChartData.ts @@ -5,6 +5,7 @@ import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { message } from 'antd'; import { useHandleLogout } from './authUtils'; +import { FileCountResponse } from '@app/types/newSettings.types'; interface ChartDataItem { value: number; @@ -43,16 +44,52 @@ const useChartData = () => { throw new Error(`Network response was not ok (status: ${response.status})`); } - const data = await response.json(); + const data: FileCountResponse = await response.json(); // Process the data into chartDataItems using translated names - const newChartData: ChartDataItem[] = [ - { value: data.kinds, name: t('categories.kinds') }, - { value: data.photos, name: t('categories.photos') }, - { value: data.videos, name: t('categories.videos') }, - { value: data.audio, name: t('categories.audio') }, - { value: data.misc, name: t('categories.misc') }, - ]; + // Handle dynamic media types while maintaining UI compatibility + const newChartData: ChartDataItem[] = []; + + // Always include kinds first + newChartData.push({ value: data.kinds, name: t('categories.kinds') }); + + // Destructure to separate kinds from media types + const { kinds, ...mediaCounts } = data; + + // Map dynamic media types to chart data with fallback translations + Object.entries(mediaCounts).forEach(([mediaType, count]) => { + let translationKey = ''; + let fallbackName = ''; + + // Map new media types to existing translation keys for UI compatibility + switch (mediaType) { + case 'image': + translationKey = 'categories.photos'; + fallbackName = 'Photos'; + break; + case 'video': + translationKey = 'categories.videos'; + fallbackName = 'Videos'; + break; + case 'audio': + translationKey = 'categories.audio'; + fallbackName = 'Audio'; + break; + case 'git': + translationKey = 'categories.misc'; + fallbackName = 'Git'; + break; + default: + // For any new media types not yet in translations + translationKey = `categories.${mediaType}`; + fallbackName = mediaType.charAt(0).toUpperCase() + mediaType.slice(1); + break; + } + + // Use translation if available, otherwise use fallback + const displayName = t(translationKey, { defaultValue: fallbackName }); + newChartData.push({ value: count, name: displayName }); + }); setChartData(newChartData); } catch (error) { diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 8dfdd58..b317b5c 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -1,33 +1,17 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import { CheckboxValueType } from 'antd/es/checkbox/Group'; import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; -import { Settings, noteOptions, mimeTypeOptions } from '@app/constants/relaySettings'; - -interface BackendRelaySettings { - mode: string; - protocol: CheckboxValueType[]; - chunked: CheckboxValueType[]; - chunksize: string; - maxFileSize: number; - maxFileSizeUnit: string; - moderationMode: string; // "strict" or "passive" - MimeTypeGroups: { - images: string[]; - videos: string[]; - audio: string[]; - documents: string[]; - }; - MimeTypeWhitelist: string[]; - KindWhitelist: string[]; - isFileStorageActive?: boolean; -} +import { Settings } from '@app/constants/relaySettings'; +import { CORE_KINDS, ensureCoreKinds } from '@app/constants/coreKinds'; + +// Legacy interface - no longer used with new API +// interface BackendRelaySettings { ... } const getInitialSettings = (): Settings => ({ mode: 'whitelist', protocol: ['WebSocket'], - kinds: [], + kinds: [...CORE_KINDS], // Always start with core kinds dynamicKinds: [], photos: [], videos: [], @@ -41,7 +25,11 @@ const getInitialSettings = (): Settings => ({ isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - moderationMode: 'strict' // Default to strict mode + moderationMode: 'strict', // Default to strict mode + // Default file size limits in MB + photoMaxSizeMB: 100, + videoMaxSizeMB: 500, + audioMaxSizeMB: 100 }); const useRelaySettings = () => { @@ -97,64 +85,43 @@ const useRelaySettings = () => { }, [relaySettings.mode, previousSmartSettings]); /* eslint-enable react-hooks/exhaustive-deps */ - const transformToBackendSettings = (settings: Settings): BackendRelaySettings => { - const mimeGroups = { - images: settings.photos, - videos: settings.videos, - audio: settings.audio, - documents: [] as string[] - }; - - const selectedMimeTypes = [ - ...mimeGroups.images, - ...mimeGroups.videos, - ...mimeGroups.audio - ]; + // Legacy transformation functions - kept for reference but not used with new API + // These can be removed once migration is fully tested - return { - mode: settings.mode, - protocol: settings.protocol as CheckboxValueType[], - chunked: [], - chunksize: '2', - maxFileSize: 10, - maxFileSizeUnit: 'MB', - moderationMode: settings.moderationMode, - MimeTypeGroups: mimeGroups, - isFileStorageActive: settings.isFileStorageActive, - MimeTypeWhitelist: settings.mode === 'whitelist' - ? selectedMimeTypes - : mimeTypeOptions - .map(m => m.value) - .filter(mimeType => !selectedMimeTypes.includes(mimeType)), - KindWhitelist: settings.mode === 'whitelist' - ? settings.kinds - : noteOptions - .map(note => note.kindString) - .filter(kind => !settings.kinds.includes(kind)) - }; - }; - - const transformFromBackendSettings = (backendSettings: BackendRelaySettings): Settings => { - console.log('Raw backend settings:', backendSettings); + // Simplified transformation functions based on actual backend response + const transformFromBackendSettings = useCallback((backendData: any): Settings => { + console.log('Raw backend settings:', backendData); const settings = getInitialSettings(); - settings.mode = backendSettings.mode; - settings.protocol = backendSettings.protocol as string[]; - settings.moderationMode = backendSettings.moderationMode ?? 'strict'; - - if (backendSettings.mode === 'blacklist') { - // In blacklist mode, start with empty selections - settings.photos = []; - settings.videos = []; - settings.audio = []; - settings.kinds = []; - } else { - // In whitelist mode, use the MimeTypeGroups directly - settings.photos = backendSettings.MimeTypeGroups?.images || []; - settings.videos = backendSettings.MimeTypeGroups?.videos || []; - settings.audio = backendSettings.MimeTypeGroups?.audio || []; - settings.kinds = backendSettings.KindWhitelist || []; - - // Store these as the previous whitelist settings + + // Map from actual backend structure + if (backendData.event_filtering) { + settings.mode = backendData.event_filtering.mode || 'whitelist'; + settings.moderationMode = backendData.event_filtering.moderation_mode || 'strict'; + // Always ensure core kinds are included from backend data + const backendKinds = backendData.event_filtering.kind_whitelist || []; + settings.kinds = ensureCoreKinds(backendKinds); + + // Extract mime types and file sizes from actual backend format + const mediaDefinitions = backendData.event_filtering.media_definitions || {}; + settings.photos = mediaDefinitions.image?.mimepatterns || []; + settings.videos = mediaDefinitions.video?.mimepatterns || []; + settings.audio = mediaDefinitions.audio?.mimepatterns || []; + + // Extract file size limits + settings.photoMaxSizeMB = mediaDefinitions.image?.maxsizemb || 100; + settings.videoMaxSizeMB = mediaDefinitions.video?.maxsizemb || 500; + settings.audioMaxSizeMB = mediaDefinitions.audio?.maxsizemb || 100; + + // Set protocols + if (backendData.event_filtering.protocols?.enabled) { + settings.protocol = backendData.event_filtering.protocols.allowed_protocols || ['WebSocket']; + } else { + settings.protocol = ['WebSocket']; + } + } + + // Store these as the previous whitelist settings if in whitelist mode + if (settings.mode === 'whitelist') { setPreviousSmartSettings({ kinds: settings.kinds, photos: settings.photos, @@ -168,15 +135,62 @@ const useRelaySettings = () => { settings.isPhotosActive = true; settings.isVideosActive = true; settings.isAudioActive = true; - settings.isFileStorageActive = backendSettings.isFileStorageActive ?? false; + settings.isFileStorageActive = true; return settings; - }; + }, []); + + const transformToBackendSettings = useCallback((settings: Settings) => { + const mediaDefinitions: any = {}; + + // Create media definitions in the format backend expects + if (settings.photos.length > 0) { + mediaDefinitions.image = { + mimepatterns: settings.photos, + extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], + maxsizemb: settings.photoMaxSizeMB + }; + } + + if (settings.videos.length > 0) { + mediaDefinitions.video = { + mimepatterns: settings.videos, + extensions: [".mp4", ".webm", ".avi", ".mov"], + maxsizemb: settings.videoMaxSizeMB + }; + } + + if (settings.audio.length > 0) { + mediaDefinitions.audio = { + mimepatterns: settings.audio, + extensions: [".mp3", ".wav", ".ogg", ".flac"], + maxsizemb: settings.audioMaxSizeMB + }; + } + return { + settings: { + event_filtering: { + mode: settings.mode, + moderation_mode: settings.moderationMode, + kind_whitelist: ensureCoreKinds(settings.kinds), // Always include core kinds + media_definitions: mediaDefinitions, + dynamic_kinds: { + enabled: false, + allowed_kinds: [] + }, + protocols: { + enabled: settings.protocol.length > 0, + allowed_protocols: settings.protocol + } + } + } + }; + }, []); const fetchSettings = useCallback(async () => { try { - const response = await fetch(`${config.baseURL}/api/relay-settings`, { + const response = await fetch(`${config.baseURL}/api/settings`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -190,24 +204,25 @@ const useRelaySettings = () => { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = await response.json(); - const settings = transformFromBackendSettings(data.relay_settings); + const settings = transformFromBackendSettings(data.settings); setRelaySettings(settings); } catch (error) { console.error('Error fetching settings:', error); } - }, [token, handleLogout]); + }, [token, handleLogout, transformFromBackendSettings]); const saveSettings = useCallback(async () => { try { - const backendSettings = transformToBackendSettings(relaySettings); - const response = await fetch(`${config.baseURL}/api/relay-settings`, { + const updateRequest = transformToBackendSettings(relaySettings); + + const response = await fetch(`${config.baseURL}/api/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify({ relay_settings: backendSettings }), + body: JSON.stringify(updateRequest), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); @@ -225,7 +240,7 @@ const useRelaySettings = () => { console.error('Error saving settings:', error); throw error; } - }, [relaySettings, token]); + }, [relaySettings, token, transformToBackendSettings]); const updateSettings = useCallback((category: keyof Settings, value: any) => { setRelaySettings(prev => ({ diff --git a/src/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index e4b70a8..88c1f48 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -41,7 +41,11 @@ const RelaySettingsPage: React.FC = () => { isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - moderationMode: 'strict' // Default to strict mode + moderationMode: 'strict', // Default to strict mode + // Default file size limits in MB + photoMaxSizeMB: 100, + videoMaxSizeMB: 500, + audioMaxSizeMB: 100 }); // Initialize stored dynamic items @@ -220,6 +224,12 @@ const RelaySettingsPage: React.FC = () => { updateSettings(type, checked); }; + // File size change handler + const handleFileSizeChange = (type: 'photoMaxSizeMB' | 'videoMaxSizeMB' | 'audioMaxSizeMB', size: number) => { + setSettings(prev => ({ ...prev, [type]: size })); + updateSettings(type, size); + }; + // Moderation mode handler const handleModerationModeChange = (mode: string) => { setSettings(prev => ({ ...prev, moderationMode: mode })); @@ -257,20 +267,26 @@ const RelaySettingsPage: React.FC = () => { photos: { selected: settings.photos, isActive: settings.isPhotosActive, + maxSizeMB: settings.photoMaxSizeMB, onChange: (values: string[]) => handleMediaChange('photos', values), onToggle: (checked: boolean) => handleMediaToggle('isPhotosActive', checked), + onMaxSizeChange: (size: number) => handleFileSizeChange('photoMaxSizeMB', size), }, videos: { selected: settings.videos, isActive: settings.isVideosActive, + maxSizeMB: settings.videoMaxSizeMB, onChange: (values: string[]) => handleMediaChange('videos', values), onToggle: (checked: boolean) => handleMediaToggle('isVideosActive', checked), + onMaxSizeChange: (size: number) => handleFileSizeChange('videoMaxSizeMB', size), }, audio: { selected: settings.audio, isActive: settings.isAudioActive, + maxSizeMB: settings.audioMaxSizeMB, onChange: (values: string[]) => handleMediaChange('audio', values), onToggle: (checked: boolean) => handleMediaToggle('isAudioActive', checked), + onMaxSizeChange: (size: number) => handleFileSizeChange('audioMaxSizeMB', size), }, // Moderation props moderationMode: settings.moderationMode, diff --git a/src/types/newSettings.types.ts b/src/types/newSettings.types.ts new file mode 100644 index 0000000..2907cc4 --- /dev/null +++ b/src/types/newSettings.types.ts @@ -0,0 +1,208 @@ +// New API structure types based on backend refactor + +export interface SubscriptionTier { + name: string; + price_sats: number; + monthly_limit: string; +} + +export interface SubscriptionTiersConfig { + tiers: SubscriptionTier[]; +} + +export interface ReadAccessConfig { + enabled: boolean; + scope: "all_users" | "paid_users" | "allowed_users"; +} + +export interface WriteAccessConfig { + enabled: boolean; +} + +export interface AllowedUsersSettings { + mode: "free" | "paid" | "exclusive"; + read_access: ReadAccessConfig; + write_access: WriteAccessConfig; + tiers: SubscriptionTier[]; + last_updated: number; +} + +export interface MediaDefinition { + mime_patterns: string[]; + extensions: string[]; + max_size_mb: number; +} + +export interface DynamicKindsConfig { + enabled: boolean; + allowed_kinds: number[]; +} + +export interface ProtocolsConfig { + enabled: boolean; + allowed_protocols: string[]; +} + +export interface EventFilteringConfig { + mode: "whitelist" | "blacklist"; + moderation_mode: "basic" | "strict" | "full"; + kind_whitelist: string[]; + media_definitions: Record; + dynamic_kinds: DynamicKindsConfig; + protocols: ProtocolsConfig; +} + +export interface TextFilterConfig { + enabled: boolean; + cache_size: number; + cache_ttl_seconds: number; + full_text_search_kinds: number[]; +} + +export interface ImageModerationConfig { + enabled: boolean; + mode: string; + threshold: number; + timeout_seconds: number; + check_interval_seconds: number; + concurrency: number; +} + +export interface ContentFilteringConfig { + text_filter: TextFilterConfig; + image_moderation: ImageModerationConfig; +} + +export interface ServerConfig { + port: number; + host: string; + private_key: string; + demo_mode: boolean; + web: boolean; + proxy: boolean; +} + +export interface ExternalServicesConfig { + wallet: { + wallet_api_key: string; + wallet_name: string; + }; + ollama: { + ollama_url: string; + ollama_model: string; + ollama_timeout: number; + }; + nest_feeder: { + nest_feeder_enabled: boolean; + nest_feeder_url: string; + nest_feeder_timeout: number; + nest_feeder_cache_size: number; + nest_feeder_cache_ttl: number; + }; + xnostr: { + xnostr_enabled: boolean; + xnostr_browser_path: string; + xnostr_browser_pool_size: number; + xnostr_check_interval: number; + xnostr_concurrency: number; + xnostr_temp_dir: string; + xnostr_update_interval: number; + }; +} + +export interface LoggingConfig { + level: string; + file_path: string; +} + +export interface RelayConfig { + relay_name: string; + relay_description: string; + relay_pubkey: string; + relay_contact: string; + relay_software: string; + relay_version: string; + relay_supported_nips: number[]; + relay_dht_key: string; + service_tag: string; + relay_stats_db: string; +} + +export interface Settings { + server: ServerConfig; + external_services: ExternalServicesConfig; + logging: LoggingConfig; + relay: RelayConfig; + content_filtering: ContentFilteringConfig; + event_filtering: EventFilteringConfig; + subscriptions: SubscriptionTiersConfig; + allowed_users: AllowedUsersSettings; +} + +export interface SettingsResponse { + settings: Settings; +} + +export interface SettingsUpdateRequest { + settings: Partial; +} + +// File count response with dynamic media types +export interface FileCountResponse { + kinds: number; + [mediaType: string]: number; // Dynamic media types based on media_definitions +} + +// Default settings for initialization +export const getDefaultSettings = (): Partial => ({ + allowed_users: { + mode: "free", + read_access: { enabled: true, scope: "all_users" }, + write_access: { enabled: true }, + tiers: [], + last_updated: Date.now() + }, + subscriptions: { + tiers: [ + { name: "Free", price_sats: 0, monthly_limit: "100 MB" }, + { name: "Basic", price_sats: 1000, monthly_limit: "1 GB" }, + { name: "Standard", price_sats: 10000, monthly_limit: "5 GB" }, + { name: "Premium", price_sats: 15000, monthly_limit: "10 GB" } + ] + }, + event_filtering: { + mode: "whitelist", + moderation_mode: "strict", + kind_whitelist: [], + media_definitions: { + image: { + mime_patterns: ["image/*"], + extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], + max_size_mb: 100 + }, + video: { + mime_patterns: ["video/*"], + extensions: [".mp4", ".webm", ".avi", ".mov"], + max_size_mb: 500 + }, + audio: { + mime_patterns: ["audio/*"], + extensions: [".mp3", ".wav", ".ogg", ".flac"], + max_size_mb: 100 + }, + git: { + mime_patterns: ["application/x-git"], + extensions: [".git", ".bundle", ".json"], + max_size_mb: 100 + } + }, + dynamic_kinds: { + enabled: false, + allowed_kinds: [] + }, + protocols: { + enabled: false, + allowed_protocols: [] + } + } +}); \ No newline at end of file From f3905e3fe8f2bd629d5c353df9c4ed67f2d54d77 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 15:45:50 +0200 Subject: [PATCH 08/17] Improve file size limit component UI and positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesign FileSizeLimitInput with compact horizontal layout - Move file size controls to bottom of each media section for better UX flow - Reduce component footprint with smaller margins and inline design - Fix theme integration using CSS variables instead of props.theme - Remove unused description prop and simplify component interface - Improve visual hierarchy: toggle → formats → size limits --- .../sections/MediaSection/MediaSection.tsx | 45 +++++---- .../components/FileSizeLimitInput.tsx | 93 +++++++++++-------- 2 files changed, 74 insertions(+), 64 deletions(-) diff --git a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx index 380adc2..b365ebd 100644 --- a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx +++ b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx @@ -94,22 +94,21 @@ export const MediaSection: React.FC = ({ onChange={photos.onToggle} mode={mode} /> + - @@ -120,22 +119,21 @@ export const MediaSection: React.FC = ({ onChange={videos.onToggle} mode={mode} /> + - @@ -146,22 +144,21 @@ export const MediaSection: React.FC = ({ onChange={audio.onToggle} mode={mode} /> + - diff --git a/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx b/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx index 852b3f6..0ed4695 100644 --- a/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx +++ b/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx @@ -1,57 +1,74 @@ // src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx import React from 'react'; -import { BaseForm } from '@app/components/common/forms/BaseForm/BaseForm'; -import { InputNumber, Space } from 'antd'; +import { InputNumber } from 'antd'; import styled from 'styled-components'; const StyledContainer = styled.div` - margin: 16px 0; - padding: 12px; - background: var(--secondary-background-color); - border-radius: 8px; + margin: 8px 0; + padding: 8px 12px; + background: var(--additional-background-color); + border-radius: 6px; border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; `; const StyledLabel = styled.div` - font-size: 14px; + font-size: 13px; font-weight: 500; color: var(--text-main-color); - margin-bottom: 8px; -`; - -const StyledDescription = styled.div` - font-size: 12px; - color: var(--text-light-color); - margin-bottom: 12px; + flex: 1; + min-width: 0; `; const StyledInputWrapper = styled.div` + display: flex; + align-items: center; + gap: 6px; + .ant-input-number { - width: 120px; + width: 80px; + height: 28px; background: var(--background-color); border-color: var(--border-color); - color: var(--text-main-color); + border-radius: 4px; &:hover { border-color: var(--primary-color); } - &:focus { + &:focus-within { border-color: var(--primary-color); - box-shadow: 0 0 0 2px var(--primary-color)20; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); + } + + .ant-input-number-input { + color: var(--text-main-color); + font-size: 12px; + height: 26px; + line-height: 26px; + } + + &.ant-input-number-disabled { + background: var(--secondary-background-color); + border-color: var(--border-color); + opacity: 0.6; + + .ant-input-number-input { + color: var(--text-light-color); + } } - } - - .ant-input-number-input { - color: var(--text-main-color); } `; const StyledUnit = styled.span` - font-size: 14px; + font-size: 12px; color: var(--text-light-color); - margin-left: 8px; + font-weight: 500; + min-width: 20px; `; export interface FileSizeLimitInputProps { @@ -61,7 +78,6 @@ export interface FileSizeLimitInputProps { min?: number; max?: number; step?: number; - description?: string; disabled?: boolean; } @@ -72,7 +88,6 @@ export const FileSizeLimitInput: React.FC = ({ min = 1, max = 5000, step = 1, - description, disabled = false, }) => { const handleChange = (newValue: number | null) => { @@ -84,21 +99,19 @@ export const FileSizeLimitInput: React.FC = ({ return ( {label} - {description && {description}} - - - MB - + + MB ); From 4884f1bddf532a411600bb607aca06f41ae649f6 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 18:28:04 +0200 Subject: [PATCH 09/17] Fix data type conversion for custom NIPs in relay info settings - Update RelayInfoSettings.tsx to use tags mode for custom NIP support - Add number conversion logic to ensure NIPs are sent as numbers to backend - Enhance useGenericSettings.ts with comprehensive field mapping for all groups - Remove query_cache from settings types as it's no longer used --- src/components/settings/RelayInfoSettings.tsx | 21 +- src/hooks/useGenericSettings.ts | 287 +++++++++++++++++- src/types/settings.types.ts | 2 - 3 files changed, 296 insertions(+), 14 deletions(-) diff --git a/src/components/settings/RelayInfoSettings.tsx b/src/components/settings/RelayInfoSettings.tsx index bdfb65e..fcfaaa7 100644 --- a/src/components/settings/RelayInfoSettings.tsx +++ b/src/components/settings/RelayInfoSettings.tsx @@ -205,9 +205,26 @@ const RelayInfoSettings: React.FC = () => { } > } - placeholder="http://localhost:8000/feed" - /> - - - - Cache Size  - - - - - } - rules={[ - { required: true, message: 'Please enter a cache size' }, - { type: 'number', min: 100, message: 'Value must be at least 100' } - ]} - > - - - - - Cache TTL (seconds)  - - - - - } - rules={[ - { required: true, message: 'Please enter a cache TTL' }, - { type: 'number', min: 1, message: 'Value must be at least 1' } - ]} - > - - - - - Timeout (seconds)  - - - - - } - rules={[ - { required: true, message: 'Please enter a timeout value' }, - { type: 'number', min: 1, message: 'Value must be at least 1' } - ]} - > - - - - - ); -}; - -export default NestFeederSettings; diff --git a/src/components/settings/QueryCacheSettings.tsx b/src/components/settings/QueryCacheSettings.tsx deleted file mode 100644 index a44dc57..0000000 --- a/src/components/settings/QueryCacheSettings.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Form, Input, Button, Space, Tooltip, Divider, InputNumber, Switch, Select } from 'antd'; -import { QuestionCircleOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'; -import useGenericSettings from '@app/hooks/useGenericSettings'; -import { SettingsGroupType } from '@app/types/settings.types'; -import BaseSettingsForm from './BaseSettingsForm'; -import styled from 'styled-components'; - -const KeyValueContainer = styled.div` - margin-bottom: 16px; -`; - -const QueryCacheSettings: React.FC = () => { - const { - settings, - loading, - error, - fetchSettings, - updateSettings, - saveSettings, - } = useGenericSettings('query_cache'); - - const [form] = Form.useForm(); - const [keyValuePairs, setKeyValuePairs] = useState>([]); - - // Update form values when settings change - useEffect(() => { - if (settings) { - // Convert settings object to array of key-value pairs - const pairs = Object.entries(settings).map(([key, value]) => ({ - key, - value, - type: typeof value === 'boolean' ? 'boolean' : - typeof value === 'number' ? 'number' : 'string' - })); - setKeyValuePairs(pairs); - } - }, [settings]); - - // Handle form value changes - const handleValuesChange = () => { - // Convert key-value pairs to settings object - const newSettings = keyValuePairs.reduce((acc, { key, value }) => { - if (key.trim()) { - acc[key.trim()] = value; - } - return acc; - }, {} as Record); - - updateSettings(newSettings); - }; - - // Add a new key-value pair - const handleAddPair = () => { - setKeyValuePairs([...keyValuePairs, { key: '', value: '', type: 'string' }]); - }; - - // Remove a key-value pair - const handleRemovePair = (index: number) => { - const newPairs = [...keyValuePairs]; - newPairs.splice(index, 1); - setKeyValuePairs(newPairs); - handleValuesChange(); - }; - - // Update a key-value pair - const handlePairChange = (index: number, field: 'key' | 'value' | 'type', value: string | number | boolean | null) => { - const newPairs = [...keyValuePairs]; - - if (field === 'type') { - // Convert the value to the new type - let convertedValue; - switch (value) { - case 'boolean': - convertedValue = Boolean(newPairs[index].value); - break; - case 'number': - convertedValue = Number(newPairs[index].value) || 0; - break; - case 'string': - convertedValue = String(newPairs[index].value); - break; - default: - convertedValue = newPairs[index].value; - } - newPairs[index].value = convertedValue; - } - - newPairs[index][field] = value; - setKeyValuePairs(newPairs); - handleValuesChange(); - }; - - // Render the value input based on the type - const renderValueInput = (pair: { key: string; value: any; type: string }, index: number) => { - switch (pair.type) { - case 'boolean': - return ( - handlePairChange(index, 'value', checked)} - /> - ); - case 'number': - return ( - handlePairChange(index, 'value', value ?? 0)} - style={{ width: '100%' }} - /> - ); - default: - return ( - handlePairChange(index, 'value', e.target.value)} - placeholder="Value" - /> - ); - } - }; - - return ( - -
- - Cache Configuration  - - - - - } - > -

- Add custom configuration parameters for the query cache. These settings control how the relay caches query results. -

- - - - {keyValuePairs.map((pair, index) => ( - - - - handlePairChange(index, 'key', e.target.value)} - placeholder="Key" - /> - - - - {renderValueInput(pair, index)} - - - - - - - -
- - -

- Note: These settings affect how the relay caches query results. Improper configuration may impact performance. - Consult the documentation for recommended values. -

-
- -
- ); -}; - -export default QueryCacheSettings; diff --git a/src/components/settings/SettingsNavigation.tsx b/src/components/settings/SettingsNavigation.tsx index 104e954..82ff0c7 100644 --- a/src/components/settings/SettingsNavigation.tsx +++ b/src/components/settings/SettingsNavigation.tsx @@ -11,7 +11,6 @@ import { InfoCircleOutlined, WalletOutlined, GlobalOutlined, - DatabaseOutlined } from '@ant-design/icons'; const { Panel } = Collapse; @@ -81,12 +80,6 @@ const settingsTabs: SettingsTab[] = [ icon: , path: '/settings/content-filter' }, - { - key: 'nest_feeder', - label: 'Nest Feeder', - icon: , - path: '/settings/nest-feeder' - }, { key: 'ollama', label: 'Ollama', @@ -111,12 +104,6 @@ const settingsTabs: SettingsTab[] = [ icon: , path: '/settings/general' }, - { - key: 'query_cache', - label: 'Query Cache', - icon: , - path: '/settings/query-cache' - } ]; const SettingsNavigation: React.FC = () => { diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index dca692b..3e21e3c 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -10,18 +10,15 @@ import { InfoCircleOutlined, WalletOutlined, GlobalOutlined, - DatabaseOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'; import ImageModerationSettings from './ImageModerationSettings'; import ContentFilterSettings from './ContentFilterSettings'; -import NestFeederSettings from './NestFeederSettings'; import OllamaSettings from './OllamaSettings'; import WalletSettings from './WalletSettings'; import GeneralSettings from './GeneralSettings'; import RelayInfoSettings from './RelayInfoSettings'; -import QueryCacheSettings from './QueryCacheSettings'; const SettingsContainer = styled.div` width: 100%; @@ -159,13 +156,6 @@ const SettingsPage: React.FC = () => { icon: , component: }, - { - key: 'nest_feeder', - path: '/settings/nest-feeder', - label: 'Nest Feeder', - icon: , - component: - }, { key: 'ollama', path: '/settings/ollama', @@ -194,13 +184,6 @@ const SettingsPage: React.FC = () => { icon: , component: }, - { - key: 'query_cache', - path: '/settings/query-cache', - label: 'Query Cache', - icon: , - component: - } ], []); // Set active key based on current path diff --git a/src/components/settings/layouts/AdvancedSettingsLayout.tsx b/src/components/settings/layouts/AdvancedSettingsLayout.tsx index d84b164..128d8fe 100644 --- a/src/components/settings/layouts/AdvancedSettingsLayout.tsx +++ b/src/components/settings/layouts/AdvancedSettingsLayout.tsx @@ -6,7 +6,6 @@ import styled from 'styled-components'; import GeneralSettingsPanel from '../panels/GeneralSettingsPanel'; import ImageModerationPanel from '../panels/ImageModerationPanel'; import ContentFilterPanel from '../panels/ContentFilterPanel'; -import NestFeederPanel from '../panels/NestFeederPanel'; import OllamaPanel from '../panels/OllamaPanel'; import WalletPanel from '../panels/WalletPanel'; import useGenericSettings from '@app/hooks/useGenericSettings'; @@ -143,10 +142,6 @@ const AdvancedSettingsLayout: React.FC = ({ - - - - diff --git a/src/hooks/useGenericSettings.ts b/src/hooks/useGenericSettings.ts index 615b1c0..138eae3 100644 --- a/src/hooks/useGenericSettings.ts +++ b/src/hooks/useGenericSettings.ts @@ -19,9 +19,6 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { rawData = settings?.content_filtering?.text_filter || {}; break; - case 'nest_feeder': - rawData = settings?.external_services?.nest_feeder || {}; - break; case 'ollama': rawData = settings?.external_services?.ollama || {}; @@ -103,34 +100,6 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { return processedData; } - // Add more mappings for other services that might need prefixed fields - if (groupName === 'nest_feeder' && rawData) { - const processedData: any = {}; - - // Handle nest_feeder prefixed fields based on the prefixedSettingsMap - const nestFeederMappings: Record = { - 'nest_feeder_cache_size': 'cache_size', - 'nest_feeder_cache_ttl': 'cache_ttl', - 'nest_feeder_enabled': 'enabled', - 'nest_feeder_timeout': 'timeout', - 'nest_feeder_url': 'url' - }; - - // Start with raw data - Object.keys(rawData).forEach(key => { - processedData[key] = rawData[key]; - }); - - // Apply prefixed field mappings - Object.entries(nestFeederMappings).forEach(([prefixedKey, rawKey]) => { - if (rawData[rawKey] !== undefined) { - processedData[prefixedKey] = rawData[rawKey]; - } - }); - - console.log(`Processed ${groupName} data:`, processedData); - return processedData; - } // Handle wallet field name mapping if (groupName === 'wallet' && rawData) { @@ -250,14 +219,6 @@ const buildNestedUpdate = (groupName: string, data: any) => { } }; - case 'nest_feeder': - return { - settings: { - external_services: { - nest_feeder: data - } - } - }; case 'ollama': return { @@ -507,16 +468,6 @@ const useGenericSettings = ( 'full_text_kinds' // Special case without prefix ] }, - 'nest_feeder': { - prefix: 'nest_feeder_', - formKeys: [ - 'nest_feeder_cache_size', - 'nest_feeder_cache_ttl', - 'nest_feeder_enabled', - 'nest_feeder_timeout', - 'nest_feeder_url' - ] - }, 'ollama': { prefix: 'ollama_', formKeys: [ diff --git a/src/types/settings.types.ts b/src/types/settings.types.ts index a23b0a5..bb75649 100644 --- a/src/types/settings.types.ts +++ b/src/types/settings.types.ts @@ -93,7 +93,6 @@ export interface QueryCacheSettings { export type SettingsGroupName = | 'image_moderation' | 'content_filter' - | 'nest_feeder' | 'ollama' | 'relay_info' | 'wallet' @@ -103,7 +102,6 @@ export type SettingsGroupName = export type SettingsGroupType = T extends 'image_moderation' ? ImageModerationSettings : T extends 'content_filter' ? ContentFilterSettings : - T extends 'nest_feeder' ? NestFeederSettings : T extends 'ollama' ? OllamaSettings : T extends 'relay_info' ? RelayInfoSettings : T extends 'wallet' ? WalletSettings : From 45e6adf8f7f4884ee022158ef41659b0457f9c59 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 20:13:14 +0200 Subject: [PATCH 12/17] Delete remaining panel files and fix relay pubkey mapping - Remove NestFeederPanel.tsx and QueryCachePanel.tsx to fix TypeScript errors - Fix relay info pubkey mapping to use 'public_key' from backend - Update reverse mapping for saving relay pubkey correctly - Remove default empty value for pubkey since it exists in backend --- .../settings/panels/NestFeederPanel.tsx | 228 ------------------ .../settings/panels/QueryCachePanel.tsx | 191 --------------- src/hooks/useGenericSettings.ts | 8 +- 3 files changed, 3 insertions(+), 424 deletions(-) delete mode 100644 src/components/settings/panels/NestFeederPanel.tsx delete mode 100644 src/components/settings/panels/QueryCachePanel.tsx diff --git a/src/components/settings/panels/NestFeederPanel.tsx b/src/components/settings/panels/NestFeederPanel.tsx deleted file mode 100644 index 9e56dc5..0000000 --- a/src/components/settings/panels/NestFeederPanel.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Form, Input, InputNumber, Switch, Tooltip, Button, Space } from 'antd'; -import { SaveOutlined } from '@ant-design/icons'; -import { QuestionCircleOutlined } from '@ant-design/icons'; -import useGenericSettings from '@app/hooks/useGenericSettings'; -import { SettingsGroupType } from '@app/types/settings.types'; -import BaseSettingsPanel from '../BaseSettingsPanel'; - -const NestFeederPanel: React.FC = () => { - const { - settings, - loading, - error, - updateSettings, - saveSettings: saveNestFeederSettings, - } = useGenericSettings('nest_feeder'); - - const [form] = Form.useForm(); - const [isUserEditing, setIsUserEditing] = useState(false); - - // Listen for save button click - useEffect(() => { - const handleGlobalSave = () => { - setTimeout(() => { - setIsUserEditing(false); - }, 200); - }; - - document.addEventListener('settings-saved', handleGlobalSave); - - return () => { - document.removeEventListener('settings-saved', handleGlobalSave); - }; - }, []); - - // Update form values when settings change, but only if user isn't actively editing - useEffect(() => { - if (settings && !isUserEditing) { - console.log('NestFeederPanel - Received settings:', settings); - - // Transform property names to match form field names - // The API returns properties without the prefix, but the form expects prefixed names - const settingsObj = settings as Record; - - const formValues = { - nest_feeder_enabled: settingsObj.enabled, - nest_feeder_url: settingsObj.url, - nest_feeder_timeout: typeof settingsObj.timeout === 'string' - ? parseInt(settingsObj.timeout) - : settingsObj.timeout, - nest_feeder_cache_size: typeof settingsObj.cache_size === 'string' - ? parseInt(settingsObj.cache_size) - : settingsObj.cache_size, - nest_feeder_cache_ttl: typeof settingsObj.cache_ttl === 'string' - ? parseInt(settingsObj.cache_ttl) - : settingsObj.cache_ttl - }; - - console.log('NestFeederPanel - Transformed form values:', formValues); - - // Set form values with a slight delay to ensure the form is ready - setTimeout(() => { - form.setFieldsValue(formValues); - console.log('NestFeederPanel - Form values after set:', form.getFieldsValue()); - }, 100); - } - }, [settings, form, isUserEditing]); - - // Handle form value changes - const handleValuesChange = (changedValues: Partial>) => { - setIsUserEditing(true); // Mark that user is currently editing - console.log('NestFeederPanel - changedValues:', changedValues); - console.log('NestFeederPanel - current form values:', form.getFieldsValue()); - updateSettings(changedValues); - }; - - const handlePanelSave = async () => { - try { - await saveNestFeederSettings(); - setIsUserEditing(false); - console.log('Nest Feeder settings saved successfully'); - } catch (error) { - console.error('Error saving Nest Feeder settings:', error); - } - }; - - return ( - } - onClick={handlePanelSave} - disabled={loading} - > - Save - - } - > -
{ - console.log('Form submitted with values:', values); - setIsUserEditing(false); - }} - > - - Enable Nest Feeder  - - - - - } - valuePropName="checked" - > - - - - - Nest Feeder URL  - - - - - } - rules={[ - { required: true, message: 'Please enter the Nest Feeder URL' }, - { type: 'url', message: 'Please enter a valid URL' } - ]} - > - - - - - Timeout (seconds)  - - - - - } - rules={[ - { type: 'number', min: 1, message: 'Value must be at least 1' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - - Cache Size  - - - - - } - rules={[ - { type: 'number', min: 100, message: 'Value must be at least 100' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - - Cache TTL (seconds)  - - - - - } - rules={[ - { type: 'number', min: 1, message: 'Value must be at least 1' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - -

- Note: Nest Feeder service helps with content discovery and recommendation. The cache improves performance by storing frequently accessed data. -

-
- -
- ); -}; - -export default NestFeederPanel; diff --git a/src/components/settings/panels/QueryCachePanel.tsx b/src/components/settings/panels/QueryCachePanel.tsx deleted file mode 100644 index 7a72e74..0000000 --- a/src/components/settings/panels/QueryCachePanel.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Form, InputNumber, Switch, Tooltip, Button } from 'antd'; -import { SaveOutlined } from '@ant-design/icons'; -import { QuestionCircleOutlined } from '@ant-design/icons'; -import useGenericSettings from '@app/hooks/useGenericSettings'; -import { SettingsGroupType } from '@app/types/settings.types'; -import BaseSettingsPanel from '../BaseSettingsPanel'; - -const QueryCachePanel: React.FC = () => { - const { - settings, - loading, - error, - updateSettings, - saveSettings: saveQueryCacheSettings, - } = useGenericSettings('query_cache'); - - const [form] = Form.useForm(); - const [isUserEditing, setIsUserEditing] = useState(false); - - // Listen for save button click - useEffect(() => { - const handleGlobalSave = () => { - setTimeout(() => { - setIsUserEditing(false); - }, 200); - }; - - document.addEventListener('settings-saved', handleGlobalSave); - - return () => { - document.removeEventListener('settings-saved', handleGlobalSave); - }; - }, []); - - // Update form values when settings change, but only if user isn't actively editing - useEffect(() => { - if (settings && !isUserEditing) { - console.log('QueryCachePanel - Received settings:', settings); - - // Set form values with a slight delay to ensure the form is ready - setTimeout(() => { - form.setFieldsValue(settings); - console.log('QueryCachePanel - Form values after set:', form.getFieldsValue()); - }, 100); - } - }, [settings, form, isUserEditing]); - - // Handle form value changes - const handleValuesChange = (changedValues: Partial>) => { - setIsUserEditing(true); // Mark that user is currently editing - console.log('QueryCachePanel - changedValues:', changedValues); - console.log('QueryCachePanel - current form values:', form.getFieldsValue()); - updateSettings(changedValues); - }; - - const handlePanelSave = async () => { - try { - await saveQueryCacheSettings(); - setIsUserEditing(false); - console.log('Query Cache settings saved successfully'); - } catch (error) { - console.error('Error saving Query Cache settings:', error); - } - }; - - return ( - } - onClick={handlePanelSave} - disabled={loading} - > - Save - - } - > -
{ - console.log('Form submitted with values:', values); - setIsUserEditing(false); - }} - > - - Enable Query Cache  - - - - - } - valuePropName="checked" - > - - - - - Max Cache Size  - - - - - } - rules={[ - { type: 'number', min: 100, message: 'Value must be at least 100' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - - Cache TTL (seconds)  - - - - - } - rules={[ - { type: 'number', min: 1, message: 'Value must be at least 1' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - - Cleanup Interval (seconds)  - - - - - } - rules={[ - { type: 'number', min: 10, message: 'Value must be at least 10' } - ]} - > - setIsUserEditing(true)} - onKeyDown={() => setIsUserEditing(true)} - /> - - - -

- Note: Query caching improves performance by storing query results temporarily. The cache is automatically cleared according to the cleanup interval and TTL settings. -

-
- -
- ); -}; - -export default QueryCachePanel; diff --git a/src/hooks/useGenericSettings.ts b/src/hooks/useGenericSettings.ts index 138eae3..3eb826c 100644 --- a/src/hooks/useGenericSettings.ts +++ b/src/hooks/useGenericSettings.ts @@ -170,7 +170,7 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { 'relayname': 'name', 'relaydescription': 'description', 'relaycontact': 'contact', - 'relaypubkey': 'pubkey', // This might not exist in backend, will be empty + 'relaypubkey': 'public_key', // Backend sends 'public_key' 'relaydhtkey': 'dht_key', 'relaysoftware': 'software', 'relayversion': 'version', @@ -183,9 +183,7 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { processedData[frontendKey] = rawData[backendKey]; } else { // Set default values for missing fields - if (frontendKey === 'relaypubkey') { - processedData[frontendKey] = ''; // Default empty for pubkey - } else if (frontendKey === 'relaysupportednips') { + if (frontendKey === 'relaysupportednips') { processedData[frontendKey] = []; // Default empty array } } @@ -258,11 +256,11 @@ const buildNestedUpdate = (groupName: string, data: any) => { 'name': 'relayname', 'description': 'relaydescription', 'contact': 'relaycontact', + 'public_key': 'relaypubkey', // Frontend 'relaypubkey' -> backend 'public_key' 'dht_key': 'relaydhtkey', 'software': 'relaysoftware', 'version': 'relayversion', 'supported_nips': 'relaysupportednips' - // Note: not mapping pubkey since it doesn't exist in backend }; Object.entries(relayFieldMappings).forEach(([backendKey, frontendKey]) => { From ae1292eab59441e20d4f22b14355994ea057be20 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Mon, 23 Jun 2025 21:40:35 +0200 Subject: [PATCH 13/17] Update allowed users API and add personal mode - Fix TypeScript error in allowedUsers.api.ts with proper type casting - Update API endpoints to use unified /api/settings endpoint - Update response parsing for new nested settings.allowed_users structure - Update request format to send data in settings.allowed_users structure - Add new 'personal' mode for single-user private relays - Update mode labels for better clarity: * free -> 'Free - Public Relay' * paid -> 'Paid - Subscription Tiers' * exclusive -> 'Free - Invite Only' * personal -> 'Free - Only Me' (NEW) - Add personal mode configuration with unlimited access and NPUB management - Update API logic to handle personal mode like free mode for tier filtering --- src/api/allowedUsers.api.ts | 82 ++++++++++++------- .../components/ModeSelector/ModeSelector.tsx | 11 ++- src/types/allowedUsers.types.ts | 15 +++- 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/api/allowedUsers.api.ts b/src/api/allowedUsers.api.ts index 27ef765..83a1426 100644 --- a/src/api/allowedUsers.api.ts +++ b/src/api/allowedUsers.api.ts @@ -6,13 +6,14 @@ import { AllowedUsersNpubsResponse, BulkImportRequest, AllowedUsersNpub, + AllowedUsersMode, DEFAULT_TIERS } from '@app/types/allowedUsers.types'; // Settings Management export const getAllowedUsersSettings = async (): Promise => { const token = readToken(); - const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + const response = await fetch(`${config.baseURL}/api/settings`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -22,16 +23,31 @@ export const getAllowedUsersSettings = async (): Promise = const text = await response.text(); try { - const data: AllowedUsersApiResponse = JSON.parse(text); + const data = JSON.parse(text); + + // Extract allowed_users from the new nested structure + const allowedUsersData = data.settings?.allowed_users; + if (!allowedUsersData) { + throw new Error('No allowed_users data found in response'); + } // Transform tiers from backend format to frontend format - let transformedTiers = data.allowed_users.tiers.map(tier => ({ - data_limit: (tier as any).datalimit || tier.data_limit || '', - price: tier.price - })); + let transformedTiers = []; + + // Check if tiers exist in response, otherwise use defaults + if (allowedUsersData.tiers && Array.isArray(allowedUsersData.tiers)) { + transformedTiers = allowedUsersData.tiers.map((tier: any) => ({ + data_limit: tier.datalimit || tier.data_limit || '', + price: tier.price || '0' + })); + } else { + // Use default tiers for the mode if none provided + const mode = allowedUsersData.mode as AllowedUsersMode; + transformedTiers = DEFAULT_TIERS[mode] || DEFAULT_TIERS.free; + } // For free mode, reconstruct full UI options with active tier marked - if (data.allowed_users.mode === 'free' && transformedTiers.length === 1) { + if (allowedUsersData.mode === 'free' && transformedTiers.length === 1) { const activeTierDataLimit = transformedTiers[0].data_limit; transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({ ...defaultTier, @@ -39,8 +55,15 @@ export const getAllowedUsersSettings = async (): Promise = })); } + // For personal mode, reconstruct with single unlimited tier + if (allowedUsersData.mode === 'personal' && transformedTiers.length === 1) { + transformedTiers = DEFAULT_TIERS.personal; + } + const transformedSettings = { - ...data.allowed_users, + mode: allowedUsersData.mode || 'free', + read_access: allowedUsersData.read_access || { enabled: true, scope: 'all_users' }, + write_access: allowedUsersData.write_access || { enabled: true, scope: 'all_users' }, tiers: transformedTiers }; @@ -53,33 +76,35 @@ export const getAllowedUsersSettings = async (): Promise = export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise<{ success: boolean, message: string }> => { const token = readToken(); - // Filter tiers based on mode - for free mode, only send active tier - const tiersToSend = settings.mode === 'free' + // Filter tiers based on mode - for free and personal modes, only send active tier + const tiersToSend = (settings.mode === 'free' || settings.mode === 'personal') ? settings.tiers.filter(tier => tier.active) : settings.tiers; - // Transform to nested format as expected by backend + // Transform to nested format as expected by new unified backend API const nestedSettings = { - "allowed_users": { - "mode": settings.mode, - "read_access": { - "enabled": settings.read_access.enabled, - "scope": settings.read_access.scope - }, - "write_access": { - "enabled": settings.write_access.enabled, - "scope": settings.write_access.scope - }, - "tiers": tiersToSend.map(tier => ({ - "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values - "price": tier.price || "0" - })) + "settings": { + "allowed_users": { + "mode": settings.mode, + "read_access": { + "enabled": settings.read_access.enabled, + "scope": settings.read_access.scope + }, + "write_access": { + "enabled": settings.write_access.enabled, + "scope": settings.write_access.scope + }, + "tiers": tiersToSend.map(tier => ({ + "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values + "price": tier.price || "0" + })) + } } }; console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2)); - const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + const response = await fetch(`${config.baseURL}/api/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -97,9 +122,10 @@ export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings) } try { - return JSON.parse(text); + return JSON.parse(text) || { success: true, message: 'Settings updated successfully' }; } catch (jsonError) { - throw new Error(`Invalid JSON response: ${text}`); + // If response is not JSON, assume success if status was OK + return { success: true, message: 'Settings updated successfully' }; } }; diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx index 58f3047..0741fd0 100644 --- a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -11,19 +11,24 @@ interface ModeSelectorProps { const MODE_INFO = { free: { - label: 'Free Mode', + label: 'Free - Public Relay', description: 'Open access with optional free tiers', color: '#1890ff' }, paid: { - label: 'Paid Mode', + label: 'Paid - Subscription Tiers', description: 'Subscription-based access control', color: '#52c41a' }, exclusive: { - label: 'Exclusive Mode', + label: 'Free - Invite Only', description: 'Invite-only access with manual NPUB management', color: '#722ed1' + }, + personal: { + label: 'Free - Only Me', + description: 'Personal relay for single user with unlimited access', + color: '#fa541c' } }; diff --git a/src/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts index 0aeafee..fd123a8 100644 --- a/src/types/allowedUsers.types.ts +++ b/src/types/allowedUsers.types.ts @@ -1,4 +1,4 @@ -export type AllowedUsersMode = 'free' | 'paid' | 'exclusive'; +export type AllowedUsersMode = 'free' | 'paid' | 'exclusive' | 'personal'; export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; @@ -91,6 +91,16 @@ export const MODE_CONFIGURATIONS: Record = { ], allowsFreeTiers: true, requiresNpubManagement: true + }, + personal: { + readOptions: [ + { value: 'allowed_users', label: 'Only Me' } + ], + writeOptions: [ + { value: 'allowed_users', label: 'Only Me' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true } }; @@ -110,5 +120,8 @@ export const DEFAULT_TIERS: Record = { { data_limit: '5 GB per month', price: '0' }, { data_limit: '50 GB per month', price: '0' }, { data_limit: 'unlimited', price: '0' } + ], + personal: [ + { data_limit: 'unlimited', price: '0', active: true } // Personal use, unlimited and free ] }; \ No newline at end of file From 63ee1bf9c0ec140845c37071791069f636a6526a Mon Sep 17 00:00:00 2001 From: Maphikza Date: Tue, 24 Jun 2025 11:24:39 +0200 Subject: [PATCH 14/17] Fix field name mapping in relay settings and add chart data debugging - Update useRelaySettings.ts to send correct field names (mime_patterns, max_size_mb) - Add backward compatibility for reading both old and new field names - Add comprehensive logging to useChartData.ts to debug media count issues - Ensure media definitions are always sent with complete structure to avoid backend conflicts --- src/hooks/useChartData.ts | 19 ++++++++++++- src/hooks/useRelaySettings.ts | 53 +++++++++++++++-------------------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/hooks/useChartData.ts b/src/hooks/useChartData.ts index bbc8fc9..54ed7f8 100644 --- a/src/hooks/useChartData.ts +++ b/src/hooks/useChartData.ts @@ -45,19 +45,29 @@ const useChartData = () => { } const data: FileCountResponse = await response.json(); + + // Log the complete backend response for debugging + console.log('=== CHART DATA BACKEND RESPONSE ==='); + console.log('Full response:', JSON.stringify(data, null, 2)); + console.log('Response keys:', Object.keys(data)); + console.log('=============================='); // Process the data into chartDataItems using translated names // Handle dynamic media types while maintaining UI compatibility const newChartData: ChartDataItem[] = []; // Always include kinds first + console.log('Adding kinds data:', { value: data.kinds, name: t('categories.kinds') }); newChartData.push({ value: data.kinds, name: t('categories.kinds') }); // Destructure to separate kinds from media types const { kinds, ...mediaCounts } = data; + console.log('Media counts after destructuring:', mediaCounts); + console.log('Media count entries:', Object.entries(mediaCounts)); // Map dynamic media types to chart data with fallback translations Object.entries(mediaCounts).forEach(([mediaType, count]) => { + console.log(`Processing media type: ${mediaType} with count: ${count}`); let translationKey = ''; let fallbackName = ''; @@ -88,9 +98,16 @@ const useChartData = () => { // Use translation if available, otherwise use fallback const displayName = t(translationKey, { defaultValue: fallbackName }); - newChartData.push({ value: count, name: displayName }); + const chartItem = { value: count, name: displayName }; + console.log(`Adding chart item:`, chartItem); + newChartData.push(chartItem); }); + console.log('=== FINAL CHART DATA ==='); + console.log('Final chart data:', newChartData); + console.log('Chart data length:', newChartData.length); + console.log('========================'); + setChartData(newChartData); } catch (error) { console.error('Error:', error); diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index b317b5c..6f12db6 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -103,14 +103,15 @@ const useRelaySettings = () => { // Extract mime types and file sizes from actual backend format const mediaDefinitions = backendData.event_filtering.media_definitions || {}; - settings.photos = mediaDefinitions.image?.mimepatterns || []; - settings.videos = mediaDefinitions.video?.mimepatterns || []; - settings.audio = mediaDefinitions.audio?.mimepatterns || []; + // Handle both old and new field names for backward compatibility + settings.photos = mediaDefinitions.image?.mime_patterns || mediaDefinitions.image?.mimepatterns || []; + settings.videos = mediaDefinitions.video?.mime_patterns || mediaDefinitions.video?.mimepatterns || []; + settings.audio = mediaDefinitions.audio?.mime_patterns || mediaDefinitions.audio?.mimepatterns || []; - // Extract file size limits - settings.photoMaxSizeMB = mediaDefinitions.image?.maxsizemb || 100; - settings.videoMaxSizeMB = mediaDefinitions.video?.maxsizemb || 500; - settings.audioMaxSizeMB = mediaDefinitions.audio?.maxsizemb || 100; + // Extract file size limits (handle both old and new field names) + settings.photoMaxSizeMB = mediaDefinitions.image?.max_size_mb || mediaDefinitions.image?.maxsizemb || 100; + settings.videoMaxSizeMB = mediaDefinitions.video?.max_size_mb || mediaDefinitions.video?.maxsizemb || 500; + settings.audioMaxSizeMB = mediaDefinitions.audio?.max_size_mb || mediaDefinitions.audio?.maxsizemb || 100; // Set protocols if (backendData.event_filtering.protocols?.enabled) { @@ -141,32 +142,24 @@ const useRelaySettings = () => { }, []); const transformToBackendSettings = useCallback((settings: Settings) => { - const mediaDefinitions: any = {}; - - // Create media definitions in the format backend expects - if (settings.photos.length > 0) { - mediaDefinitions.image = { - mimepatterns: settings.photos, + // Always create media definitions with correct field names to avoid backend conflicts + const mediaDefinitions = { + image: { + mime_patterns: settings.photos, // Only send correct field name extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], - maxsizemb: settings.photoMaxSizeMB - }; - } - - if (settings.videos.length > 0) { - mediaDefinitions.video = { - mimepatterns: settings.videos, + max_size_mb: settings.photoMaxSizeMB // Only send correct field name + }, + video: { + mime_patterns: settings.videos, // Only send correct field name extensions: [".mp4", ".webm", ".avi", ".mov"], - maxsizemb: settings.videoMaxSizeMB - }; - } - - if (settings.audio.length > 0) { - mediaDefinitions.audio = { - mimepatterns: settings.audio, + max_size_mb: settings.videoMaxSizeMB // Only send correct field name + }, + audio: { + mime_patterns: settings.audio, // Only send correct field name extensions: [".mp3", ".wav", ".ogg", ".flac"], - maxsizemb: settings.audioMaxSizeMB - }; - } + max_size_mb: settings.audioMaxSizeMB // Only send correct field name + } + }; return { settings: { From f05ec60b97e3649a421a300e8d045225ce9d9b69 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Tue, 24 Jun 2025 15:32:55 +0200 Subject: [PATCH 15/17] Implement new tier system with bytes-based limits and user-friendly UI - Add tierConversion utility with MB/GB/TB conversion and validation - Create TierEditor component with number input + unit dropdown - Update AllowedUsersTier to use price_sats and monthly_limit_bytes - Migrate DEFAULT_TIERS to new format with proper byte values - Update API layer to send/receive new backend format - Fix all TypeScript errors in tier management components - Add validation for 1MB-1TB range and unlimited tiers - Maintain backward compatibility during transition Backend format: {name, price_sats, monthly_limit_bytes, unlimited} Frontend UI: User-friendly number + unit selection with real-time validation --- src/api/allowedUsers.api.ts | 16 +- .../NPubManagement/NPubManagement.tsx | 16 +- .../components/TierEditor/TierEditor.tsx | 190 ++++++++++++ .../components/TiersConfig/TiersConfig.tsx | 272 ++++++++---------- .../layouts/AllowedUsersLayout.tsx | 12 +- src/hooks/useAllowedUsers.ts | 4 +- src/types/allowedUsers.types.ts | 33 ++- src/utils/tierConversion.utils.ts | 193 +++++++++++++ 8 files changed, 557 insertions(+), 179 deletions(-) create mode 100644 src/components/allowed-users/components/TierEditor/TierEditor.tsx create mode 100644 src/utils/tierConversion.utils.ts diff --git a/src/api/allowedUsers.api.ts b/src/api/allowedUsers.api.ts index 83a1426..d01525b 100644 --- a/src/api/allowedUsers.api.ts +++ b/src/api/allowedUsers.api.ts @@ -37,8 +37,10 @@ export const getAllowedUsersSettings = async (): Promise = // Check if tiers exist in response, otherwise use defaults if (allowedUsersData.tiers && Array.isArray(allowedUsersData.tiers)) { transformedTiers = allowedUsersData.tiers.map((tier: any) => ({ - data_limit: tier.datalimit || tier.data_limit || '', - price: tier.price || '0' + name: tier.name || 'Unnamed Tier', + price_sats: tier.price_sats || 0, + monthly_limit_bytes: tier.monthly_limit_bytes || 0, + unlimited: tier.unlimited || false })); } else { // Use default tiers for the mode if none provided @@ -48,10 +50,10 @@ export const getAllowedUsersSettings = async (): Promise = // For free mode, reconstruct full UI options with active tier marked if (allowedUsersData.mode === 'free' && transformedTiers.length === 1) { - const activeTierDataLimit = transformedTiers[0].data_limit; + const activeTierBytes = transformedTiers[0].monthly_limit_bytes; transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({ ...defaultTier, - active: defaultTier.data_limit === activeTierDataLimit + active: defaultTier.monthly_limit_bytes === activeTierBytes })); } @@ -95,8 +97,10 @@ export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings) "scope": settings.write_access.scope }, "tiers": tiersToSend.map(tier => ({ - "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values - "price": tier.price || "0" + "name": tier.name, + "price_sats": tier.price_sats, + "monthly_limit_bytes": tier.monthly_limit_bytes, + "unlimited": tier.unlimited })) } } diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx index 1720347..ca9465f 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -72,10 +72,16 @@ export const NPubManagement: React.FC = ({ setUnifiedUsers(Array.from(allNpubs.values())); }, [readNpubs.npubs, writeNpubs.npubs]); - const tierOptions = settings.tiers.map(tier => ({ - label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, - value: tier.data_limit - })); + const tierOptions = settings.tiers.map(tier => { + const displayFormat = tier.unlimited + ? 'unlimited' + : `${(tier.monthly_limit_bytes / 1073741824).toFixed(tier.monthly_limit_bytes % 1073741824 === 0 ? 0 : 1)} GB per month`; + + return { + label: `${tier.name} - ${displayFormat} (${tier.price_sats === 0 ? 'Free' : `${tier.price_sats} sats`})`, + value: tier.name + }; + }); const handleAddNpub = async () => { try { @@ -140,7 +146,7 @@ export const NPubManagement: React.FC = ({ } const lines = bulkText.split('\n').filter(line => line.trim()); - const defaultTier = settings.tiers[0]?.data_limit || 'basic'; + const defaultTier = settings.tiers[0]?.name || 'basic'; try { for (const line of lines) { diff --git a/src/components/allowed-users/components/TierEditor/TierEditor.tsx b/src/components/allowed-users/components/TierEditor/TierEditor.tsx new file mode 100644 index 0000000..d905b5c --- /dev/null +++ b/src/components/allowed-users/components/TierEditor/TierEditor.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { Input, Select, Checkbox, Space, Typography, Alert } from 'antd'; +import { + TierDisplayFormat, + DataUnit, + validateTierFormat, + displayToFriendlyString, + bytesToDisplayFormat, + TIER_VALIDATION +} from '@app/utils/tierConversion.utils'; +import { AllowedUsersTier } from '@app/types/allowedUsers.types'; + +const { Text } = Typography; +const { Option } = Select; + +interface TierEditorProps { + tier: AllowedUsersTier; + onTierChange: (updatedTier: AllowedUsersTier) => void; + disabled?: boolean; + showName?: boolean; + showPrice?: boolean; +} + +export const TierEditor: React.FC = ({ + tier, + onTierChange, + disabled = false, + showName = true, + showPrice = true +}) => { + // Convert backend format to display format for editing + const [displayFormat, setDisplayFormat] = useState(() => { + if (tier.unlimited) { + return { value: 0, unit: 'MB', unlimited: true }; + } + return { ...bytesToDisplayFormat(tier.monthly_limit_bytes), unlimited: false }; + }); + + const [name, setName] = useState(tier.name); + const [priceSats, setPriceSats] = useState(tier.price_sats); + + // Validation + const validation = validateTierFormat(displayFormat); + const isValid = validation.isValid; + + // Update parent when any field changes + useEffect(() => { + if (isValid) { + const updatedTier: AllowedUsersTier = { + name, + price_sats: priceSats, + monthly_limit_bytes: displayFormat.unlimited ? 0 : + Math.round(displayFormat.value * getUnitMultiplier(displayFormat.unit)), + unlimited: displayFormat.unlimited, + active: tier.active // Preserve active state + }; + onTierChange(updatedTier); + } + }, [displayFormat, name, priceSats, isValid, onTierChange, tier.active]); + + const getUnitMultiplier = (unit: DataUnit): number => { + switch (unit) { + case 'MB': return 1048576; // 1024 * 1024 + case 'GB': return 1073741824; // 1024 * 1024 * 1024 + case 'TB': return 1099511627776; // 1024 * 1024 * 1024 * 1024 + default: return 1048576; + } + }; + + const handleValueChange = (value: string) => { + const numericValue = parseFloat(value) || 0; + setDisplayFormat(prev => ({ ...prev, value: numericValue })); + }; + + const handleUnitChange = (unit: DataUnit) => { + setDisplayFormat(prev => ({ ...prev, unit })); + }; + + const handleUnlimitedChange = (unlimited: boolean) => { + setDisplayFormat(prev => ({ ...prev, unlimited })); + }; + + const handleNameChange = (value: string) => { + setName(value); + }; + + const handlePriceChange = (value: string) => { + const numericValue = parseInt(value) || 0; + setPriceSats(numericValue); + }; + + return ( + + {/* Tier Name */} + {showName && ( +
+ Tier Name + handleNameChange(e.target.value)} + placeholder="Enter tier name" + disabled={disabled} + style={{ marginTop: 4 }} + /> +
+ )} + + {/* Price */} + {showPrice && ( +
+ Price (sats) + handlePriceChange(e.target.value)} + placeholder="Price in satoshis" + disabled={disabled} + min={0} + style={{ marginTop: 4 }} + /> +
+ )} + + {/* Data Limit */} +
+ Monthly Data Limit + + {/* Unlimited Checkbox */} +
+ handleUnlimitedChange(e.target.checked)} + disabled={disabled} + > + Unlimited + +
+ + {/* Value and Unit Inputs */} + {!displayFormat.unlimited && ( + + handleValueChange(e.target.value)} + placeholder="Amount" + disabled={disabled} + min={TIER_VALIDATION.MIN_VALUE} + style={{ flex: 1 }} + /> + + + )} + + {/* Preview */} +
+ + Preview: {displayToFriendlyString(displayFormat)} + +
+ + {/* Validation Error */} + {!isValid && validation.error && ( + + )} +
+ + {/* Helpful Information */} +
+ + Valid range: 1 MB to 1 TB + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index 21c20db..37d3601 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -1,7 +1,9 @@ import React, { useState } from 'react'; -import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert, Radio, Card } from 'antd'; +import { Button, Table, Space, Modal, Popconfirm, Alert, Radio, Card } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; +import { TierEditor } from '../TierEditor/TierEditor'; +import { displayToFriendlyString, bytesToDisplayFormat } from '@app/utils/tierConversion.utils'; import * as S from './TiersConfig.styles'; interface TiersConfigProps { @@ -11,10 +13,7 @@ interface TiersConfigProps { disabled?: boolean; } -interface TierFormData { - data_limit: string; - price: string; -} +// Remove old form interface - using TierEditor now export const TiersConfig: React.FC = ({ settings, @@ -24,15 +23,15 @@ export const TiersConfig: React.FC = ({ }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [editingIndex, setEditingIndex] = useState(null); - const [form] = Form.useForm(); + const [currentTier, setCurrentTier] = useState(null); const isPaidMode = mode === 'paid'; const isFreeMode = mode === 'free'; - const handleFreeTierChange = (dataLimit: string) => { + const handleFreeTierChange = (tierName: string) => { const updatedTiers = settings.tiers.map(tier => ({ ...tier, - active: tier.data_limit === dataLimit + active: tier.name === tierName })); const updatedSettings = { @@ -45,17 +44,18 @@ export const TiersConfig: React.FC = ({ const handleAddTier = () => { setEditingIndex(null); - form.resetFields(); + setCurrentTier({ + name: '', + price_sats: isFreeMode ? 0 : 1000, + monthly_limit_bytes: 1073741824, // 1 GB default + unlimited: false + }); setIsModalVisible(true); }; const handleEditTier = (index: number) => { setEditingIndex(index); - const tier = settings.tiers[index]; - form.setFieldsValue({ - data_limit: tier.data_limit, - price: tier.price - }); + setCurrentTier({ ...settings.tiers[index] }); setIsModalVisible(true); }; @@ -68,68 +68,74 @@ export const TiersConfig: React.FC = ({ onSettingsChange(updatedSettings); }; - const handleModalOk = async () => { - try { - const values = await form.validateFields(); - - // Validate price for paid mode - if (isPaidMode && values.price === '0') { - form.setFields([{ - name: 'price', - errors: ['Paid mode cannot have free tiers'] - }]); - return; - } - - // Force price to "0" only for free mode, ensure it's always a string - const tierPrice = isFreeMode ? '0' : String(values.price || '0'); + const handleTierChange = (updatedTier: AllowedUsersTier) => { + setCurrentTier(updatedTier); + }; - const newTier: AllowedUsersTier = { - data_limit: values.data_limit, - price: tierPrice - }; + const handleModalOk = () => { + if (!currentTier) return; - let newTiers: AllowedUsersTier[]; - if (editingIndex !== null) { - newTiers = [...settings.tiers]; - newTiers[editingIndex] = newTier; - } else { - newTiers = [...settings.tiers, newTier]; - } + // Validate for paid mode + if (isPaidMode && currentTier.price_sats === 0) { + return; // TierEditor should show validation error + } - const updatedSettings = { - ...settings, - tiers: newTiers - }; + // Force price to 0 for free mode + const finalTier = { + ...currentTier, + price_sats: isFreeMode ? 0 : currentTier.price_sats + }; - onSettingsChange(updatedSettings); - setIsModalVisible(false); - form.resetFields(); - } catch (error) { - // Form validation failed + let newTiers: AllowedUsersTier[]; + if (editingIndex !== null) { + newTiers = [...settings.tiers]; + newTiers[editingIndex] = finalTier; + } else { + newTiers = [...settings.tiers, finalTier]; } + + const updatedSettings = { + ...settings, + tiers: newTiers + }; + + onSettingsChange(updatedSettings); + setIsModalVisible(false); + setCurrentTier(null); }; const handleModalCancel = () => { setIsModalVisible(false); - form.resetFields(); + setCurrentTier(null); setEditingIndex(null); }; const columns = [ + { + title: 'Tier Name', + dataIndex: 'name', + key: 'name', + render: (name: string) => {name} + }, { title: 'Data Limit', - dataIndex: 'data_limit', - key: 'data_limit', - render: (text: string) => {text} + dataIndex: 'monthly_limit_bytes', + key: 'monthly_limit_bytes', + render: (bytes: number, record: AllowedUsersTier) => { + if (record.unlimited) { + return unlimited; + } + const display = bytesToDisplayFormat(bytes); + return {displayToFriendlyString({ ...display, unlimited: false })}; + } }, { title: 'Price (sats)', - dataIndex: 'price', - key: 'price', - render: (price: string) => ( - - {price === '0' ? 'Free' : `${price} sats`} + dataIndex: 'price_sats', + key: 'price_sats', + render: (priceSats: number) => ( + + {priceSats === 0 ? 'Free' : `${priceSats} sats`} ) }, @@ -226,29 +232,36 @@ export const TiersConfig: React.FC = ({ {isFreeMode ? ( tier.active)?.data_limit} + value={settings.tiers.find(tier => tier.active)?.name} onChange={(e) => handleFreeTierChange(e.target.value)} disabled={disabled} > - {settings.tiers.map((tier, index) => ( - !disabled && handleFreeTierChange(tier.data_limit)} - > - - - {tier.data_limit} - Free - - - - ))} + {settings.tiers.map((tier, index) => { + const display = tier.unlimited + ? { value: 0, unit: 'MB' as const, unlimited: true } + : { ...bytesToDisplayFormat(tier.monthly_limit_bytes), unlimited: false }; + + return ( + !disabled && handleFreeTierChange(tier.name)} + > + + + {tier.name} + {displayToFriendlyString(display)} + Free + + + + ); + })} ) : ( @@ -267,76 +280,45 @@ export const TiersConfig: React.FC = ({ onOk={handleModalOk} onCancel={handleModalCancel} destroyOnClose + width={600} > -
- - - - - { - if (isPaidMode && value === '0') { - return Promise.reject('Paid mode cannot have free tiers'); - } - return Promise.resolve(); - } - } - ]} - > - - + {currentTier && ( + + )} - {isPaidMode && ( - - )} - - {isFreeMode && ( - - )} - + {isPaidMode && ( + + )} + + {isFreeMode && ( + + )}
); diff --git a/src/components/allowed-users/layouts/AllowedUsersLayout.tsx b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx index 215246e..7c30bc3 100644 --- a/src/components/allowed-users/layouts/AllowedUsersLayout.tsx +++ b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx @@ -37,20 +37,20 @@ export const AllowedUsersLayout: React.FC = () => { if (currentTiers.length === 0) return false; return currentTiers.every(tier => { - const hasValidDataLimit = tier.data_limit && tier.data_limit.trim() !== ''; + const hasValidName = tier.name && tier.name.trim() !== ''; if (targetMode === 'paid') { // Paid mode requires at least one tier with non-zero price - return hasValidDataLimit && tier.price && tier.price !== '0'; + return hasValidName && tier.price_sats > 0; } else if (targetMode === 'free') { - // Free mode should have price "0" - return hasValidDataLimit && tier.price === '0'; + // Free mode should have price 0 + return hasValidName && tier.price_sats === 0; } else if (targetMode === 'exclusive') { // Exclusive mode can have any price - return hasValidDataLimit; + return hasValidName; } - return hasValidDataLimit; + return hasValidName; }); }; diff --git a/src/hooks/useAllowedUsers.ts b/src/hooks/useAllowedUsers.ts index d1bbc17..cf03a75 100644 --- a/src/hooks/useAllowedUsers.ts +++ b/src/hooks/useAllowedUsers.ts @@ -50,7 +50,7 @@ export const useAllowedUsersSettings = () => { scope: 'all_users' }, tiers: [ - { data_limit: '1 GB per month', price: '0' } + { name: 'Basic', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false } ] }); } finally { @@ -232,7 +232,7 @@ export const useAllowedUsersValidation = () => { } // Tier validation - if (settings.mode === 'paid' && settings.tiers.some(t => t.price === '0')) { + if (settings.mode === 'paid' && settings.tiers.some(t => t.price_sats === 0)) { errors.push('Paid mode cannot have free tiers'); } diff --git a/src/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts index fd123a8..eda582b 100644 --- a/src/types/allowedUsers.types.ts +++ b/src/types/allowedUsers.types.ts @@ -3,15 +3,18 @@ export type AllowedUsersMode = 'free' | 'paid' | 'exclusive' | 'personal'; export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; export interface AllowedUsersTier { - data_limit: string; - price: string; + name: string; + price_sats: number; + monthly_limit_bytes: number; + unlimited: boolean; active?: boolean; // For free mode - only one tier can be active at a time } -// Backend expects this format -export interface AllowedUsersTierBackend { - datalimit: string; +// Legacy interface - kept for migration purposes +export interface AllowedUsersTierLegacy { + data_limit: string; price: string; + active?: boolean; } export interface AllowedUsersAccessConfig { @@ -107,21 +110,21 @@ export const MODE_CONFIGURATIONS: Record = { // Default tier configurations for each mode export const DEFAULT_TIERS: Record = { free: [ - { data_limit: '100 MB per month', price: '0', active: false }, - { data_limit: '500 MB per month', price: '0', active: true }, // Default active tier - { data_limit: '1 GB per month', price: '0', active: false } + { name: 'Basic', price_sats: 0, monthly_limit_bytes: 104857600, unlimited: false, active: false }, // 100 MB + { name: 'Standard', price_sats: 0, monthly_limit_bytes: 524288000, unlimited: false, active: true }, // 500 MB - default active + { name: 'Plus', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false, active: false } // 1 GB ], paid: [ - { data_limit: '1 GB per month', price: '1000' }, - { data_limit: '5 GB per month', price: '5000' }, - { data_limit: '10 GB per month', price: '10000' } + { name: 'Starter', price_sats: 1000, monthly_limit_bytes: 1073741824, unlimited: false }, // 1 GB + { name: 'Professional', price_sats: 5000, monthly_limit_bytes: 5368709120, unlimited: false }, // 5 GB + { name: 'Business', price_sats: 10000, monthly_limit_bytes: 10737418240, unlimited: false } // 10 GB ], exclusive: [ - { data_limit: '5 GB per month', price: '0' }, - { data_limit: '50 GB per month', price: '0' }, - { data_limit: 'unlimited', price: '0' } + { name: 'Member', price_sats: 0, monthly_limit_bytes: 5368709120, unlimited: false }, // 5 GB + { name: 'VIP', price_sats: 0, monthly_limit_bytes: 53687091200, unlimited: false }, // 50 GB + { name: 'Unlimited', price_sats: 0, monthly_limit_bytes: 0, unlimited: true } ], personal: [ - { data_limit: 'unlimited', price: '0', active: true } // Personal use, unlimited and free + { name: 'Personal', price_sats: 0, monthly_limit_bytes: 0, unlimited: true, active: true } // Unlimited and free ] }; \ No newline at end of file diff --git a/src/utils/tierConversion.utils.ts b/src/utils/tierConversion.utils.ts new file mode 100644 index 0000000..5a14885 --- /dev/null +++ b/src/utils/tierConversion.utils.ts @@ -0,0 +1,193 @@ +// Utility functions for converting between user-friendly tier formats and backend bytes + +export type DataUnit = 'MB' | 'GB' | 'TB'; + +export interface TierDisplayFormat { + value: number; // e.g., 500, 1, 10 + unit: DataUnit; // MB, GB, TB + unlimited: boolean; // special case +} + +export interface TierLimits { + name: string; + price_sats: number; + monthly_limit_bytes: number; + unlimited: boolean; +} + +// Validation constants (matching backend) +export const TIER_VALIDATION = { + MIN_BYTES: 1048576, // 1 MB + MAX_BYTES: 1099511627776, // 1 TB + MIN_VALUE: 1, + MAX_VALUE_MB: 1048576, // 1 TB in MB + MAX_VALUE_GB: 1024, // 1 TB in GB + MAX_VALUE_TB: 1 // 1 TB +} as const; + +// Conversion constants +const BYTES_PER_MB = 1048576; // 1024 * 1024 +const BYTES_PER_GB = 1073741824; // 1024 * 1024 * 1024 +const BYTES_PER_TB = 1099511627776; // 1024 * 1024 * 1024 * 1024 + +/** + * Convert display format (value + unit) to bytes + */ +export const convertToBytes = (value: number, unit: DataUnit): number => { + switch (unit) { + case 'MB': + return value * BYTES_PER_MB; + case 'GB': + return value * BYTES_PER_GB; + case 'TB': + return value * BYTES_PER_TB; + default: + throw new Error(`Unknown unit: ${unit}`); + } +}; + +/** + * Convert bytes to the most appropriate display format + */ +export const bytesToDisplayFormat = (bytes: number): TierDisplayFormat => { + // Handle zero or very small values + if (bytes === 0) { + return { value: 0, unit: 'MB', unlimited: false }; + } + + // Convert to TB if >= 1 TB + if (bytes >= BYTES_PER_TB) { + const tbValue = bytes / BYTES_PER_TB; + return { + value: Number(tbValue.toFixed(tbValue % 1 === 0 ? 0 : 2)), + unit: 'TB', + unlimited: false + }; + } + + // Convert to GB if >= 1 GB + if (bytes >= BYTES_PER_GB) { + const gbValue = bytes / BYTES_PER_GB; + return { + value: Number(gbValue.toFixed(gbValue % 1 === 0 ? 0 : 2)), + unit: 'GB', + unlimited: false + }; + } + + // Default to MB + const mbValue = bytes / BYTES_PER_MB; + return { + value: Number(mbValue.toFixed(mbValue % 1 === 0 ? 0 : 2)), + unit: 'MB', + unlimited: false + }; +}; + +/** + * Convert display format to friendly string (for display purposes) + */ +export const displayToFriendlyString = (display: TierDisplayFormat): string => { + if (display.unlimited) { + return 'unlimited'; + } + return `${display.value} ${display.unit} per month`; +}; + +/** + * Parse legacy string format to display format (for backward compatibility) + */ +export const parseLegacyFormat = (legacyString: string): TierDisplayFormat => { + if (!legacyString || legacyString.toLowerCase().includes('unlimited')) { + return { value: 0, unit: 'MB', unlimited: true }; + } + + // Extract number and unit from strings like "500 MB per month" + const match = legacyString.match(/(\d+(?:\.\d+)?)\s*(MB|GB|TB)/i); + if (match) { + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase() as DataUnit; + return { value, unit, unlimited: false }; + } + + // Fallback for unrecognized formats + return { value: 100, unit: 'MB', unlimited: false }; +}; + +/** + * Validate tier display format + */ +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +export const validateTierFormat = (display: TierDisplayFormat): ValidationResult => { + if (display.unlimited) { + return { isValid: true }; + } + + if (!display.value || display.value <= 0) { + return { isValid: false, error: 'Value must be greater than 0' }; + } + + // Check max values per unit + switch (display.unit) { + case 'MB': + if (display.value > TIER_VALIDATION.MAX_VALUE_MB) { + return { isValid: false, error: 'Maximum limit is 1 TB (1,048,576 MB)' }; + } + break; + case 'GB': + if (display.value > TIER_VALIDATION.MAX_VALUE_GB) { + return { isValid: false, error: 'Maximum limit is 1 TB (1,024 GB)' }; + } + break; + case 'TB': + if (display.value > TIER_VALIDATION.MAX_VALUE_TB) { + return { isValid: false, error: 'Maximum limit is 1 TB' }; + } + break; + } + + // Check converted bytes are within range + const bytes = convertToBytes(display.value, display.unit); + if (bytes < TIER_VALIDATION.MIN_BYTES) { + return { isValid: false, error: 'Minimum limit is 1 MB' }; + } + + if (bytes > TIER_VALIDATION.MAX_BYTES) { + return { isValid: false, error: 'Maximum limit is 1 TB' }; + } + + return { isValid: true }; +}; + +/** + * Convert display format to backend API format + */ +export const toBackendFormat = ( + name: string, + priceSats: number, + display: TierDisplayFormat +): TierLimits => ({ + name, + price_sats: priceSats, + monthly_limit_bytes: display.unlimited ? 0 : convertToBytes(display.value, display.unit), + unlimited: display.unlimited +}); + +/** + * Convert backend API format to display format + */ +export const fromBackendFormat = (backend: TierLimits): { + name: string; + price_sats: number; + display: TierDisplayFormat; +} => ({ + name: backend.name, + price_sats: backend.price_sats, + display: backend.unlimited + ? { value: 0, unit: 'MB', unlimited: true } + : { ...bytesToDisplayFormat(backend.monthly_limit_bytes), unlimited: false } +}); \ No newline at end of file From 9bee87f66f88f34e33f3019312672f40d224beff Mon Sep 17 00:00:00 2001 From: Maphikza Date: Tue, 24 Jun 2025 16:23:54 +0200 Subject: [PATCH 16/17] Update allowed users mode selector to 2x2 grid layout - Change from horizontal layout to 2x2 grid for better visual organization - Update button labels and order to match design: Only Me, Invite Only, Public Relay, Subscription - Increase button height to 80px to properly accommodate two-line text - Add proper flexbox layout to prevent text overlapping - Improve typography with larger main labels and smaller subtitle text - Maintain existing colors and functionality while improving layout Layout: Top row (Only Me, Invite Only), Bottom row (Public Relay, Subscription) --- .../ModeSelector/ModeSelector.styles.ts | 12 ++++-- .../components/ModeSelector/ModeSelector.tsx | 41 ++++++++++++------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts index 191eaf7..f686bfe 100644 --- a/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts @@ -8,12 +8,14 @@ export const Container = styled.div` export const ModeGrid = styled.div` display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); gap: 1rem; margin-bottom: 1.5rem; ${media.md} { grid-template-columns: 1fr; + grid-template-rows: none; gap: 0.75rem; } `; @@ -24,10 +26,14 @@ interface ModeButtonProps { } export const ModeButton = styled(Button)` - height: 60px; + height: 80px; border-radius: 8px; font-weight: 600; transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; ${({ $isActive, $color }) => $isActive && ` background-color: ${$color} !important; @@ -41,7 +47,7 @@ export const ModeButton = styled(Button)` } ${media.md} { - height: 50px; + height: 70px; } `; diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx index 0741fd0..5cfa794 100644 --- a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -10,25 +10,29 @@ interface ModeSelectorProps { } const MODE_INFO = { + personal: { + label: 'Only Me', + subtitle: '[Free]', + description: 'Personal relay for single user with unlimited access', + color: '#fa541c' + }, + exclusive: { + label: 'Invite Only', + subtitle: '[Free]', + description: 'Invite-only access with manual NPUB management', + color: '#722ed1' + }, free: { - label: 'Free - Public Relay', + label: 'Public Relay', + subtitle: '[Free]', description: 'Open access with optional free tiers', color: '#1890ff' }, paid: { - label: 'Paid - Subscription Tiers', + label: 'Subscription', + subtitle: '[Paid]', description: 'Subscription-based access control', color: '#52c41a' - }, - exclusive: { - label: 'Free - Invite Only', - description: 'Invite-only access with manual NPUB management', - color: '#722ed1' - }, - personal: { - label: 'Free - Only Me', - description: 'Personal relay for single user with unlimited access', - color: '#fa541c' } }; @@ -40,7 +44,7 @@ export const ModeSelector: React.FC = ({ return ( - {(Object.keys(MODE_INFO) as AllowedUsersMode[]).map((mode) => { + {(['personal', 'exclusive', 'free', 'paid'] as AllowedUsersMode[]).map((mode) => { const info = MODE_INFO[mode]; const isActive = currentMode === mode; @@ -54,7 +58,16 @@ export const ModeSelector: React.FC = ({ $isActive={isActive} $color={info.color} > - {info.label} +
+
{info.label}
+
{info.subtitle}
+
); From 3eaf795a8506f9b7185504b18c4daba04a4e1b48 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Tue, 24 Jun 2025 19:33:52 +0200 Subject: [PATCH 17/17] Fix View All paid subscribers modal with improved layout and npub encoding - Add working View All button functionality with modal display - Implement horizontal list layout for better scrolling through many subscribers - Use proper nostr-tools nip19.npubEncode() for correct npub format display - Add fallback to original hex format if encoding fails - Improve visual design with larger avatars, better shadows, and modern card styling - Remove loading spinner that was blocking content display - Enhance hover effects and typography for better user experience Each subscriber now displays with avatar, name, and properly formatted npub/hex --- .../paid-subscribers/PaidSubscribers.tsx | 226 +++++++++++++++++- src/locales/en/translation.json | 1 + 2 files changed, 223 insertions(+), 4 deletions(-) diff --git a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx index 7fad87b..ab1749b 100644 --- a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx +++ b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx @@ -12,17 +12,24 @@ import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; import { SplideCarousel } from '@app/components/common/SplideCarousel/SplideCarousel'; import { useResponsive } from '@app/hooks/useResponsive'; import usePaidSubscribers, { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; -import { Row, Col } from 'antd'; +import { Row, Col, Modal, Spin, Typography } from 'antd'; +import { nip19 } from 'nostr-tools'; + +const { Text } = Typography; export const PaidSubscribers: React.FC = () => { console.log('[PaidSubscribers] Component rendering...'); const hookResult = usePaidSubscribers(12); - const { subscribers } = hookResult; + const { subscribers, fetchMore, hasMore, loading } = hookResult; // Modal state for subscriber details const [selectedSubscriber, setSelectedSubscriber] = useState(null); const [isModalVisible, setIsModalVisible] = useState(false); + // Modal state for view all subscribers + const [isViewAllModalVisible, setIsViewAllModalVisible] = useState(false); + const [allSubscribers, setAllSubscribers] = useState([]); + // Handle opening subscriber detail modal const handleOpenSubscriberDetails = (subscriber: SubscriberProfile) => { setSelectedSubscriber(subscriber); @@ -34,6 +41,33 @@ export const PaidSubscribers: React.FC = () => { setIsModalVisible(false); }; + // Handle opening view all modal + const handleViewAll = async () => { + setIsViewAllModalVisible(true); + setAllSubscribers([...subscribers]); // Start with current subscribers + + // Fetch more subscribers if available + let currentSubscribers = [...subscribers]; + let canFetchMore = hasMore; + + while (canFetchMore) { + try { + await fetchMore(); + // Note: This is a simplified approach. In a real scenario, you'd want to + // track the updated state properly or use a separate hook for fetching all + canFetchMore = false; // For now, just fetch once more + } catch (error) { + console.error('Error fetching more subscribers:', error); + break; + } + } + }; + + // Handle closing view all modal + const handleCloseViewAllModal = () => { + setIsViewAllModalVisible(false); + }; + console.log('[PaidSubscribers] Received subscribers:', subscribers); console.log('[PaidSubscribers] Complete hook result:', hookResult); @@ -63,7 +97,7 @@ export const PaidSubscribers: React.FC = () => { - + @@ -87,6 +121,98 @@ export const PaidSubscribers: React.FC = () => { isVisible={isModalVisible} onClose={handleCloseModal} /> + + {/* View All Subscribers Modal */} + + + {(allSubscribers.length > 0 ? allSubscribers : subscribers).map((subscriber: SubscriberProfile) => ( +
+
{ + setSelectedSubscriber(subscriber); + setIsModalVisible(true); + setIsViewAllModalVisible(false); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'var(--background-color-light)'; + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'var(--background-color-secondary)'; + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)'; + }} + > +
+ {subscriber.name +
+
+ + {subscriber.name || 'Anonymous User'} + + + {(() => { + try { + return nip19.npubEncode(subscriber.pubkey); + } catch { + // Fallback to original hex format if encoding fails + return subscriber.pubkey; + } + })()} + +
+
+ + ))} + + ); } @@ -123,7 +249,7 @@ export const PaidSubscribers: React.FC = () => { - + {isTabletOrHigher && subscribers.length > 1 && ( @@ -163,6 +289,98 @@ export const PaidSubscribers: React.FC = () => { isVisible={isModalVisible} onClose={handleCloseModal} /> + + {/* View All Subscribers Modal */} + + + {(allSubscribers.length > 0 ? allSubscribers : subscribers).map((subscriber: SubscriberProfile) => ( + +
{ + setSelectedSubscriber(subscriber); + setIsModalVisible(true); + setIsViewAllModalVisible(false); + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'var(--background-color-light)'; + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'var(--background-color-secondary)'; + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)'; + }} + > +
+ {subscriber.name +
+
+ + {subscriber.name || 'Anonymous User'} + + + {(() => { + try { + return nip19.npubEncode(subscriber.pubkey); + } catch { + // Fallback to original hex format if encoding fails + return subscriber.pubkey; + } + })()} + +
+
+ + ))} + + ); }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8ea2ab9..c4405b2 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -971,6 +971,7 @@ "bitcoinPrice": "Bitcoin Price", "yourBalance": "Your Balance", "topUpBalance": "Top Up Balance", + "allPaidSubscribers": "All Paid Subscribers", "amount": "Amount", "selectCard": "Select card", "totalEarning": "Total Earning",