From b714f50fa3753524dc1524813bc1c9bf4720ccb7 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:01:03 +0200 Subject: [PATCH 1/5] Add allowed users management with mode-specific tier defaults - Implement free, paid, and exclusive modes with distinct tier configurations - Add mode-specific default tiers (free: 100MB-1GB, paid: 1-10GB with pricing, exclusive: 2-25GB) - Create comprehensive allowed users page with permissions and tier management - Add API integration for allowed users settings and NPUB management - Implement tier validation and automatic price handling per mode - Add navigation and routing for allowed users functionality --- .claude/settings.local.json | 3 + src/api/allowedUsers.api.ts | 241 ++++++++++++++ .../MigrationHelper/MigrationHelper.tsx | 6 + .../ModeSelector/ModeSelector.styles.ts | 60 ++++ .../components/ModeSelector/ModeSelector.tsx | 66 ++++ .../NPubManagement/NPubManagement.styles.ts | 65 ++++ .../NPubManagement/NPubManagement.tsx | 295 ++++++++++++++++++ .../PermissionsConfig.styles.ts | 63 ++++ .../PermissionsConfig/PermissionsConfig.tsx | 155 +++++++++ .../TiersConfig/TiersConfig.styles.ts | 40 +++ .../components/TiersConfig/TiersConfig.tsx | 291 +++++++++++++++++ src/components/allowed-users/index.ts | 6 + .../layouts/AllowedUsersLayout.styles.ts | 67 ++++ .../layouts/AllowedUsersLayout.tsx | 238 ++++++++++++++ .../layouts/main/sider/sidebarNavigation.tsx | 8 +- src/components/router/AppRouter.tsx | 3 + src/hooks/useAllowedUsers.ts | 276 ++++++++++++++++ src/pages/AllowedUsersPage.tsx | 8 + src/store/slices/allowedUsersSlice.ts | 74 +++++ src/store/slices/index.ts | 2 + src/types/allowedUsers.types.ts | 112 +++++++ 21 files changed, 2078 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json create mode 100644 src/api/allowedUsers.api.ts create mode 100644 src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx create mode 100644 src/components/allowed-users/components/ModeSelector/ModeSelector.styles.ts create mode 100644 src/components/allowed-users/components/ModeSelector/ModeSelector.tsx create mode 100644 src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts create mode 100644 src/components/allowed-users/components/NPubManagement/NPubManagement.tsx create mode 100644 src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts create mode 100644 src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx create mode 100644 src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts create mode 100644 src/components/allowed-users/components/TiersConfig/TiersConfig.tsx create mode 100644 src/components/allowed-users/index.ts create mode 100644 src/components/allowed-users/layouts/AllowedUsersLayout.styles.ts create mode 100644 src/components/allowed-users/layouts/AllowedUsersLayout.tsx create mode 100644 src/hooks/useAllowedUsers.ts create mode 100644 src/pages/AllowedUsersPage.tsx create mode 100644 src/store/slices/allowedUsersSlice.ts create mode 100644 src/types/allowedUsers.types.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 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..c87a14a8 --- /dev/null +++ b/src/api/allowedUsers.api.ts @@ -0,0 +1,241 @@ +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; +import { + AllowedUsersSettings, + AllowedUsersApiResponse, + AllowedUsersNpubsResponse, + BulkImportRequest, + AllowedUsersNpub +} from '@app/types/allowedUsers.types'; + +// Settings Management +export const getAllowedUsersSettings = async (): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data: AllowedUsersApiResponse = JSON.parse(text); + + // Transform tiers from backend format to frontend format + const transformedSettings = { + ...data.allowed_users, + tiers: data.allowed_users.tiers.map(tier => ({ + data_limit: (tier as any).datalimit || tier.data_limit || '', + price: tier.price + })) + }; + + return transformedSettings; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + + // Transform to nested format as expected by backend + const nestedSettings = { + "allowed_users": { + "mode": settings.mode, + "read_access": { + "enabled": settings.read_access.enabled, + "scope": settings.read_access.scope + }, + "write_access": { + "enabled": settings.write_access.enabled, + "scope": settings.write_access.scope + }, + "tiers": settings.tiers.map(tier => ({ + "datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values + "price": tier.price || "0" + })) + } + }; + + console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2)); + + const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(nestedSettings), + }); + + const text = await response.text(); + console.log('Backend response:', response.status, text); + + if (!response.ok) { + console.error('Backend error:', response.status, text); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } + + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Read NPUBs Management +export const getReadNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addReadNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeReadNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/read/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Write NPUBs Management +export const getWriteNpubs = async (page = 1, pageSize = 20): Promise => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write?page=${page}&pageSize=${pageSize}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + const data = JSON.parse(text); + // Transform backend response to expected format + return { + npubs: data.npubs || [], + total: data.pagination?.total || 0, + page: data.pagination?.page || page, + pageSize: data.pagination?.pageSize || pageSize + }; + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const addWriteNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ npub, tier }), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +export const removeWriteNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/write/${npub}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; + +// Bulk Import +export const bulkImportNpubs = async (importData: BulkImportRequest): Promise<{ success: boolean, message: string, imported: number, failed: number }> => { + const token = readToken(); + const response = await fetch(`${config.baseURL}/api/allowed-npubs/bulk-import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(importData), + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const text = await response.text(); + try { + return JSON.parse(text); + } catch (jsonError) { + throw new Error(`Invalid JSON response: ${text}`); + } +}; \ No newline at end of file diff --git a/src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx b/src/components/allowed-users/components/MigrationHelper/MigrationHelper.tsx new file mode 100644 index 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..e466b00a --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.styles.ts @@ -0,0 +1,65 @@ +import styled from 'styled-components'; +import { media } from '@app/styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const TabContent = styled.div` + padding: 1rem 0; +`; + +export const TabHeader = styled.div` + margin-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + + ${media.md} { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } +`; + +export const NpubText = styled.code` + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + background: var(--background-color-secondary); + padding: 2px 6px; + border-radius: 4px; + color: var(--text-main-color); +`; + +export const TierTag = styled.span` + background: var(--primary-color); + color: white; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +`; + +export const BulkImportContainer = styled.div` + p { + margin-bottom: 0.5rem; + color: var(--text-main-color); + } + + ul { + margin-bottom: 1rem; + padding-left: 1.5rem; + + li { + margin-bottom: 0.25rem; + color: var(--text-secondary-color); + + code { + background: var(--background-color-secondary); + padding: 1px 4px; + border-radius: 3px; + font-size: 12px; + } + } + } +`; \ No newline at end of file diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx new file mode 100644 index 00000000..98ed2e60 --- /dev/null +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -0,0 +1,295 @@ +import React, { useState } from 'react'; +import { Tabs, Card, Button, Input, Table, Space, Modal, Form, Select, Upload, message, Popconfirm } from 'antd'; +import { PlusOutlined, UploadOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; +import { useAllowedUsersNpubs, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; +import { AllowedUsersSettings, AllowedUsersMode } from '@app/types/allowedUsers.types'; +import * as S from './NPubManagement.styles'; + +interface NPubManagementProps { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; +} + +interface AddNpubFormData { + npub: string; + tier: string; +} + +export const NPubManagement: React.FC = ({ + settings, + mode +}) => { + const [activeTab, setActiveTab] = useState('read'); + const [isAddModalVisible, setIsAddModalVisible] = useState(false); + const [isBulkModalVisible, setIsBulkModalVisible] = useState(false); + const [bulkText, setBulkText] = useState(''); + const [addForm] = Form.useForm(); + + const readNpubs = useAllowedUsersNpubs('read'); + const writeNpubs = useAllowedUsersNpubs('write'); + const { validateNpub } = useAllowedUsersValidation(); + + const currentNpubs = activeTab === 'read' ? readNpubs : writeNpubs; + const tierOptions = settings.tiers.map(tier => ({ + label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, + value: tier.data_limit + })); + + const handleAddNpub = async () => { + try { + const values = await addForm.validateFields(); + await currentNpubs.addNpub(values.npub, values.tier); + setIsAddModalVisible(false); + addForm.resetFields(); + } catch (error) { + // Form validation failed or API error + } + }; + + const handleBulkImport = async () => { + if (!bulkText.trim()) { + message.error('Please enter NPUBs to import'); + return; + } + + const lines = bulkText.split('\n').filter(line => line.trim()); + const npubsData: string[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine.includes(':')) { + // Format: npub:tier + npubsData.push(trimmedLine); + } else { + // Just npub, use first tier as default + const defaultTier = settings.tiers[0]?.data_limit || 'basic'; + npubsData.push(`${trimmedLine}:${defaultTier}`); + } + } + + await currentNpubs.bulkImport(npubsData); + setIsBulkModalVisible(false); + setBulkText(''); + }; + + const handleExport = () => { + const data = currentNpubs.npubs.map(npub => `${npub.npub}:${npub.tier}`).join('\n'); + const blob = new Blob([data], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${activeTab}-npubs.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const columns = [ + { + title: 'NPUB', + dataIndex: 'npub', + key: 'npub', + render: (npub: string) => ( + {npub.slice(0, 16)}...{npub.slice(-8)} + ) + }, + { + title: 'Tier', + dataIndex: 'tier', + key: 'tier', + render: (tier: string) => {tier} + }, + { + title: 'Added', + dataIndex: 'added_at', + key: 'added_at', + render: (date: string) => new Date(date).toLocaleDateString() + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, record: any) => ( + currentNpubs.removeNpub(record.npub)} + > + + + + + + + + + ) + }, + { + key: 'write', + label: `Write Access (${writeNpubs.total})`, + children: ( + + + + + + + + + +
+ + ) + } + ]; + + return ( + + + + {/* Add NPUB Modal */} + { + setIsAddModalVisible(false); + addForm.resetFields(); + }} + destroyOnClose + > +
+ { + const error = validateNpub(value); + return error ? Promise.reject(error) : Promise.resolve(); + }} + ]} + > + + + + + + {modeConfig.readOptions.map(option => ( + + {option.label} + + ))} + + )} + + + + +
+ + Write: + + + {settings.write_access.enabled && ( + + )} + + + + + + + + Read Access: Controls who can read events from your relay + + + Write Access: Controls who can publish events to your relay + + {mode === 'paid' && ( + + Paid Mode: Write access is automatically limited to paid users + + )} + {mode === 'exclusive' && ( + + Exclusive Mode: Write access is automatically limited to allowed users + + )} + + + ); +}; diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts b/src/components/allowed-users/components/TiersConfig/TiersConfig.styles.ts new file mode 100644 index 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..8c6f70e2 --- /dev/null +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -0,0 +1,291 @@ +import React, { useState } from 'react'; +import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; +import * as S from './TiersConfig.styles'; + +interface TiersConfigProps { + settings: AllowedUsersSettings; + mode: AllowedUsersMode; + onSettingsChange: (settings: AllowedUsersSettings) => void; + disabled?: boolean; +} + +interface TierFormData { + data_limit: string; + price: string; +} + +export const TiersConfig: React.FC = ({ + settings, + mode, + onSettingsChange, + disabled = false +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [form] = Form.useForm(); + + const isPaidMode = mode === 'paid'; + const isFreeMode = mode === 'free'; + + const handleAddTier = () => { + setEditingIndex(null); + form.resetFields(); + setIsModalVisible(true); + }; + + const handleEditTier = (index: number) => { + setEditingIndex(index); + const tier = settings.tiers[index]; + form.setFieldsValue({ + data_limit: tier.data_limit, + price: tier.price + }); + setIsModalVisible(true); + }; + + const handleDeleteTier = (index: number) => { + const newTiers = settings.tiers.filter((_, i) => i !== index); + const updatedSettings = { + ...settings, + tiers: newTiers + }; + onSettingsChange(updatedSettings); + }; + + const handleModalOk = async () => { + try { + const values = await form.validateFields(); + + // Validate price for paid mode + if (isPaidMode && values.price === '0') { + form.setFields([{ + name: 'price', + errors: ['Paid mode cannot have free tiers'] + }]); + return; + } + + // Force price to "0" only for free mode, ensure it's always a string + const tierPrice = isFreeMode ? '0' : String(values.price || '0'); + + const newTier: AllowedUsersTier = { + data_limit: values.data_limit, + price: tierPrice + }; + + let newTiers: AllowedUsersTier[]; + if (editingIndex !== null) { + newTiers = [...settings.tiers]; + newTiers[editingIndex] = newTier; + } else { + newTiers = [...settings.tiers, newTier]; + } + + const updatedSettings = { + ...settings, + tiers: newTiers + }; + + onSettingsChange(updatedSettings); + setIsModalVisible(false); + form.resetFields(); + } catch (error) { + // Form validation failed + } + }; + + const handleModalCancel = () => { + setIsModalVisible(false); + form.resetFields(); + setEditingIndex(null); + }; + + const columns = [ + { + title: 'Data Limit', + dataIndex: 'data_limit', + key: 'data_limit', + render: (text: string) => {text} + }, + { + title: 'Price (sats)', + dataIndex: 'price', + key: 'price', + render: (price: string) => ( + + {price === '0' ? 'Free' : `${price} sats`} + + ) + }, + { + title: 'Actions', + key: 'actions', + render: (_: any, __: any, index: number) => ( + + + + +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + + + + + + + + { + if (isPaidMode && value === '0') { + return Promise.reject('Paid mode cannot have free tiers'); + } + return Promise.resolve(); + } + } + ]} + > + + + + {isPaidMode && ( + + )} + + {isFreeMode && ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/src/components/allowed-users/index.ts b/src/components/allowed-users/index.ts new file mode 100644 index 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/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/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/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/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..8a467187 --- /dev/null +++ b/src/types/allowedUsers.types.ts @@ -0,0 +1,112 @@ +export type AllowedUsersMode = 'free' | 'paid' | 'exclusive'; + +export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; + +export interface AllowedUsersTier { + data_limit: string; + price: string; +} + +// Backend expects this format +export interface AllowedUsersTierBackend { + datalimit: string; + price: string; +} + +export interface AllowedUsersAccessConfig { + enabled: boolean; + scope: AccessScope; +} + +export interface AllowedUsersSettings { + mode: AllowedUsersMode; + read_access: AllowedUsersAccessConfig; + write_access: AllowedUsersAccessConfig; + tiers: AllowedUsersTier[]; +} + +export interface AllowedUsersNpub { + npub: string; + tier: string; + added_at: string; +} + +export interface AllowedUsersNpubsResponse { + npubs: AllowedUsersNpub[]; + total: number; + page: number; + pageSize: number; +} + +export interface BulkImportRequest { + type: 'read' | 'write'; + npubs: string[]; // Format: "npub1...:tier" +} + +export interface AllowedUsersApiResponse { + allowed_users: AllowedUsersSettings; +} + +// Mode-specific option configurations +export interface ModeOptions { + readOptions: { value: AccessScope; label: string }[]; + writeOptions: { value: AccessScope; label: string }[]; + allowsFreeTiers: boolean; + requiresNpubManagement: boolean; +} + +export const MODE_CONFIGURATIONS: Record = { + free: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + writeOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'allowed_users', label: 'Allowed Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: false + }, + paid: { + readOptions: [ + { value: 'all_users', label: 'All Users' }, + { value: 'paid_users', label: 'Paid Users' } + ], + writeOptions: [ + { value: 'paid_users', label: 'Paid Users' } + ], + allowsFreeTiers: false, + requiresNpubManagement: false + }, + exclusive: { + readOptions: [ + { value: 'allowed_users', label: 'Allowed Users' }, + { value: 'all_users', label: 'All Users' } + ], + writeOptions: [ + { value: 'allowed_users', label: 'Allowed Users' } + ], + allowsFreeTiers: true, + requiresNpubManagement: true + } +}; + +// Default tier configurations for each mode +export const DEFAULT_TIERS: Record = { + free: [ + { data_limit: '100 MB per month', price: '0' }, + { data_limit: '500 MB per month', price: '0' }, + { data_limit: '1 GB per month', price: '0' } + ], + paid: [ + { data_limit: '1 GB per month', price: '1000' }, + { data_limit: '5 GB per month', price: '5000' }, + { data_limit: '10 GB per month', price: '10000' } + ], + exclusive: [ + { data_limit: '5 GB per month', price: '0' }, + { data_limit: '50 GB per month', price: '0' }, + { data_limit: 'unlimited', price: '0' } + ] +}; \ No newline at end of file From ad7f481c8dfc3970d481e0f6625d5b25bdaf5bd0 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:17:21 +0200 Subject: [PATCH 2/5] Update alert styling to use consistent dark theme colors - Apply dark background (#25284B) with light text (#d9d9d9) for better readability - Maintain visual consistency across all alert components - Improve contrast and accessibility for notification messages --- .../PermissionsConfig/PermissionsConfig.tsx | 7 ++++- .../components/TiersConfig/TiersConfig.tsx | 29 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx index 35b3deaa..02315b7e 100644 --- a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx +++ b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx @@ -72,7 +72,12 @@ export const PermissionsConfig: React.FC = ({ description="Your relay is configured to allow read access to all users. This means anyone can read events from your relay." type="warning" showIcon - style={{ marginBottom: '1rem' }} + style={{ + marginBottom: '1rem', + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' + }} /> )} diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index 8c6f70e2..baef2b41 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -157,9 +157,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #ffd666 !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -172,9 +172,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #52c41a !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -187,9 +187,9 @@ export const TiersConfig: React.FC = ({ showIcon style={{ marginBottom: '1rem', - backgroundColor: '#ffffff !important', - border: '1px solid #1890ff !important', - color: '#1f1f1f !important' + backgroundColor: '#25284B', + border: '1px solid #d9d9d9', + color: '#d9d9d9' }} /> )} @@ -269,6 +269,11 @@ export const TiersConfig: React.FC = ({ message="Note: Free tiers (price = 0) are not allowed in paid mode" type="warning" showIcon + style={{ + backgroundColor: '#fafafa', + border: '1px solid #d9d9d9', + color: '#262626' + }} /> )} @@ -278,9 +283,9 @@ export const TiersConfig: React.FC = ({ type="success" showIcon style={{ - backgroundColor: '#ffffff !important', - border: '1px solid #52c41a !important', - color: '#1f1f1f !important' + backgroundColor: '#fafafa', + border: '1px solid #d9d9d9', + color: '#262626' }} /> )} From 32ed6c8aef111661946565f0c61b9771f5eb5b31 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Wed, 18 Jun 2025 14:50:32 +0200 Subject: [PATCH 3/5] Redesign user management with unified interface and toggle controls - Replace separate read/write tabs with single unified table - Add read/write access toggle switches for instant permission management - Enhance add user modal with permission selection toggles - Improve bulk import to support permission flags (r/w/rw) - Update export format to include permission information - Streamline user experience - manage all permissions from one view --- .../NPubManagement/NPubManagement.tsx | 353 +++++++++++------- 1 file changed, 223 insertions(+), 130 deletions(-) diff --git a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx index 98ed2e60..9821d4a6 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Tabs, Card, Button, Input, Table, Space, Modal, Form, Select, Upload, message, Popconfirm } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { Button, Input, Table, Space, Modal, Form, Select, message, Popconfirm, Switch } from 'antd'; import { PlusOutlined, UploadOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; import { useAllowedUsersNpubs, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; import { AllowedUsersSettings, AllowedUsersMode } from '@app/types/allowedUsers.types'; @@ -13,23 +13,65 @@ interface NPubManagementProps { interface AddNpubFormData { npub: string; tier: string; + readAccess: boolean; + writeAccess: boolean; +} + +interface UnifiedUser { + npub: string; + tier: string; + readAccess: boolean; + writeAccess: boolean; + added_at: string; } export const NPubManagement: React.FC = ({ settings, mode }) => { - const [activeTab, setActiveTab] = useState('read'); const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isBulkModalVisible, setIsBulkModalVisible] = useState(false); const [bulkText, setBulkText] = useState(''); + const [unifiedUsers, setUnifiedUsers] = useState([]); const [addForm] = Form.useForm(); const readNpubs = useAllowedUsersNpubs('read'); const writeNpubs = useAllowedUsersNpubs('write'); const { validateNpub } = useAllowedUsersValidation(); - const currentNpubs = activeTab === 'read' ? readNpubs : writeNpubs; + // Merge read and write NPUBs into unified list + useEffect(() => { + const allNpubs = new Map(); + + // Add read NPUBs + readNpubs.npubs.forEach(npub => { + allNpubs.set(npub.npub, { + npub: npub.npub, + tier: npub.tier, + readAccess: true, + writeAccess: false, + added_at: npub.added_at + }); + }); + + // Add write NPUBs (merge with existing or create new) + writeNpubs.npubs.forEach(npub => { + const existing = allNpubs.get(npub.npub); + if (existing) { + existing.writeAccess = true; + } else { + allNpubs.set(npub.npub, { + npub: npub.npub, + tier: npub.tier, + readAccess: false, + writeAccess: true, + added_at: npub.added_at + }); + } + }); + + setUnifiedUsers(Array.from(allNpubs.values())); + }, [readNpubs.npubs, writeNpubs.npubs]); const tierOptions = settings.tiers.map(tier => ({ label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`, value: tier.data_limit @@ -38,7 +80,17 @@ export const NPubManagement: React.FC = ({ const handleAddNpub = async () => { try { const values = await addForm.validateFields(); - await currentNpubs.addNpub(values.npub, values.tier); + + // Add to read list if read access is enabled + if (values.readAccess) { + await readNpubs.addNpub(values.npub, values.tier); + } + + // Add to write list if write access is enabled + if (values.writeAccess) { + await writeNpubs.addNpub(values.npub, values.tier); + } + setIsAddModalVisible(false); addForm.resetFields(); } catch (error) { @@ -46,6 +98,41 @@ export const NPubManagement: React.FC = ({ } }; + const handleToggleAccess = async (npub: string, type: 'read' | 'write', enabled: boolean) => { + const user = unifiedUsers.find(u => u.npub === npub); + if (!user) return; + + try { + if (type === 'read') { + if (enabled) { + await readNpubs.addNpub(npub, user.tier); + } else { + await readNpubs.removeNpub(npub); + } + } else { + if (enabled) { + await writeNpubs.addNpub(npub, user.tier); + } else { + await writeNpubs.removeNpub(npub); + } + } + } catch (error) { + message.error(`Failed to update ${type} access`); + } + }; + + const handleRemoveUser = async (npub: string) => { + try { + // Remove from both lists + await Promise.all([ + readNpubs.removeNpub(npub).catch(() => {}), // Ignore errors if not in list + writeNpubs.removeNpub(npub).catch(() => {}) // Ignore errors if not in list + ]); + } catch (error) { + message.error('Failed to remove user'); + } + }; + const handleBulkImport = async () => { if (!bulkText.trim()) { message.error('Please enter NPUBs to import'); @@ -53,32 +140,60 @@ export const NPubManagement: React.FC = ({ } const lines = bulkText.split('\n').filter(line => line.trim()); - const npubsData: string[] = []; + const defaultTier = settings.tiers[0]?.data_limit || 'basic'; - for (const line of lines) { - const trimmedLine = line.trim(); - if (trimmedLine.includes(':')) { - // Format: npub:tier - npubsData.push(trimmedLine); - } else { - // Just npub, use first tier as default - const defaultTier = settings.tiers[0]?.data_limit || 'basic'; - npubsData.push(`${trimmedLine}:${defaultTier}`); + try { + for (const line of lines) { + const trimmedLine = line.trim(); + const parts = trimmedLine.split(':'); + + const npub = parts[0]; + const tier = parts[1] || defaultTier; + const permissions = parts[2] || 'r'; // default to read only + + const hasReadAccess = permissions.includes('r'); + const hasWriteAccess = permissions.includes('w'); + + // Add to read list if read access + if (hasReadAccess) { + try { + await readNpubs.addNpub(npub, tier); + } catch (error) { + // Might already exist, continue + } + } + + // Add to write list if write access + if (hasWriteAccess) { + try { + await writeNpubs.addNpub(npub, tier); + } catch (error) { + // Might already exist, continue + } + } } + + message.success('Bulk import completed'); + setIsBulkModalVisible(false); + setBulkText(''); + } catch (error) { + message.error('Bulk import failed'); } - - await currentNpubs.bulkImport(npubsData); - setIsBulkModalVisible(false); - setBulkText(''); }; const handleExport = () => { - const data = currentNpubs.npubs.map(npub => `${npub.npub}:${npub.tier}`).join('\n'); + const data = unifiedUsers.map(user => { + let permissions = ''; + if (user.readAccess) permissions += 'r'; + if (user.writeAccess) permissions += 'w'; + return `${user.npub}:${user.tier}:${permissions}`; + }).join('\n'); + const blob = new Blob([data], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${activeTab}-npubs.txt`; + a.download = 'allowed-users.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -100,6 +215,32 @@ export const NPubManagement: React.FC = ({ key: 'tier', render: (tier: string) => {tier} }, + { + title: 'Read Access', + key: 'readAccess', + align: 'center' as const, + render: (_: any, record: UnifiedUser) => ( + handleToggleAccess(record.npub, 'read', checked)} + loading={readNpubs.loading} + size="small" + /> + ) + }, + { + title: 'Write Access', + key: 'writeAccess', + align: 'center' as const, + render: (_: any, record: UnifiedUser) => ( + handleToggleAccess(record.npub, 'write', checked)} + loading={writeNpubs.loading} + size="small" + /> + ) + }, { title: 'Added', dataIndex: 'added_at', @@ -109,10 +250,10 @@ export const NPubManagement: React.FC = ({ { title: 'Actions', key: 'actions', - render: (_: any, record: any) => ( + render: (_: any, record: UnifiedUser) => ( currentNpubs.removeNpub(record.npub)} + title="Are you sure you want to remove this user completely?" + onConfirm={() => handleRemoveUser(record.npub)} > - - - - - -
- - ) - }, - { - key: 'write', - label: `Write Access (${writeNpubs.total})`, - children: ( - - - - - - - - - -
- - ) - } - ]; - return ( - + + + + + + + +
`Total ${total} users` + }} + rowKey="npub" /> - {/* Add NPUB Modal */} + {/* Add User Modal */} { @@ -239,7 +316,7 @@ export const NPubManagement: React.FC = ({ }} destroyOnClose > -
+ = ({ > } - /> - - - - )} - - {currentTiers.map((tier, index) => { - // Determine tier title based on index - const tierTitles = ['Standard', 'Premium', 'Professional']; - const tierTitle = tierTitles[index] || `Tier ${index + 1}`; - - return ( - - -
- {tierTitle} Tier - {index === 1 && Popular} -
- removeTier(index)}> - - Remove - -
- -
- - - - Data Limit - - - handleUpdateTierAmount(index, value)} - prefix={} - /> - handleUpdateTierUnit(index, value)} - options={[ - { value: 'MB', label: 'MB' }, - { value: 'GB', label: 'GB' } - ]} - /> - - - - - - - Price (sats) - - handleUpdateTierPrice(index, e.target.value)} - placeholder="Price in sats" - style={{ - width: '100%', - backgroundColor: '#1b1b38', - borderColor: '#313131', - color: 'white', - height: '48px', - borderRadius: '8px' - }} - prefix={} - /> - -
-
- ); - })} - - = 3} - > - - Add Tier - - - - - - Configure subscription tiers to define data limits and pricing for your relay service. - {freeTierEnabled && " A free tier can help attract new users to your service."} - - - - ); -}; - -export default SubscriptionTiersManager; diff --git a/src/components/SubscriptionTiersManager/index.ts b/src/components/SubscriptionTiersManager/index.ts deleted file mode 100644 index 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/NPubManagement/NPubManagement.tsx b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx index af9e03c3..1720347e 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -125,8 +125,8 @@ export const NPubManagement: React.FC = ({ try { // Remove from both lists await Promise.all([ - readNpubs.removeNpub(npub).catch(() => {}), // Ignore errors if not in list - writeNpubs.removeNpub(npub).catch(() => {}) // Ignore errors if not in list + readNpubs.removeNpub(npub).catch(() => { /* Ignore errors if not in list */ }), + writeNpubs.removeNpub(npub).catch(() => { /* Ignore errors if not in list */ }) ]); } catch (error) { message.error('Failed to remove user'); diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index baef2b41..21c20db7 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert } from 'antd'; +import { Button, Input, Table, Space, Modal, Form, InputNumber, Popconfirm, Alert, Radio, Card } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { AllowedUsersSettings, AllowedUsersMode, AllowedUsersTier } from '@app/types/allowedUsers.types'; import * as S from './TiersConfig.styles'; @@ -29,6 +29,20 @@ export const TiersConfig: React.FC = ({ const isPaidMode = mode === 'paid'; const isFreeMode = mode === 'free'; + const handleFreeTierChange = (dataLimit: string) => { + const updatedTiers = settings.tiers.map(tier => ({ + ...tier, + active: tier.data_limit === dataLimit + })); + + const updatedSettings = { + ...settings, + tiers: updatedTiers + }; + + onSettingsChange(updatedSettings); + }; + const handleAddTier = () => { setEditingIndex(null); form.resetFields(); @@ -195,24 +209,57 @@ export const TiersConfig: React.FC = ({ )} - Subscription Tiers - + + {isFreeMode ? 'Free Tier Selection' : 'Subscription Tiers'} + + {!isFreeMode && ( + + )} -
({ ...tier, key: index }))} - pagination={false} - size="small" - locale={{ emptyText: 'No tiers configured' }} - /> + {isFreeMode ? ( + tier.active)?.data_limit} + onChange={(e) => handleFreeTierChange(e.target.value)} + disabled={disabled} + > + + {settings.tiers.map((tier, index) => ( + !disabled && handleFreeTierChange(tier.data_limit)} + > + + + {tier.data_limit} + Free + + + + ))} + + + ) : ( +
({ ...tier, key: index }))} + pagination={false} + size="small" + locale={{ emptyText: 'No tiers configured' }} + /> + )} void; onAddBucket: (bucket: string) => void; onRemoveBucket: (bucket: string) => void; - // Subscription section props - subscriptionTiers: SubscriptionTier[]; - onSubscriptionChange: (newTiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean, - freeTierLimit: string, - onFreeTierChange: (enabled: boolean, limit: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -91,12 +83,6 @@ export const DesktopLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -142,13 +128,6 @@ export const DesktopLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; onAddBucket: (bucket: string) => void; onRemoveBucket: (bucket: string) => void; - // Subscription section props - subscriptionTiers: SubscriptionTier[]; - onSubscriptionChange: (newTiers: SubscriptionTier[]) => void; - freeTierEnabled: boolean, - freeTierLimit: string, - onFreeTierChange: (enabled: boolean, limit: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -88,12 +80,6 @@ export const MobileLayout: React.FC = ({ onDynamicAppBucketsChange, onAddBucket, onRemoveBucket, - // Subscription props - subscriptionTiers, - onSubscriptionChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, // Kinds props isKindsActive, selectedKinds, @@ -137,13 +123,6 @@ export const MobileLayout: React.FC = ({ onRemoveBucket={onRemoveBucket} /> - void; - freeTierEnabled: boolean; - freeTierLimit: string; - onFreeTierChange: (enabled: boolean, limit: string) => void; -} - -export const SubscriptionSection: React.FC = ({ - tiers, - onChange, - freeTierEnabled, - freeTierLimit, - onFreeTierChange, -}) => { - return ( - - - - - - - - ); -}; - -export default SubscriptionSection; \ No newline at end of file diff --git a/src/components/relay-settings/sections/SubscriptionSection/index.ts b/src/components/relay-settings/sections/SubscriptionSection/index.ts deleted file mode 100644 index 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/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/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/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/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts index 951c160c..0aeafeee 100644 --- a/src/types/allowedUsers.types.ts +++ b/src/types/allowedUsers.types.ts @@ -5,6 +5,7 @@ export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; export interface AllowedUsersTier { data_limit: string; price: string; + active?: boolean; // For free mode - only one tier can be active at a time } // Backend expects this format @@ -96,9 +97,9 @@ export const MODE_CONFIGURATIONS: Record = { // Default tier configurations for each mode export const DEFAULT_TIERS: Record = { free: [ - { data_limit: '100 MB per month', price: '0' }, - { data_limit: '500 MB per month', price: '0' }, - { data_limit: '1 GB per month', price: '0' } + { data_limit: '100 MB per month', price: '0', active: false }, + { data_limit: '500 MB per month', price: '0', active: true }, // Default active tier + { data_limit: '1 GB per month', price: '0', active: false } ], paid: [ { data_limit: '1 GB per month', price: '1000' },