diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..e8f289d7 --- /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 00000000..27ef765d --- /dev/null +++ b/src/api/allowedUsers.api.ts @@ -0,0 +1,258 @@ +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; +import { + AllowedUsersSettings, + AllowedUsersApiResponse, + AllowedUsersNpubsResponse, + BulkImportRequest, + AllowedUsersNpub, + DEFAULT_TIERS +} from '@app/types/allowedUsers.types'; + +// Settings Management +export const getAllowedUsersSettings = async (): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data: AllowedUsersApiResponse = JSON.parse(text); + + // Transform tiers from backend format to frontend format + let transformedTiers = data.allowed_users.tiers.map(tier => ({ + data_limit: (tier as any).datalimit || tier.data_limit || '', + price: tier.price + })); + + // For free mode, reconstruct full UI options with active tier marked + if (data.allowed_users.mode === 'free' && transformedTiers.length === 1) { + const activeTierDataLimit = transformedTiers[0].data_limit; + transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({ + ...defaultTier, + active: defaultTier.data_limit === activeTierDataLimit + })); + } + + const transformedSettings = { + ...data.allowed_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 mode, only send active tier + const tiersToSend = settings.mode === 'free' + ? settings.tiers.filter(tier => tier.active) + : settings.tiers; + + // Transform to nested format as expected by backend + const nestedSettings = { + "allowed_users": { + "mode": settings.mode, + "read_access": { + "enabled": settings.read_access.enabled, + "scope": settings.read_access.scope + }, + "write_access": { + "enabled": settings.write_access.enabled, + "scope": settings.write_access.scope + }, + "tiers": tiersToSend.map(tier => ({ + "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values + "price": tier.price || "0" + })) + } + }; + + console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2)); + + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(nestedSettings), + }); + + const text = await response.text(); + console.log('Backend response:', response.status, text); + + if (!response.ok) { + console.error('Backend error:', response.status, text); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } + + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Read NPUBs Management +export const getReadNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addReadNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeReadNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Write NPUBs Management +export const getWriteNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addWriteNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeWriteNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Bulk Import +export const bulkImportNpubs = async (importData: BulkImportRequest): Promise<{ success: boolean, message: string, imported: number, failed: number }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/bulk-import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(importData), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; \ No newline at end of file diff --git a/src/components/SubscriptionTiersManager/SubscriptionTiersManager.tsx b/src/components/SubscriptionTiersManager/SubscriptionTiersManager.tsx deleted file mode 100644 index d5d08d90..00000000 --- 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 66b1992e..00000000 --- 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 00000000..92ceea43 --- /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 00000000..191eaf7c --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts @@ -0,0 +1,60 @@ +import styled from 'styled-components'; +import { Button } from 'antd'; +import { media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const ModeGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; + + ${media.md} { + grid-template-columns: 1fr; + gap: 0.75rem; + } +`; + +interface ModeButtonProps { + $isActive: boolean; + $color: string; +} + +export const ModeButton = styled(Button)` + height: 60px; + border-radius: 8px; + font-weight: 600; + transition: all 0.3s ease; + + ${({ $isActive, $color }) => $isActive && ` + background-color: ${$color} !important; + border-color: ${$color} !important; + box-shadow: 0 4px 12px ${$color}33; + `} + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + } + + ${media.md} { + height: 50px; + } +`; + +export const ModeDescription = styled.div` + padding: 1rem; + background: var(--background-color-secondary); + border-radius: 8px; + border: 1px solid var(--border-color-base); +`; + +export const DescriptionText = styled.p` + margin: 0; + color: var(--text-main-color); + font-size: 14px; + line-height: 1.5; +`; \ No newline at end of file diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx new file mode 100644 index 00000000..58f3047c --- /dev/null +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Button, Space, Tooltip } from 'antd'; +import { AllowedUsersMode } from '@app/types/allowedUsers.types'; +import * as S from './ModeSelector.styles'; + +interface ModeSelectorProps { + currentMode: AllowedUsersMode; + onModeChange: (mode: AllowedUsersMode) => void; + disabled?: boolean; +} + +const MODE_INFO = { + free: { + label: 'Free Mode', + description: 'Open access with optional free tiers', + color: '#1890ff' + }, + paid: { + label: 'Paid Mode', + description: 'Subscription-based access control', + color: '#52c41a' + }, + exclusive: { + label: 'Exclusive Mode', + description: 'Invite-only access with manual NPUB management', + color: '#722ed1' + } +}; + +export const ModeSelector: React.FC = ({ + currentMode, + onModeChange, + disabled = false +}) => { + return ( + + + {(Object.keys(MODE_INFO) as AllowedUsersMode[]).map((mode) => { + const info = MODE_INFO[mode]; + const isActive = currentMode === mode; + + return ( + + onModeChange(mode)} + disabled={disabled} + $isActive={isActive} + $color={info.color} + > + {info.label} + + + ); + })} + + + + + {MODE_INFO[currentMode].label}: {MODE_INFO[currentMode].description} + + + + ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts b/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts new file mode 100644 index 00000000..5365a00a --- /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 00000000..1720347e --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -0,0 +1,392 @@ +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 => ({ + label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, + value: tier.data_limit + })); + + 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]?.data_limit || '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/TiersConfig/TiersConfig.styles.ts b/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts new file mode 100644 index 00000000..a278f933 --- /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 00000000..21c20db7 --- /dev/null +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -0,0 +1,343 @@ +import React, { useState } from 'react'; +import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert, Radio, Card } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; +import * as S from './TiersConfig.styles'; + +interface TiersConfigProps { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; + onSettingsChange: (settings: AllowedUsersSettings) => void; + disabled?: boolean; +} + +interface TierFormData { + data_limit: string; + price: string; +} + +export const TiersConfig: React.FC = ({ + settings, + mode, + onSettingsChange, + disabled = false +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [form] = Form.useForm(); + + const isPaidMode = mode === 'paid'; + const isFreeMode = mode === 'free'; + + const handleFreeTierChange = (dataLimit: string) => { + const updatedTiers = settings.tiers.map(tier => ({ + ...tier, + active: tier.data_limit === dataLimit + })); + + const updatedSettings = { + ...settings, + tiers: updatedTiers + }; + + onSettingsChange(updatedSettings); + }; + + const handleAddTier = () => { + setEditingIndex(null); + form.resetFields(); + setIsModalVisible(true); + }; + + const handleEditTier = (index: number) => { + setEditingIndex(index); + const tier = settings.tiers[index]; + form.setFieldsValue({ + data_limit: tier.data_limit, + price: tier.price + }); + setIsModalVisible(true); + }; + + const handleDeleteTier = (index: number) => { + const newTiers = settings.tiers.filter((_, i) => i !== index); + const updatedSettings = { + ...settings, + tiers: newTiers + }; + onSettingsChange(updatedSettings); + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + + // Validate price for paid mode + if (isPaidMode && values.price === '0') { + form.setFields([{ + name: 'price', + errors: ['Paid mode cannot have free tiers'] + }]); + return; + } + + // Force price to "0" only for free mode, ensure it's always a string + const tierPrice = isFreeMode ? '0' : String(values.price || '0'); + + const newTier: AllowedUsersTier = { + data_limit: values.data_limit, + price: tierPrice + }; + + let newTiers: AllowedUsersTier[]; + if (editingIndex !== null) { + newTiers = [...settings.tiers]; + newTiers[editingIndex] = newTier; + } else { + newTiers = [...settings.tiers, newTier]; + } + + const updatedSettings = { + ...settings, + tiers: newTiers + }; + + onSettingsChange(updatedSettings); + setIsModalVisible(false); + form.resetFields(); + } catch (error) { + // Form validation failed + } + }; + + const handleModalCancel = () => { + setIsModalVisible(false); + form.resetFields(); + setEditingIndex(null); + }; + + const columns = [ + { + title: 'Data Limit', + dataIndex: 'data_limit', + key: 'data_limit', + render: (text: string) => {text} + }, + { + title: 'Price (sats)', + dataIndex: 'price', + key: 'price', + render: (price: string) => ( + + {price === '0' ? 'Free' : `${price} sats`} + + ) + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, __: any, index: number) => ( + + + )} + + + {isFreeMode ? ( + tier.active)?.data_limit} + onChange={(e) => handleFreeTierChange(e.target.value)} + disabled={disabled} + > + + {settings.tiers.map((tier, index) => ( + !disabled && handleFreeTierChange(tier.data_limit)} + > + + + {tier.data_limit} + Free + + + + ))} + + + ) : ( +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + )} + + + + + + + + { + if (isPaidMode && value === '0') { + return Promise.reject('Paid mode cannot have free tiers'); + } + return Promise.resolve(); + } + } + ]} + > + + + + {isPaidMode && ( + + )} + + {isFreeMode && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/index.ts b/src/components/allowed-users/index.ts new file mode 100644 index 00000000..da437b84 --- /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 00000000..42ed95f7 --- /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 00000000..215246e4 --- /dev/null +++ b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { Card, Row, Col, Spin, Alert, Button, Space } from 'antd'; +import { SaveOutlined } from '@ant-design/icons'; +import { useAllowedUsersSettings } from '@app/hooks/useAllowedUsers'; +import { ModeSelector } from '../components/ModeSelector/ModeSelector'; +import { PermissionsConfig } from '../components/PermissionsConfig/PermissionsConfig'; +import { TiersConfig } from '../components/TiersConfig/TiersConfig'; +import { NPubManagement } from '../components/NPubManagement/NPubManagement'; +import { AllowedUsersMode, MODE_CONFIGURATIONS, AllowedUsersSettings, DEFAULT_TIERS } from '@app/types/allowedUsers.types'; +import * as S from './AllowedUsersLayout.styles'; + +export const AllowedUsersLayout: React.FC = () => { + const { settings, loading, error, updateSettings } = useAllowedUsersSettings(); + const [currentMode, setCurrentMode] = useState('free'); + const [localSettings, setLocalSettings] = useState(null); + const [hasChanges, setHasChanges] = useState(false); + const [saving, setSaving] = useState(false); + + React.useEffect(() => { + if (settings) { + setCurrentMode(settings.mode); + setLocalSettings(settings); + setHasChanges(false); + } + }, [settings]); + + const handleModeChange = (mode: AllowedUsersMode) => { + if (!localSettings) return; + + const modeConfig = MODE_CONFIGURATIONS[mode]; + + // Use mode-specific default tiers or existing tiers if they're compatible + let tiers = localSettings.tiers; + + // Check if current tiers are compatible with the new mode + const isCompatibleTiers = (currentTiers: typeof tiers, targetMode: AllowedUsersMode): boolean => { + if (currentTiers.length === 0) return false; + + return currentTiers.every(tier => { + const hasValidDataLimit = tier.data_limit && tier.data_limit.trim() !== ''; + + if (targetMode === 'paid') { + // Paid mode requires at least one tier with non-zero price + return hasValidDataLimit && tier.price && tier.price !== '0'; + } else if (targetMode === 'free') { + // Free mode should have price "0" + return hasValidDataLimit && tier.price === '0'; + } else if (targetMode === 'exclusive') { + // Exclusive mode can have any price + return hasValidDataLimit; + } + + return hasValidDataLimit; + }); + }; + + // Each mode should use its own defaults when switching modes + // Only preserve existing tiers if we're already in the target mode (backend data) + const currentMode = localSettings.mode; + + if (currentMode === mode) { + // We're already in this mode (from backend), keep existing tiers if compatible + if (isCompatibleTiers(localSettings.tiers, mode)) { + tiers = localSettings.tiers; + if (mode === 'free') { + // Ensure all prices are "0" for free mode + tiers = tiers.map(tier => ({ + ...tier, + price: '0' + })); + } + } else { + // Backend data isn't compatible with mode, use defaults + tiers = DEFAULT_TIERS[mode]; + } + } else { + // Switching between different modes, always use mode-specific defaults + tiers = DEFAULT_TIERS[mode]; + } + + const updatedSettings = { + ...localSettings, + mode, + tiers, + // Adjust scopes based on mode constraints + read_access: { + ...localSettings.read_access, + scope: modeConfig.readOptions[0].value // Default to first available option + }, + write_access: { + ...localSettings.write_access, + scope: modeConfig.writeOptions[0].value // Default to first available option + } + }; + + setLocalSettings(updatedSettings); + setCurrentMode(mode); + setHasChanges(true); + }; + + const handleSettingsUpdate = (newSettings: AllowedUsersSettings) => { + setLocalSettings(newSettings); + setHasChanges(true); + }; + + const handleSave = async () => { + if (!localSettings) return; + + setSaving(true); + try { + await updateSettings(localSettings); + setHasChanges(false); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (settings) { + setLocalSettings(settings); + setCurrentMode(settings.mode); + setHasChanges(false); + } + }; + + if (loading && !settings) { + return ( + + + + ); + } + + if (error && !settings) { + return ( + + + + ); + } + + if (!settings || !localSettings) { + return null; + } + + const modeConfig = MODE_CONFIGURATIONS[currentMode]; + const showNpubManagement = modeConfig.requiresNpubManagement || + (localSettings.read_access.scope === 'allowed_users' || localSettings.write_access.scope === 'allowed_users'); + const showTiers = currentMode === 'paid' || currentMode === 'free' || currentMode === 'exclusive'; + + return ( + + + H.O.R.N.E.T Allowed Users + Centralized user permission management + + + + + + + + + + + + + + + + {showTiers && ( + + + + + + )} + + {showNpubManagement && ( + + + + + + )} + + + + + + + {hasChanges && ( + + You have unsaved changes + + )} + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/layouts/main/sider/sidebarNavigation.tsx b/src/components/layouts/main/sider/sidebarNavigation.tsx index a6179169..76c897d4 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-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index c192c42a..016e65ae 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[]; @@ -91,12 +83,6 @@ export const DesktopLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -142,13 +128,6 @@ export const DesktopLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; onAddBucket: (bucket: string) => void; onRemoveBucket: (bucket: string) => void; - // Subscription section props - subscriptionTiers: SubscriptionTier[]; - onSubscriptionChange: (newTiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean, - freeTierLimit: string, - onFreeTierChange: (enabled: boolean, limit: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -88,12 +80,6 @@ export const MobileLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -137,13 +123,6 @@ export const MobileLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; - freeTierEnabled: boolean; - freeTierLimit: string; - onFreeTierChange: (enabled: boolean, limit: string) => void; -} - -export const SubscriptionSection: React.FC = ({ - tiers, - onChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, -}) => { - return ( - - - - - - - - ); -}; - -export default SubscriptionSection; \ No newline at end of file diff --git a/src/components/relay-settings/sections/SubscriptionSection/index.ts b/src/components/relay-settings/sections/SubscriptionSection/index.ts deleted file mode 100644 index 4c5ca3d6..00000000 --- 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 b9e4d616..64afd016 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/constants/relaySettings.ts b/src/constants/relaySettings.ts index ffb96fb2..c642feb3 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -15,17 +15,9 @@ export type Settings = { isGitNestrActive: boolean; isAudioActive: boolean; isFileStorageActive: boolean; - subscription_tiers: SubscriptionTier[]; - freeTierEnabled: boolean; // New field - freeTierLimit: string; // New field - e.g. "100 MB per month moderationMode: string; // "strict" or "passive" } -export type SubscriptionTier = { - data_limit: string; - price: string; -} - export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'appBuckets' | 'dynamicAppBuckets'; export const noteOptions = [ { kind: 0, kindString: 'kind0', description: 'Metadata', category: 1 }, @@ -114,8 +106,3 @@ export const mimeTypeOptions: FormatOption[] = [ { value: 'audio/midi', label: 'MIDI Audio' }, ]; -export const defaultTiers: SubscriptionTier[] = [ - { data_limit: '1 GB per month', price: '8000' }, - { data_limit: '5 GB per month', price: '10000' }, - { data_limit: '10 GB per month', price: '15000' } -]; diff --git a/src/hooks/useAllowedUsers.ts b/src/hooks/useAllowedUsers.ts new file mode 100644 index 00000000..d1bbc171 --- /dev/null +++ b/src/hooks/useAllowedUsers.ts @@ -0,0 +1,276 @@ +import { useState, useEffect, useCallback } from 'react'; +import { message } from 'antd'; +import { + getAllowedUsersSettings, + updateAllowedUsersSettings, + getReadNpubs, + getWriteNpubs, + addReadNpub, + addWriteNpub, + removeReadNpub, + removeWriteNpub, + bulkImportNpubs +} from '@app/api/allowedUsers.api'; +import { + AllowedUsersSettings, + AllowedUsersNpub, + AllowedUsersMode, + BulkImportRequest +} from '@app/types/allowedUsers.types'; + +export const useAllowedUsersSettings = () => { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchSettings = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getAllowedUsersSettings(); + setSettings(data); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch settings'; + setError(errorMessage); + + // Don't show error message if it's just that the endpoint doesn't exist yet + if (!errorMessage.includes('404') && !errorMessage.includes('not valid JSON')) { + message.error(errorMessage); + } + + // Set default settings if API is not available yet + setSettings({ + mode: 'free', + read_access: { + enabled: true, + scope: 'all_users' + }, + write_access: { + enabled: true, + scope: 'all_users' + }, + tiers: [ + { data_limit: '1 GB per month', price: '0' } + ] + }); + } finally { + setLoading(false); + } + }, []); + + const updateSettings = useCallback(async (newSettings: AllowedUsersSettings) => { + setLoading(true); + setError(null); + try { + const result = await updateAllowedUsersSettings(newSettings); + if (result.success) { + setSettings(newSettings); + message.success('Settings updated successfully'); + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to update settings'; + setError(errorMessage); + if (errorMessage.includes('access control not initialized')) { + message.error('Please restart the relay after configuration changes'); + } else { + message.error(errorMessage); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + return { + settings, + loading, + error, + updateSettings, + refetch: fetchSettings + }; +}; + +export const useAllowedUsersNpubs = (type: 'read' | 'write') => { + const [npubs, setNpubs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + + const fetchNpubs = useCallback(async (pageNum: number = page) => { + setLoading(true); + setError(null); + try { + const data = type === 'read' + ? await getReadNpubs(pageNum, pageSize) + : await getWriteNpubs(pageNum, pageSize); + + setNpubs(data.npubs); + setTotal(data.total); + setPage(data.page); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to fetch ${type} NPUBs`; + setError(errorMessage); + + // Don't show error message if it's just that the endpoint doesn't exist yet + if (!errorMessage.includes('404') && !errorMessage.includes('not valid JSON')) { + message.error(errorMessage); + } + + // Set empty data if API is not available yet + setNpubs([]); + setTotal(0); + setPage(1); + } finally { + setLoading(false); + } + }, [type, page, pageSize]); + + const addNpub = useCallback(async (npub: string, tier: string) => { + setLoading(true); + try { + const result = type === 'read' + ? await addReadNpub(npub, tier) + : await addWriteNpub(npub, tier); + + if (result.success) { + message.success(`NPUB added to ${type} list successfully`); + await fetchNpubs(1); // Refresh the list + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to add NPUB to ${type} list`; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, fetchNpubs]); + + const removeNpub = useCallback(async (npub: string) => { + setLoading(true); + try { + const result = type === 'read' + ? await removeReadNpub(npub) + : await removeWriteNpub(npub); + + if (result.success) { + message.success(`NPUB removed from ${type} list successfully`); + await fetchNpubs(page); // Refresh current page + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : `Failed to remove NPUB from ${type} list`; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, page, fetchNpubs]); + + const bulkImport = useCallback(async (npubsData: string[]) => { + setLoading(true); + try { + const importData: BulkImportRequest = { + type, + npubs: npubsData + }; + + const result = await bulkImportNpubs(importData); + if (result.success) { + message.success(`Bulk import completed: ${result.imported} imported, ${result.failed} failed`); + await fetchNpubs(1); // Refresh the list + } else { + throw new Error(result.message); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Bulk import failed'; + message.error(errorMessage); + } finally { + setLoading(false); + } + }, [type, fetchNpubs]); + + const changePage = useCallback((newPage: number) => { + setPage(newPage); + fetchNpubs(newPage); + }, [fetchNpubs]); + + useEffect(() => { + fetchNpubs(); + }, [fetchNpubs]); + + return { + npubs, + total, + loading, + error, + page, + pageSize, + addNpub, + removeNpub, + bulkImport, + changePage, + refetch: fetchNpubs + }; +}; + +// Validation hook +export const useAllowedUsersValidation = () => { + const validateSettings = useCallback((settings: AllowedUsersSettings): string[] => { + const errors: string[] = []; + + // Mode validation + if (!['free', 'paid', 'exclusive'].includes(settings.mode)) { + errors.push('Invalid mode selected'); + } + + // Tier validation + if (settings.mode === 'paid' && settings.tiers.some(t => t.price === '0')) { + errors.push('Paid mode cannot have free tiers'); + } + + // Scope validation + if (settings.mode === 'paid' && settings.write_access.scope !== 'paid_users') { + errors.push('Paid mode write access must be limited to paid users'); + } + + if (settings.mode === 'exclusive' && settings.write_access.scope !== 'allowed_users') { + errors.push('Exclusive mode write access must be limited to allowed users'); + } + + // Tiers validation + if (settings.tiers.length === 0) { + errors.push('At least one tier must be configured'); + } + + return errors; + }, []); + + const validateNpub = useCallback((npub: string): string | null => { + if (!npub.trim()) { + return 'NPUB cannot be empty'; + } + + if (!npub.startsWith('npub1')) { + return 'NPUB must start with "npub1"'; + } + + if (npub.length !== 63) { + return 'NPUB must be 63 characters long'; + } + + return null; + }, []); + + return { + validateSettings, + validateNpub + }; +}; \ No newline at end of file diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 4aa6d015..8dfdd583 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -3,12 +3,7 @@ import { CheckboxValueType } from 'antd/es/checkbox/Group'; import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; -import { Settings, noteOptions, mimeTypeOptions, SubscriptionTier } from '@app/constants/relaySettings'; - -interface BackendSubscriptionTier { - datalimit: string; - price: string; -} +import { Settings, noteOptions, mimeTypeOptions } from '@app/constants/relaySettings'; interface BackendRelaySettings { mode: string; @@ -17,9 +12,6 @@ interface BackendRelaySettings { chunksize: string; maxFileSize: number; maxFileSizeUnit: string; - subscription_tiers: BackendSubscriptionTier[]; - freeTierEnabled: boolean; // New field - freeTierLimit: string; // New field - e.g. "100 MB per month" moderationMode: string; // "strict" or "passive" MimeTypeGroups: { images: string[]; @@ -32,12 +24,6 @@ interface BackendRelaySettings { isFileStorageActive?: boolean; } -const defaultTiers: SubscriptionTier[] = [ - { data_limit: '1 GB per month', price: '8000' }, - { data_limit: '5 GB per month', price: '10000' }, - { data_limit: '10 GB per month', price: '15000' } -]; - const getInitialSettings = (): Settings => ({ mode: 'whitelist', protocol: ['WebSocket'], @@ -55,9 +41,6 @@ const getInitialSettings = (): Settings => ({ isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - subscription_tiers: defaultTiers, - freeTierEnabled: false, - freeTierLimit: '100 MB per month', moderationMode: 'strict' // Default to strict mode }); @@ -135,12 +118,6 @@ const useRelaySettings = () => { chunksize: '2', maxFileSize: 10, maxFileSizeUnit: 'MB', - subscription_tiers: settings.subscription_tiers.map(tier => ({ - datalimit: tier.data_limit, - price: tier.price - })), - freeTierEnabled: settings.freeTierEnabled, - freeTierLimit: settings.freeTierLimit, moderationMode: settings.moderationMode, MimeTypeGroups: mimeGroups, isFileStorageActive: settings.isFileStorageActive, @@ -162,27 +139,8 @@ const useRelaySettings = () => { const settings = getInitialSettings(); settings.mode = backendSettings.mode; settings.protocol = backendSettings.protocol as string[]; - settings.freeTierEnabled = backendSettings.freeTierEnabled ?? false; - settings.freeTierLimit = backendSettings.freeTierLimit ?? '100 MB per month'; settings.moderationMode = backendSettings.moderationMode ?? 'strict'; - // Handle subscription tiers - if (Array.isArray(backendSettings.subscription_tiers)) { - settings.subscription_tiers = backendSettings.subscription_tiers.map(tier => ({ - data_limit: tier.datalimit, - price: tier.price - })); - console.log('Transformed tiers:', settings.subscription_tiers); - } else { - console.log('No backend tiers, using defaults'); - settings.subscription_tiers = defaultTiers; - } - - if (!settings.subscription_tiers.length || - settings.subscription_tiers.every(tier => !tier.data_limit)) { - settings.subscription_tiers = defaultTiers; - } - if (backendSettings.mode === 'blacklist') { // In blacklist mode, start with empty selections settings.photos = []; diff --git a/src/pages/AllowedUsersPage.tsx b/src/pages/AllowedUsersPage.tsx new file mode 100644 index 00000000..b44366f6 --- /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 767e29f3..e4b70a89 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -10,7 +10,7 @@ import { useResponsive } from '@app/hooks/useResponsive'; import useRelaySettings from '@app/hooks/useRelaySettings'; import { DesktopLayout } from '@app/components/relay-settings/layouts/DesktopLayout'; import { MobileLayout } from '@app/components/relay-settings/layouts/MobileLayout'; -import { Settings, Category, defaultTiers, SubscriptionTier } from '@app/constants/relaySettings'; +import { Settings, Category } from '@app/constants/relaySettings'; const RelaySettingsPage: React.FC = () => { const { t } = useTranslation(); @@ -41,9 +41,6 @@ const RelaySettingsPage: React.FC = () => { isGitNestrActive: true, isAudioActive: true, isFileStorageActive: false, - subscription_tiers: [], - freeTierEnabled: false, - freeTierLimit: '100 MB per month', moderationMode: 'strict' // Default to strict mode }); @@ -75,17 +72,9 @@ const RelaySettingsPage: React.FC = () => { if (relaySettings) { console.log('Raw relay settings:', relaySettings); // For debugging - // Only set defaults if there are no tiers or if they are invalid - const tiers = Array.isArray(relaySettings.subscription_tiers) && - relaySettings.subscription_tiers.length > 0 && - relaySettings.subscription_tiers.every(tier => tier.data_limit && tier.price) - ? relaySettings.subscription_tiers - : defaultTiers; - setSettings(prev => ({ ...relaySettings, - protocol: Array.isArray(relaySettings.protocol) ? relaySettings.protocol : [relaySettings.protocol], - subscription_tiers: tiers + protocol: Array.isArray(relaySettings.protocol) ? relaySettings.protocol : [relaySettings.protocol] })); setDynamicAppBuckets(relaySettings.dynamicAppBuckets); } @@ -136,9 +125,6 @@ const RelaySettingsPage: React.FC = () => { updateSettings('isFileStorageActive', settings.isFileStorageActive), updateSettings('appBuckets', settings.appBuckets), updateSettings('dynamicAppBuckets', settings.dynamicAppBuckets), - updateSettings('freeTierEnabled', settings.freeTierEnabled), - updateSettings('freeTierLimit', settings.freeTierLimit), - updateSettings('subscription_tiers', settings.subscription_tiers), updateSettings('moderationMode', settings.moderationMode), ]); @@ -257,26 +243,6 @@ const RelaySettingsPage: React.FC = () => { onDynamicAppBucketsChange: handleDynamicAppBucketsChange, onAddBucket: handleAddBucket, onRemoveBucket: handleRemoveBucket, - // Subscription props - subscriptionTiers: settings.subscription_tiers || defaultTiers, - freeTierEnabled: settings.freeTierEnabled, - freeTierLimit: settings.freeTierLimit, - onSubscriptionChange: (tiers: SubscriptionTier[]) => { - setSettings(prev => ({ - ...prev, - subscription_tiers: tiers - })); - updateSettings('subscription_tiers', tiers); - }, - onFreeTierChange: (enabled: boolean, limit: string) => { // Combined function - setSettings(prev => ({ - ...prev, - freeTierEnabled: enabled, - freeTierLimit: limit - })); - updateSettings('freeTierEnabled', enabled); - updateSettings('freeTierLimit', limit); - }, // Kinds props isKindsActive: settings.isKindsActive, selectedKinds: settings.kinds, diff --git a/src/store/slices/allowedUsersSlice.ts b/src/store/slices/allowedUsersSlice.ts new file mode 100644 index 00000000..1c587a3a --- /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 b5a73b91..d7f35d1e 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 00000000..0aeafeee --- /dev/null +++ b/src/types/allowedUsers.types.ts @@ -0,0 +1,114 @@ +export type AllowedUsersMode = 'free' | 'paid' | 'exclusive'; + +export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; + +export interface AllowedUsersTier { + data_limit: string; + price: string; + active?: boolean; // For free mode - only one tier can be active at a time +} + +// Backend expects this format +export interface AllowedUsersTierBackend { + datalimit: string; + price: string; +} + +export interface AllowedUsersAccessConfig { + enabled: boolean; + scope: AccessScope; +} + +export interface AllowedUsersSettings { + mode: AllowedUsersMode; + read_access: AllowedUsersAccessConfig; + write_access: AllowedUsersAccessConfig; + tiers: AllowedUsersTier[]; +} + +export interface AllowedUsersNpub { + npub: string; + tier: string; + added_at: string; +} + +export interface AllowedUsersNpubsResponse { + npubs: AllowedUsersNpub[]; + total: number; + page: number; + pageSize: number; +} + +export interface BulkImportRequest { + type: 'read' | 'write'; + npubs: string[]; // Format: "npub1...:tier" +} + +export interface AllowedUsersApiResponse { + allowed_users: AllowedUsersSettings; +} + +// Mode-specific option configurations +export interface ModeOptions { + readOptions: { value: AccessScope; label: string }[]; + writeOptions: { value: AccessScope; label: string }[]; + allowsFreeTiers: boolean; + requiresNpubManagement: boolean; +} + +export const MODE_CONFIGURATIONS: Record = { + free: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + writeOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: false + }, + paid: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'paid_users', label: 'Paid Users' } + ], + writeOptions: [ + { value: 'paid_users', label: 'Paid Users' } + ], + allowsFreeTiers: false, + requiresNpubManagement: false + }, + exclusive: { + readOptions: [ + { value: 'allowed_users', label: 'Allowed Users' }, + { value: 'all_users', label: 'All Users' } + ], + writeOptions: [ + { value: 'allowed_users', label: 'Allowed Users' }, + { value: 'all_users', label: 'All Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true + } +}; + +// Default tier configurations for each mode +export const DEFAULT_TIERS: Record = { + free: [ + { data_limit: '100 MB per month', price: '0', active: false }, + { data_limit: '500 MB per month', price: '0', active: true }, // Default active tier + { data_limit: '1 GB per month', price: '0', active: false } + ], + paid: [ + { data_limit: '1 GB per month', price: '1000' }, + { data_limit: '5 GB per month', price: '5000' }, + { data_limit: '10 GB per month', price: '10000' } + ], + exclusive: [ + { data_limit: '5 GB per month', price: '0' }, + { data_limit: '50 GB per month', price: '0' }, + { data_limit: 'unlimited', price: '0' } + ] +}; \ No newline at end of file