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..d01525b --- /dev/null +++ b/src/api/allowedUsers.api.ts @@ -0,0 +1,288 @@ +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; +import { + AllowedUsersSettings, + AllowedUsersApiResponse, + 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`, { + 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); + + // 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 = []; + + // Check if tiers exist in response, otherwise use defaults + if (allowedUsersData.tiers && Array.isArray(allowedUsersData.tiers)) { + transformedTiers = allowedUsersData.tiers.map((tier: any) => ({ + 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 + 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 (allowedUsersData.mode === 'free' && transformedTiers.length === 1) { + const activeTierBytes = transformedTiers[0].monthly_limit_bytes; + transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({ + ...defaultTier, + active: defaultTier.monthly_limit_bytes === activeTierBytes + })); + } + + // For personal mode, reconstruct with single unlimited tier + if (allowedUsersData.mode === 'personal' && transformedTiers.length === 1) { + transformedTiers = DEFAULT_TIERS.personal; + } + + const transformedSettings = { + 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 + }; + + 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(); + + // 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 new unified backend API + const nestedSettings = { + "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 => ({ + "name": tier.name, + "price_sats": tier.price_sats, + "monthly_limit_bytes": tier.monthly_limit_bytes, + "unlimited": tier.unlimited + })) + } + } + }; + + console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2)); + + const response = await fetch(`${config.baseURL}/api/settings`, { + 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) || { success: true, message: 'Settings updated successfully' }; + } catch (jsonError) { + // If response is not JSON, assume success if status was OK + return { success: true, message: 'Settings updated successfully' }; + } +}; + +// 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/SubscriptionTiersManager/SubscriptionTiersManager.tsx b/src/components/SubscriptionTiersManager/SubscriptionTiersManager.tsx deleted file mode 100644 index d5d08d9..0000000 --- a/src/components/SubscriptionTiersManager/SubscriptionTiersManager.tsx +++ /dev/null @@ -1,535 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Input, Switch, Tooltip, Select, InputNumber, Space } from 'antd'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import { PlusOutlined, DatabaseOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; -import type { SubscriptionTier } from '@app/constants/relaySettings'; -import styled from 'styled-components'; - -// Helper functions for data limit parsing and formatting -interface DataLimit { - amount: number; - unit: 'MB' | 'GB'; -} - -const parseDataLimit = (dataLimitString: string): DataLimit => { - const match = dataLimitString.match(/^(\d+)\s*(MB|GB)/i); - if (match) { - return { - amount: parseInt(match[1], 10), - unit: match[2].toUpperCase() as 'MB' | 'GB' - }; - } - // Default fallback - return { amount: 1, unit: 'GB' }; -}; - -const formatDataLimit = (amount: number, unit: 'MB' | 'GB'): string => { - return `${amount} ${unit} per month`; -}; - -// Styled components for better UI -const TierCard = styled.div` - background: linear-gradient(145deg, #1b1b38 0%, #161632 100%); - border-radius: 12px; - padding: 1.5rem; - margin-bottom: 1.5rem; - border: 1px solid #2c2c50; - box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.2); - transition: all 0.3s ease; - - &:hover { - box-shadow: 0px 6px 16px rgba(0, 0, 0, 0.3); - transform: translateY(-2px); - } -`; - -const TierHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - border-bottom: 1px solid #2c2c50; - padding-bottom: 0.75rem; -`; - -const TierTitle = styled.h3` - color: white; - font-size: 1.2rem; - margin: 0; - font-weight: 500; -`; - -const TierBadge = styled.span` - background-color: #4e4e8b; - color: white; - font-size: 0.75rem; - padding: 4px 8px; - border-radius: 4px; - margin-left: 8px; -`; - -const InputGroup = styled.div` - margin-bottom: 1rem; -`; - -const InputLabel = styled.label` - display: block; - color: #a9a9c8; - margin-bottom: 0.5rem; - font-size: 0.9rem; -`; - -const StyledSwitch = styled(Switch)` - &.ant-switch-checked { - background-color: #4e4e8b; - } -`; - -const ActionButton = styled(BaseButton)` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - border-radius: 8px; - transition: all 0.2s ease; - - &:hover { - transform: translateY(-1px); - } -`; - -const RemoveButton = styled(ActionButton)` - background-color: #321e28; - border-color: #4e2a32; - color: #e5a9b3; - - &:hover { - background-color: #3d2530; - } -`; - -const AddButton = styled(ActionButton)` - background-color: #1e3232; - border-color: #2a4e4e; - color: #a9e5e5; - - &:hover { - background-color: #254242; - } -`; - -const FreeTierToggle = styled.div` - display: flex; - align-items: center; - background-color: rgba(78, 78, 139, 0.2); - padding: 0.75rem; - border-radius: 8px; - margin-bottom: 1.5rem; -`; - -const FreeTierLabel = styled.span` - margin-left: 0.75rem; - color: white; - font-size: 0.95rem; -`; - -const InfoText = styled.small` - color: #a9a9c8; - line-height: 1.5; -`; - -const InputIcon = styled.div` - display: flex; - align-items: center; - margin-bottom: 0.5rem; - - svg { - margin-right: 0.5rem; - color: #a9a9c8; - } -`; - -const DataLimitInputGroup = styled.div` - display: flex; - gap: 8px; - align-items: flex-start; -`; - -const StyledInputNumber = styled(InputNumber)` - flex: 1; - background-color: #1b1b38 !important; - border-color: #313131 !important; - color: white !important; - border-radius: 8px !important; - height: 48px !important; - - .ant-input-number-input { - color: white !important; - } - - &.ant-input-number-focused { - border-color: #4e4e8b !important; - box-shadow: 0 0 0 2px rgba(78, 78, 139, 0.2) !important; - } -`; - -const StyledSelect = styled(Select)` - width: 120px !important; - - .ant-select-selector { - background-color: #1b1b38 !important; - border-color: #313131 !important; - height: 48px !important; - display: flex !important; - align-items: center !important; - border-radius: 8px !important; - } - - .ant-select-selection-item { - color: white !important; - } - - &.ant-select-focused .ant-select-selector { - border-color: #4e4e8b !important; - box-shadow: 0 0 0 2px rgba(78, 78, 139, 0.2) !important; - } -`; - -interface SubscriptionTiersManagerProps { - tiers?: SubscriptionTier[]; - onChange: (tiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean; - freeTierLimit: string; - onFreeTierChange: (enabled: boolean, limit: string) => void; -} - -const SubscriptionTiersManager: React.FC = ({ - tiers = [], - onChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange -}) => { - 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' } - ]; - - // Initialize tiers with data_limit parsed into amount and unit - const [currentTiers, setCurrentTiers] = useState<(SubscriptionTier & { amount: number; unit: 'MB' | 'GB' })[]>(() => { - return tiers.length > 0 ? tiers.map(tier => { - const dataLimit = parseDataLimit(tier.data_limit); - return { - data_limit: tier.data_limit.includes('per month') ? tier.data_limit : `${tier.data_limit} per month`, - price: tier.price, - amount: dataLimit.amount, - unit: dataLimit.unit - }; - }) : defaultTiers.map(tier => { - const dataLimit = parseDataLimit(tier.data_limit); - return { - data_limit: tier.data_limit, - price: tier.price, - amount: dataLimit.amount, - unit: dataLimit.unit - }; - }); - }); - - // Parse free tier limit into amount and unit - const parsedFreeTierLimit = parseDataLimit(freeTierLimit); - const [freeTierAmount, setFreeTierAmount] = useState(parsedFreeTierLimit.amount); - const [freeTierUnit, setFreeTierUnit] = useState<'MB' | 'GB'>(parsedFreeTierLimit.unit); - - // Update current tiers when props change - useEffect(() => { - if (tiers.length > 0) { - // Use functional update pattern to avoid dependency on currentTiers - setCurrentTiers(prevTiers => { - const formattedTiers = tiers.map(tier => { - const dataLimit = parseDataLimit(tier.data_limit); - return { - data_limit: tier.data_limit.includes('per month') ? tier.data_limit : `${tier.data_limit} per month`, - price: tier.price, - amount: dataLimit.amount, - unit: dataLimit.unit - }; - }); - - // Only update if the formatted tiers are different from current - const currentTierDataOnly = prevTiers.map(({ data_limit, price }) => ({ data_limit, price })); - const formattedTierDataOnly = formattedTiers.map(({ data_limit, price }) => ({ data_limit, price })); - - if (JSON.stringify(currentTierDataOnly) !== JSON.stringify(formattedTierDataOnly)) { - return formattedTiers; - } - return prevTiers; - }); - } - }, [tiers]); - - // Update free tier amount and unit when freeTierLimit prop changes - useEffect(() => { - const parsed = parseDataLimit(freeTierLimit); - setFreeTierAmount(parsed.amount); - setFreeTierUnit(parsed.unit); - }, [freeTierLimit]); - - const handleUpdateTierPrice = (index: number, value: string) => { - const newTiers = currentTiers.map((tier, i) => { - if (i === index) { - return { ...tier, price: value }; - } - return tier; - }); - - setCurrentTiers(newTiers); - onChange(newTiers.map(({ data_limit, price }) => ({ data_limit, price }))); - }; - - // Fixed type signature for InputNumber's onChange - const handleUpdateTierAmount = (index: number, value: string | number | null) => { - if (value === null) return; - - const numValue = typeof value === 'string' ? parseInt(value, 10) : value; - - const newTiers = currentTiers.map((tier, i) => { - if (i === index) { - const newDataLimit = formatDataLimit(numValue, tier.unit); - return { - ...tier, - amount: numValue, - data_limit: newDataLimit - }; - } - return tier; - }); - - setCurrentTiers(newTiers); - onChange(newTiers.map(({ data_limit, price }) => ({ data_limit, price }))); - }; - - // Fixed type signature for Select's onChange - const handleUpdateTierUnit = (index: number, value: unknown) => { - const unit = value as 'MB' | 'GB'; - - const newTiers = currentTiers.map((tier, i) => { - if (i === index) { - const newDataLimit = formatDataLimit(tier.amount, unit); - return { - ...tier, - unit, - data_limit: newDataLimit - }; - } - return tier; - }); - - setCurrentTiers(newTiers); - onChange(newTiers.map(({ data_limit, price }) => ({ data_limit, price }))); - }; - - const addTier = () => { - if (currentTiers.length < 3) { - const newTier = { - data_limit: '1 GB per month', - price: '10000', - amount: 1, - unit: 'GB' as 'MB' | 'GB' - }; - const updatedTiers = [...currentTiers, newTier]; - setCurrentTiers(updatedTiers); - onChange(updatedTiers.map(({ data_limit, price }) => ({ data_limit, price }))); - } - }; - - const removeTier = (index: number) => { - const newTiers = currentTiers.filter((_, i) => i !== index); - setCurrentTiers(newTiers); - onChange(newTiers.map(({ data_limit, price }) => ({ data_limit, price }))); - }; - - const toggleFreeTier = (checked: boolean) => { - onFreeTierChange(checked, checked ? freeTierLimit : '100 MB per month'); - }; - - // Fixed type signature for InputNumber's onChange - const updateFreeTierAmount = (value: string | number | null) => { - if (value === null) return; - - const numValue = typeof value === 'string' ? parseInt(value, 10) : value; - setFreeTierAmount(numValue); - const newLimit = formatDataLimit(numValue, freeTierUnit); - onFreeTierChange(freeTierEnabled, newLimit); - }; - - // Fixed type signature for Select's onChange - const updateFreeTierUnit = (value: unknown) => { - const unit = value as 'MB' | 'GB'; - setFreeTierUnit(unit); - const newLimit = formatDataLimit(freeTierAmount, unit); - onFreeTierChange(freeTierEnabled, newLimit); - }; - - return ( -
- - - - Include Free Tier - - - - - - - {freeTierEnabled && ( - - -
- Free Tier - Basic -
-
- -
- - - - Data Limit - - - } - /> - - - - - - - - Price - - } - /> - -
-
- )} - - {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/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..f686bfe --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts @@ -0,0 +1,66 @@ +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(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; + } +`; + +interface ModeButtonProps { + $isActive: boolean; + $color: string; +} + +export const ModeButton = styled(Button)` + 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; + 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: 70px; + } +`; + +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..5cfa794 --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -0,0 +1,84 @@ +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 = { + 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: 'Public Relay', + subtitle: '[Free]', + description: 'Open access with optional free tiers', + color: '#1890ff' + }, + paid: { + label: 'Subscription', + subtitle: '[Paid]', + description: 'Subscription-based access control', + color: '#52c41a' + } +}; + +export const ModeSelector: React.FC = ({ + currentMode, + onModeChange, + disabled = false +}) => { + return ( + + + {(['personal', 'exclusive', 'free', 'paid'] 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}
+
{info.subtitle}
+
+
+
+ ); + })} +
+ + + + {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..5365a00 --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts @@ -0,0 +1,120 @@ +import styled from 'styled-components'; +import { Switch } from 'antd'; +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; + } + } + } +`; + +export const StyledSwitch = styled(Switch)` + &.ant-switch { + /* When switch is OFF (unchecked) */ + background-color: #434343 !important; + border: 1px solid #666 !important; + + /* When switch is ON (checked) */ + &.ant-switch-checked { + background-color: var(--primary-color) !important; + border: 1px solid var(--primary-color) !important; + } + + /* Handle styling */ + .ant-switch-handle { + background-color: #fff !important; + border: 1px solid #d9d9d9; + + &::before { + background-color: #fff !important; + } + } + + /* Disabled state */ + &.ant-switch-disabled { + background-color: #2a2a2a !important; + border: 1px solid #444 !important; + opacity: 0.6; + + .ant-switch-handle { + background-color: #666 !important; + } + } + + /* Loading state */ + &.ant-switch-loading { + background-color: #434343 !important; + border: 1px solid #666 !important; + + &.ant-switch-checked { + background-color: var(--primary-color) !important; + opacity: 0.7; + } + } + } +`; + +export const PermissionLabel = styled.div` + display: flex; + align-items: center; + gap: 8px; + color: var(--text-main-color); + font-size: 14px; +`; \ 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..ca9465f --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -0,0 +1,398 @@ +import React, { useState, useEffect } from 'react'; +import { Button, Input, Table, Space, Modal, Form, Select, 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; + 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 [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(); + + // 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 => { + 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 { + const values = await addForm.validateFields(); + + // 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) { + // Form validation failed or API error + } + }; + + 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'); + return; + } + + const lines = bulkText.split('\n').filter(line => line.trim()); + const defaultTier = settings.tiers[0]?.name || 'basic'; + + 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'); + } + }; + + const handleExport = () => { + 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 = 'allowed-users.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: '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', + key: 'added_at', + render: (date: string) => new Date(date).toLocaleDateString() + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: UnifiedUser) => ( + handleRemoveUser(record.npub)} + > + + + + + + + `Total ${total} users` + }} + rowKey="npub" + /> + + {/* Add User 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/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.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..37d3601 --- /dev/null +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -0,0 +1,325 @@ +import React, { useState } from 'react'; +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 { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; + onSettingsChange: (settings: AllowedUsersSettings) => void; + disabled?: boolean; +} + +// Remove old form interface - using TierEditor now + +export const TiersConfig: React.FC = ({ + settings, + mode, + onSettingsChange, + disabled = false +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [currentTier, setCurrentTier] = useState(null); + + const isPaidMode = mode === 'paid'; + const isFreeMode = mode === 'free'; + + const handleFreeTierChange = (tierName: string) => { + const updatedTiers = settings.tiers.map(tier => ({ + ...tier, + active: tier.name === tierName + })); + + const updatedSettings = { + ...settings, + tiers: updatedTiers + }; + + onSettingsChange(updatedSettings); + }; + + const handleAddTier = () => { + setEditingIndex(null); + setCurrentTier({ + name: '', + price_sats: isFreeMode ? 0 : 1000, + monthly_limit_bytes: 1073741824, // 1 GB default + unlimited: false + }); + setIsModalVisible(true); + }; + + const handleEditTier = (index: number) => { + setEditingIndex(index); + setCurrentTier({ ...settings.tiers[index] }); + setIsModalVisible(true); + }; + + const handleDeleteTier = (index: number) => { + const newTiers = settings.tiers.filter((_, i) => i !== index); + const updatedSettings = { + ...settings, + tiers: newTiers + }; + onSettingsChange(updatedSettings); + }; + + const handleTierChange = (updatedTier: AllowedUsersTier) => { + setCurrentTier(updatedTier); + }; + + const handleModalOk = () => { + if (!currentTier) return; + + // Validate for paid mode + if (isPaidMode && currentTier.price_sats === 0) { + return; // TierEditor should show validation error + } + + // Force price to 0 for free mode + const finalTier = { + ...currentTier, + price_sats: isFreeMode ? 0 : currentTier.price_sats + }; + + 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); + setCurrentTier(null); + setEditingIndex(null); + }; + + const columns = [ + { + title: 'Tier Name', + dataIndex: 'name', + key: 'name', + render: (name: string) => {name} + }, + { + title: 'Data Limit', + 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_sats', + key: 'price_sats', + render: (priceSats: number) => ( + + {priceSats === 0 ? 'Free' : `${priceSats} sats`} + + ) + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, __: any, index: number) => ( + + + )} + + + {isFreeMode ? ( + tier.active)?.name} + onChange={(e) => handleFreeTierChange(e.target.value)} + disabled={disabled} + > + + {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 + + + + ); + })} + + + ) : ( +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + )} + + + {currentTier && ( + + )} + + {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..7c30bc3 --- /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 hasValidName = tier.name && tier.name.trim() !== ''; + + if (targetMode === 'paid') { + // Paid mode requires at least one tier with non-zero price + return hasValidName && tier.price_sats > 0; + } else if (targetMode === 'free') { + // Free mode should have price 0 + return hasValidName && tier.price_sats === 0; + } else if (targetMode === 'exclusive') { + // Exclusive mode can have any price + return hasValidName; + } + + return hasValidName; + }); + }; + + // 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/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/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index c192c42..8457973 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -10,12 +10,10 @@ import { ActivityStory } from '@app/components/relay-dashboard/transactions/Tran import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { NetworkSection } from '@app/components/relay-settings/sections/NetworkSection'; import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBucketsSection'; -import { SubscriptionSection } from '@app/components/relay-settings/sections/SubscriptionSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; import { useTranslation } from 'react-i18next'; -import { SubscriptionTier } from '@app/constants/relaySettings'; interface DesktopLayoutProps { mode: string; @@ -34,12 +32,6 @@ interface DesktopLayoutProps { onDynamicAppBucketsChange: (values: string[]) => 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[]; @@ -54,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; @@ -91,12 +89,6 @@ export const DesktopLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -142,13 +134,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[]; @@ -51,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; @@ -88,12 +86,6 @@ export const MobileLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -137,13 +129,6 @@ export const MobileLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - 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; }; } @@ -94,6 +101,14 @@ export const MediaSection: React.FC = ({ isActive={photos.isActive} mode={mode} /> + @@ -111,6 +126,14 @@ export const MediaSection: React.FC = ({ isActive={videos.isActive} mode={mode} /> + @@ -128,6 +151,14 @@ export const MediaSection: React.FC = ({ isActive={audio.isActive} mode={mode} /> + diff --git a/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx b/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx new file mode 100644 index 0000000..0ed4695 --- /dev/null +++ b/src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx @@ -0,0 +1,120 @@ +// src/components/relay-settings/sections/MediaSection/components/FileSizeLimitInput.tsx + +import React from 'react'; +import { InputNumber } from 'antd'; +import styled from 'styled-components'; + +const StyledContainer = styled.div` + 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: 13px; + font-weight: 500; + color: var(--text-main-color); + flex: 1; + min-width: 0; +`; + +const StyledInputWrapper = styled.div` + display: flex; + align-items: center; + gap: 6px; + + .ant-input-number { + width: 80px; + height: 28px; + background: var(--background-color); + border-color: var(--border-color); + border-radius: 4px; + + &:hover { + border-color: var(--primary-color); + } + + &:focus-within { + border-color: var(--primary-color); + 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); + } + } + } +`; + +const StyledUnit = styled.span` + font-size: 12px; + color: var(--text-light-color); + font-weight: 500; + min-width: 20px; +`; + +export interface FileSizeLimitInputProps { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; +} + +export const FileSizeLimitInput: React.FC = ({ + label, + value, + onChange, + min = 1, + max = 5000, + step = 1, + disabled = false, +}) => { + const handleChange = (newValue: number | null) => { + if (newValue !== null && newValue >= min && newValue <= max) { + onChange(newValue); + } + }; + + return ( + + {label} + + + MB + + + ); +}; + +export default FileSizeLimitInput; \ No newline at end of file diff --git a/src/components/relay-settings/sections/SubscriptionSection/SubscriptionSection.tsx b/src/components/relay-settings/sections/SubscriptionSection/SubscriptionSection.tsx deleted file mode 100644 index 6276c93..0000000 --- a/src/components/relay-settings/sections/SubscriptionSection/SubscriptionSection.tsx +++ /dev/null @@ -1,44 +0,0 @@ -// src/components/relay-settings/sections/SubscriptionSection/SubscriptionSection.tsx - -import React from 'react'; -import { Collapse } from 'antd'; -import styled from 'styled-components'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; -import SubscriptionTiersManager from '@app/components/SubscriptionTiersManager'; -import { SubscriptionTier } from '@app/constants/relaySettings'; - -const StyledPanel = styled(Collapse.Panel)``; - -interface SubscriptionSectionProps { - tiers: SubscriptionTier[]; - onChange: (tiers: SubscriptionTier[]) => 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/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/components/settings/ImageModerationSettings.tsx b/src/components/settings/ImageModerationSettings.tsx index 7039e5b..7bd2012 100644 --- a/src/components/settings/ImageModerationSettings.tsx +++ b/src/components/settings/ImageModerationSettings.tsx @@ -25,39 +25,15 @@ const ImageModerationSettings: React.FC = () => { if (settings && !isUserEditing) { console.log('ImageModerationSettings - Received settings:', settings); - // Transform property names to match form field names - // The API returns properties without the prefix, but the form expects prefixed names + // The useGenericSettings hook now returns properly prefixed field names + // so we can use the settings directly without transformation const settingsObj = settings as Record; - // Log the mode value specifically to debug - console.log('Mode field from settings:', settingsObj.mode); + console.log('ImageModerationSettings - Setting form values directly:', settingsObj); - const formValues = { - image_moderation_api: settingsObj.api, - image_moderation_check_interval: typeof settingsObj.check_interval === 'string' - ? parseFloat(settingsObj.check_interval) - : settingsObj.check_interval, - image_moderation_concurrency: typeof settingsObj.concurrency === 'string' - ? parseFloat(settingsObj.concurrency) - : settingsObj.concurrency, - image_moderation_enabled: settingsObj.enabled, - image_moderation_mode: settingsObj.mode || 'basic', // Default to basic if mode is undefined - image_moderation_temp_dir: settingsObj.temp_dir, - image_moderation_threshold: typeof settingsObj.threshold === 'string' - ? parseFloat(settingsObj.threshold) - : settingsObj.threshold, - image_moderation_timeout: typeof settingsObj.timeout === 'string' - ? parseFloat(settingsObj.timeout) - : settingsObj.timeout - }; - - console.log('ImageModerationSettings - Transformed form values:', formValues); - - // Set form values with a slight delay to ensure the form is ready - setTimeout(() => { - form.setFieldsValue(formValues); - console.log('ImageModerationSettings - Form values after set:', form.getFieldsValue()); - }, 100); + // Set form values directly since they're already properly prefixed + form.setFieldsValue(settingsObj); + console.log('ImageModerationSettings - Form values after set:', form.getFieldsValue()); } }, [settings, form, isUserEditing]); diff --git a/src/components/settings/NestFeederSettings.tsx b/src/components/settings/NestFeederSettings.tsx deleted file mode 100644 index 2b9def3..0000000 --- a/src/components/settings/NestFeederSettings.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Form, Input, InputNumber, Switch, Tooltip } from 'antd'; -import { QuestionCircleOutlined, ApiOutlined } from '@ant-design/icons'; -import useGenericSettings from '@app/hooks/useGenericSettings'; -import { SettingsGroupType } from '@app/types/settings.types'; -import BaseSettingsForm from './BaseSettingsForm'; - -const NestFeederSettings: React.FC = () => { - const { - settings, - loading, - error, - fetchSettings, - updateSettings, - saveSettings, - } = useGenericSettings('nest_feeder'); - - const [form] = Form.useForm(); - const [isUserEditing, setIsUserEditing] = useState(false); - - // Update form values when settings change, but only if user isn't actively editing - useEffect(() => { - if (settings && !isUserEditing) { - console.log('NestFeederSettings - Received settings:', settings); - - // Transform property names to match form field names - const settingsObj = settings as Record; - - const formValues = { - nest_feeder_enabled: settingsObj.enabled, - nest_feeder_url: settingsObj.url, - 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, - nest_feeder_timeout: typeof settingsObj.timeout === 'string' - ? parseInt(settingsObj.timeout) - : settingsObj.timeout, - }; - - console.log('NestFeederSettings - Transformed form values:', formValues); - - // Set form values with a slight delay to ensure the form is ready - setTimeout(() => { - form.setFieldsValue(formValues); - console.log('NestFeederSettings - 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 - updateSettings(changedValues); - }; - - // Modified save function to reset the editing flag - const handleSave = async () => { - await saveSettings(); - setIsUserEditing(false); // Reset after saving - }; - - return ( - { - fetchSettings(); - setIsUserEditing(false); - }} - > - { - console.log('Form submitted with values:', values); - setIsUserEditing(false); - }} - > - - - - - - API Endpoint  - - - - - } - rules={[{ required: true, message: 'Please enter the API endpoint' }]} - > - } - 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/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 = () => { } > - - - - 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/components/settings/panels/WalletPanel.tsx b/src/components/settings/panels/WalletPanel.tsx index 7b4797e..b1b3833 100644 --- a/src/components/settings/panels/WalletPanel.tsx +++ b/src/components/settings/panels/WalletPanel.tsx @@ -37,22 +37,15 @@ const WalletPanel: React.FC = () => { if (settings && !isUserEditing) { console.log('WalletPanel - Received settings:', settings); - // Transform property names to match form field names - // The API returns properties without the prefix, but the form expects prefixed names + // The useGenericSettings hook now returns properly prefixed field names + // so we can use the settings directly without transformation const settingsObj = settings as Record; - const formValues = { - wallet_name: settingsObj.name, - wallet_api_key: settingsObj.api_key - }; + console.log('WalletPanel - Setting form values directly:', settingsObj); - console.log('WalletPanel - Transformed form values:', formValues); - - // Set form values with a slight delay to ensure the form is ready - setTimeout(() => { - form.setFieldsValue(formValues); - console.log('WalletPanel - Form values after set:', form.getFieldsValue()); - }, 100); + // Set form values directly since they're already properly prefixed + form.setFieldsValue(settingsObj); + console.log('WalletPanel - Form values after set:', form.getFieldsValue()); } }, [settings, form, isUserEditing]); 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 ffb96fb..09dd09f 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -15,18 +15,14 @@ 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" + // File size limits in MB + photoMaxSizeMB: number; + videoMaxSizeMB: number; + audioMaxSizeMB: number; } -export type SubscriptionTier = { - data_limit: string; - price: string; -} - -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 }, @@ -51,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' }, @@ -114,8 +116,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/useAllowedUsers.ts b/src/hooks/useAllowedUsers.ts new file mode 100644 index 0000000..cf03a75 --- /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: [ + { name: 'Basic', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false } + ] + }); + } 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_sats === 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/hooks/useChartData.ts b/src/hooks/useChartData.ts index daf94c1..54ed7f8 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; @@ -27,7 +28,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', @@ -43,17 +44,70 @@ const useChartData = () => { throw new Error(`Network response was not ok (status: ${response.status})`); } - const data = await response.json(); + 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 - 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 + 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 = ''; + + // 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 }); + 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); @@ -71,89 +125,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 diff --git a/src/hooks/useGenericSettings.ts b/src/hooks/useGenericSettings.ts index cbef4e9..3eb826c 100644 --- a/src/hooks/useGenericSettings.ts +++ b/src/hooks/useGenericSettings.ts @@ -4,6 +4,333 @@ import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; import { SettingsGroupName, SettingsGroupType } from '@app/types/settings.types'; +// Helper function to extract the correct nested data for each settings group +const extractSettingsForGroup = (settings: any, groupName: string) => { + console.log(`Extracting settings for group: ${groupName}`, settings); + + let rawData: any = {}; + + switch (groupName) { + case 'image_moderation': + rawData = settings?.content_filtering?.image_moderation || {}; + break; + + case 'content_filter': + rawData = settings?.content_filtering?.text_filter || {}; + break; + + + case 'ollama': + rawData = settings?.external_services?.ollama || {}; + break; + + case 'wallet': + rawData = settings?.external_services?.wallet || {}; + break; + + case 'relay_info': + rawData = settings?.relay || {}; + break; + + case 'general': + rawData = settings?.server || {}; + break; + + default: + console.warn(`Unknown settings group: ${groupName}`); + return {}; + } + + // Handle the prefixed field name issue + // The backend returns both prefixed and unprefixed fields, but forms expect prefixed ones + if (groupName === 'image_moderation' && rawData) { + const processedData: any = {}; + + // Map backend fields to prefixed ones that the form expects + // Based on the actual backend response, backend sends both prefixed and unprefixed versions + const imageModerationMappings: Record = { + 'image_moderation_api': ['image_moderation_api'], + 'image_moderation_check_interval': ['image_moderation_check_interval_seconds', 'check_interval_seconds'], + 'image_moderation_concurrency': ['image_moderation_concurrency', 'concurrency'], + 'image_moderation_enabled': ['image_moderation_enabled', 'enabled'], + 'image_moderation_mode': ['image_moderation_mode', 'mode'], + 'image_moderation_temp_dir': ['image_moderation_temp_dir'], + 'image_moderation_threshold': ['image_moderation_threshold', 'threshold'], + 'image_moderation_timeout': ['image_moderation_timeout_seconds', 'timeout_seconds'] + }; + + // Map fields, prioritizing prefixed versions if they exist + Object.entries(imageModerationMappings).forEach(([formField, possibleBackendFields]) => { + for (const backendField of possibleBackendFields) { + if (rawData[backendField] !== undefined) { + processedData[formField] = rawData[backendField]; + break; // Use the first found value + } + } + }); + + console.log(`Processed ${groupName} data:`, processedData); + return processedData; + } + + if (groupName === 'content_filter' && rawData) { + const processedData: any = {}; + + // Handle content filter prefixed fields + const contentFilterMappings: Record = { + 'content_filter_cache_size': 'cache_size', + 'content_filter_cache_ttl': 'cache_ttl_seconds', + 'content_filter_enabled': 'enabled', + 'full_text_kinds': 'full_text_search_kinds' // Special mapping + }; + + // Start with raw data + Object.keys(rawData).forEach(key => { + processedData[key] = rawData[key]; + }); + + // Apply prefixed field mappings + Object.entries(contentFilterMappings).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) { + const processedData: any = {}; + + // Map backend field names to frontend field names + const walletMappings: Record = { + 'wallet_name': 'name', + 'wallet_api_key': 'key' // Backend sends 'key', frontend expects 'wallet_api_key' + }; + + // Apply field mappings + Object.entries(walletMappings).forEach(([frontendKey, backendKey]) => { + if (rawData[backendKey] !== undefined) { + processedData[frontendKey] = rawData[backendKey]; + } + }); + + console.log(`Processed ${groupName} data:`, processedData); + return processedData; + } + + // Handle general settings field name mapping + if (groupName === 'general' && rawData) { + const processedData: any = {}; + + // General settings come from both server and relay sections + // We need to access both sections from the root settings + const relayData = settings?.relay || {}; + + // Map backend field names to frontend field names + // Some fields come from server section, others from relay section + const generalMappings: Record = { + 'port': { section: 'server', field: 'port' }, + 'private_key': { section: 'relay', field: 'private_key' }, + 'service_tag': { section: 'relay', field: 'service_tag' }, + 'relay_stats_db': { section: 'server', field: 'stats_db' }, + 'proxy': { section: 'server', field: 'proxy' }, // May not exist + 'demo_mode': { section: 'server', field: 'demo' }, + 'web': { section: 'server', field: 'web' } + }; + + // Apply field mappings + Object.entries(generalMappings).forEach(([frontendKey, mapping]) => { + const sourceData = mapping.section === 'relay' ? relayData : rawData; + if (sourceData[mapping.field] !== undefined) { + processedData[frontendKey] = sourceData[mapping.field]; + } else { + // Set default values for missing fields + if (frontendKey === 'relay_stats_db') { + processedData[frontendKey] = ''; // Default empty + } else if (frontendKey === 'proxy') { + processedData[frontendKey] = false; // Default false + } + } + }); + + console.log(`Processed ${groupName} data:`, processedData); + return processedData; + } + + // Handle relay info field name mapping + if (groupName === 'relay_info' && rawData) { + const processedData: any = {}; + + // Map backend field names to frontend field names + const relayInfoMappings: Record = { + 'relayname': 'name', + 'relaydescription': 'description', + 'relaycontact': 'contact', + 'relaypubkey': 'public_key', // Backend sends 'public_key' + 'relaydhtkey': 'dht_key', + 'relaysoftware': 'software', + 'relayversion': 'version', + 'relaysupportednips': 'supported_nips' + }; + + // Apply field mappings + Object.entries(relayInfoMappings).forEach(([frontendKey, backendKey]) => { + if (rawData[backendKey] !== undefined) { + processedData[frontendKey] = rawData[backendKey]; + } else { + // Set default values for missing fields + if (frontendKey === 'relaysupportednips') { + processedData[frontendKey] = []; // Default empty array + } + } + }); + + console.log(`Processed ${groupName} data:`, processedData); + return processedData; + } + + return rawData; +}; + +// Helper function to build the nested update structure for the new API +const buildNestedUpdate = (groupName: string, data: any) => { + switch (groupName) { + case 'image_moderation': + return { + settings: { + content_filtering: { + image_moderation: data + } + } + }; + + case 'content_filter': + return { + settings: { + content_filtering: { + text_filter: data + } + } + }; + + + case 'ollama': + return { + settings: { + external_services: { + ollama: data + } + } + }; + + case 'wallet': + // Reverse the field mapping for saving + const backendWalletData: any = {}; + const walletFieldMappings: Record = { + 'name': 'wallet_name', + 'key': 'wallet_api_key' + }; + + Object.entries(walletFieldMappings).forEach(([backendKey, frontendKey]) => { + if (data[frontendKey] !== undefined) { + backendWalletData[backendKey] = data[frontendKey]; + } + }); + + return { + settings: { + external_services: { + wallet: backendWalletData + } + } + }; + + case 'relay_info': + // Reverse the field mapping for saving + const backendRelayData: any = {}; + const relayFieldMappings: Record = { + 'name': 'relayname', + 'description': 'relaydescription', + 'contact': 'relaycontact', + 'public_key': 'relaypubkey', // Frontend 'relaypubkey' -> backend 'public_key' + 'dht_key': 'relaydhtkey', + 'software': 'relaysoftware', + 'version': 'relayversion', + 'supported_nips': 'relaysupportednips' + }; + + Object.entries(relayFieldMappings).forEach(([backendKey, frontendKey]) => { + if (data[frontendKey] !== undefined) { + // Special handling for supported_nips to ensure they're numbers + if (backendKey === 'supported_nips') { + const nips = data[frontendKey]; + if (Array.isArray(nips)) { + backendRelayData[backendKey] = nips.map((nip: any) => Number(nip)).filter((nip: number) => !isNaN(nip)); + } else { + backendRelayData[backendKey] = []; + } + } else { + backendRelayData[backendKey] = data[frontendKey]; + } + } + }); + + return { + settings: { + relay: backendRelayData + } + }; + + case 'general': + // Reverse the field mapping for saving + // General settings need to be split between server and relay sections + const serverData: any = {}; + const relayData: any = {}; + + const generalFieldMappings: Record = { + 'port': { section: 'server', field: 'port' }, + 'private_key': { section: 'relay', field: 'private_key' }, + 'service_tag': { section: 'relay', field: 'service_tag' }, + 'stats_db': { section: 'server', field: 'relay_stats_db' }, + 'proxy': { section: 'server', field: 'proxy' }, + 'demo': { section: 'server', field: 'demo_mode' }, // Frontend 'demo_mode' -> backend 'demo' + 'web': { section: 'server', field: 'web' } + }; + + Object.entries(generalFieldMappings).forEach(([backendField, mapping]) => { + const frontendField = mapping.field; + if (data[frontendField] !== undefined) { + if (mapping.section === 'server') { + serverData[backendField] = data[frontendField]; + } else { + relayData[backendField] = data[frontendField]; + } + } + }); + + // Return nested structure with both server and relay sections + const result: any = { settings: {} }; + if (Object.keys(serverData).length > 0) { + result.settings.server = serverData; + } + if (Object.keys(relayData).length > 0) { + result.settings.relay = relayData; + } + + return result; + + default: + console.warn(`Unknown settings group for save: ${groupName}`); + return { + settings: {} + }; + } +}; + interface UseGenericSettingsResult { settings: T | null; loading: boolean; @@ -35,7 +362,7 @@ const useGenericSettings = ( console.log(`Fetching ${groupName} settings...`); - const response = await fetch(`${config.baseURL}/api/settings/${groupName}`, { + const response = await fetch(`${config.baseURL}/api/settings`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -52,10 +379,10 @@ const useGenericSettings = ( } const data = await response.json(); - console.log(`Raw ${groupName} settings data:`, data); + console.log(`Raw settings data:`, data); - // The API returns data in the format { [groupName]: settings } - const settingsData = data[groupName] as SettingsGroupType; + // Extract the correct nested data based on groupName + const settingsData = extractSettingsForGroup(data.settings, groupName) as SettingsGroupType; if (!settingsData) { console.warn(`No settings data found for group: ${groupName}`); @@ -139,16 +466,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: [ @@ -185,9 +502,9 @@ const useGenericSettings = ( console.log(`Settings from state for ${groupName}:`, settings); const { prefix, formKeys } = prefixedSettingsMap[groupName]; - // First fetch current settings to preserve values not in the form - console.log(`Fetching current ${groupName} settings before saving...`); - const fetchResponse = await fetch(`${config.baseURL}/api/settings/${groupName}`, { + // First fetch complete settings structure to preserve all values + console.log(`Fetching complete settings before saving ${groupName}...`); + const fetchResponse = await fetch(`${config.baseURL}/api/settings`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -198,7 +515,7 @@ const useGenericSettings = ( } const currentData = await fetchResponse.json(); - const currentSettings = currentData[groupName] || {}; + const currentSettings = extractSettingsForGroup(currentData.settings, groupName) || {}; console.log(`Current ${groupName} settings from API:`, currentSettings); // Create a properly prefixed object for the API @@ -231,13 +548,17 @@ const useGenericSettings = ( console.log(`Saving ${groupName} settings:`, dataToSave); - const response = await fetch(`${config.baseURL}/api/settings/${groupName}`, { + // Construct the nested update structure for the new API + const nestedUpdate = buildNestedUpdate(groupName, dataToSave); + console.log(`Nested update structure:`, nestedUpdate); + + const response = await fetch(`${config.baseURL}/api/settings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify({ [groupName]: dataToSave }), + body: JSON.stringify(nestedUpdate), }); if (response.status === 401) { diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 4aa6d01..6f12db6 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -1,47 +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, SubscriptionTier } from '@app/constants/relaySettings'; +import { Settings } from '@app/constants/relaySettings'; +import { CORE_KINDS, ensureCoreKinds } from '@app/constants/coreKinds'; -interface BackendSubscriptionTier { - datalimit: string; - price: string; -} - -interface BackendRelaySettings { - mode: string; - protocol: CheckboxValueType[]; - chunked: CheckboxValueType[]; - 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[]; - videos: string[]; - audio: string[]; - documents: string[]; - }; - MimeTypeWhitelist: string[]; - KindWhitelist: string[]; - 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' } -]; +// 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: [], @@ -55,10 +25,11 @@ 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 + moderationMode: 'strict', // Default to strict mode + // Default file size limits in MB + photoMaxSizeMB: 100, + videoMaxSizeMB: 500, + audioMaxSizeMB: 100 }); const useRelaySettings = () => { @@ -114,89 +85,44 @@ 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', - 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, - 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.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; + + // 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 || {}; + // 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 (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) { + settings.protocol = backendData.event_filtering.protocols.allowed_protocols || ['WebSocket']; + } else { + settings.protocol = ['WebSocket']; + } } - 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 + // Store these as the previous whitelist settings if in whitelist mode + if (settings.mode === 'whitelist') { setPreviousSmartSettings({ kinds: settings.kinds, photos: settings.photos, @@ -210,15 +136,54 @@ 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) => { + // 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"], + 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"], + 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"], + max_size_mb: settings.audioMaxSizeMB // Only send correct field name + } + }; + + 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}`, }, @@ -232,24 +197,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}`); @@ -267,7 +233,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/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", 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/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index 767e29f..88c1f48 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,10 +41,11 @@ 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 + moderationMode: 'strict', // Default to strict mode + // Default file size limits in MB + photoMaxSizeMB: 100, + videoMaxSizeMB: 500, + audioMaxSizeMB: 100 }); // Initialize stored dynamic items @@ -75,17 +76,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 +129,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), ]); @@ -234,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,26 +253,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, @@ -291,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/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..eda582b --- /dev/null +++ b/src/types/allowedUsers.types.ts @@ -0,0 +1,130 @@ +export type AllowedUsersMode = 'free' | 'paid' | 'exclusive' | 'personal'; + +export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; + +export interface AllowedUsersTier { + 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 +} + +// Legacy interface - kept for migration purposes +export interface AllowedUsersTierLegacy { + data_limit: string; + price: string; + active?: boolean; +} + +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' }, + { value: 'all_users', label: 'All Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true + }, + personal: { + readOptions: [ + { value: 'allowed_users', label: 'Only Me' } + ], + writeOptions: [ + { value: 'allowed_users', label: 'Only Me' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true + } +}; + +// Default tier configurations for each mode +export const DEFAULT_TIERS: Record = { + free: [ + { 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: [ + { 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: [ + { 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: [ + { 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/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 diff --git a/src/types/settings.types.ts b/src/types/settings.types.ts index 9cb404c..bb75649 100644 --- a/src/types/settings.types.ts +++ b/src/types/settings.types.ts @@ -93,22 +93,18 @@ export interface QueryCacheSettings { export type SettingsGroupName = | 'image_moderation' | 'content_filter' - | 'nest_feeder' | 'ollama' | 'relay_info' | 'wallet' | 'general' - | 'query_cache' | 'relay_settings'; 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 : T extends 'general' ? GeneralSettings : - T extends 'query_cache' ? QueryCacheSettings : T extends 'relay_settings' ? any : // Using any for relay_settings as it's already defined elsewhere never; 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