diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..d74c264c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,9 @@ +build/ +dist/ +node_modules/ +coverage/ +public/themes/ +*.min.js +*.config.js +craco.config.js +reportWebVitals.ts \ No newline at end of file diff --git a/package.json b/package.json index 77bf5625..06905474 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "build": "NODE_OPTIONS=--openssl-legacy-provider yarn buildThemes && NODE_OPTIONS=--openssl-legacy-provider craco build", "test": "NODE_OPTIONS=--openssl-legacy-provider craco test", "eject": "NODE_OPTIONS=--openssl-legacy-provider craco eject", - "lint": "eslint \"*/**/*.{js,ts,tsx}\" --fix", + "lint": "eslint \"src/**/*.{js,ts,tsx}\" --fix", "lint:styles": "stylelint '*/**/*.{js,ts,tsx}'", "prepare": "husky install", "update-browserslist": "npx update-browserslist-db@latest", diff --git a/src/api/allowedUsers.api.ts b/src/api/allowedUsers.api.ts index 9e2ff455..099c7637 100644 --- a/src/api/allowedUsers.api.ts +++ b/src/api/allowedUsers.api.ts @@ -2,12 +2,14 @@ import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { AllowedUsersSettings, - AllowedUsersApiResponse, - AllowedUsersNpubsResponse, - BulkImportRequest, - AllowedUsersNpub, + AllowedUsersResponse, + AddAllowedUserRequest, + RemoveAllowedUserRequest, + ApiResponse, AllowedUsersMode, - DEFAULT_TIERS + DEFAULT_TIERS, + RelayOwnerResponse, + SetRelayOwnerRequest } from '@app/types/allowedUsers.types'; // Settings Management @@ -45,28 +47,15 @@ export const getAllowedUsersSettings = async (): Promise = } else { // Use default tiers for the mode if none provided const mode = allowedUsersData.mode as AllowedUsersMode; - transformedTiers = DEFAULT_TIERS[mode] || DEFAULT_TIERS.free; - } - - // For free mode, reconstruct full UI options with active tier marked - if (allowedUsersData.mode === 'free' && transformedTiers.length === 1) { - const activeTierBytes = transformedTiers[0].monthly_limit_bytes; - transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({ - ...defaultTier, - active: defaultTier.monthly_limit_bytes === activeTierBytes - })); - } - - // For personal mode, reconstruct with single unlimited tier - if (allowedUsersData.mode === 'personal' && transformedTiers.length === 1) { - transformedTiers = DEFAULT_TIERS.personal; + transformedTiers = DEFAULT_TIERS[mode] || DEFAULT_TIERS['public']; } const transformedSettings = { - mode: allowedUsersData.mode || 'free', - read_access: allowedUsersData.read_access || { enabled: true, scope: 'all_users' }, - write_access: allowedUsersData.write_access || { enabled: true, scope: 'all_users' }, - tiers: transformedTiers + mode: allowedUsersData.mode || 'public', + read: allowedUsersData.read || 'all_users', + write: allowedUsersData.write || 'all_users', + tiers: transformedTiers, + relay_owner_npub: allowedUsersData.relay_owner_npub || '' }; return transformedSettings; @@ -75,37 +64,46 @@ export const getAllowedUsersSettings = async (): Promise = } }; -export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise<{ success: boolean, message: string }> => { +export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise => { const token = readToken(); - // Filter tiers based on mode - for free and personal modes, only send active tier - const tiersToSend = (settings.mode === 'free' || settings.mode === 'personal') - ? settings.tiers.filter(tier => tier.active) - : settings.tiers; - // Transform to nested format as expected by new unified backend API + // Note: relay_owner_npub is no longer sent in settings - it's managed via /api/allowed-users const nestedSettings = { "settings": { "allowed_users": { "mode": settings.mode, - "read_access": { - "enabled": settings.read_access.enabled, - "scope": settings.read_access.scope - }, - "write_access": { - "enabled": settings.write_access.enabled, - "scope": settings.write_access.scope - }, - "tiers": tiersToSend.map(tier => ({ - "name": tier.name, - "price_sats": tier.price_sats, - "monthly_limit_bytes": tier.monthly_limit_bytes, - "unlimited": tier.unlimited - })) + "read": settings.read, + "write": settings.write, + "tiers": settings.mode === 'public' + ? settings.tiers.filter(tier => tier.active).map(tier => ({ + "name": tier.name, + "price_sats": tier.price_sats, + "monthly_limit_bytes": tier.monthly_limit_bytes, + "unlimited": tier.unlimited + })) + : settings.tiers.map(tier => ({ + "name": tier.name, + "price_sats": tier.price_sats, + "monthly_limit_bytes": tier.monthly_limit_bytes, + "unlimited": tier.unlimited + })) } } }; + // Comprehensive logging for debugging + console.group('🔧 [API] Updating Allowed Users Settings'); + console.log('📤 Original frontend settings:', settings); + console.log('đŸ“Ļ Transformed payload for backend:', nestedSettings); + console.log('đŸŽ¯ Mode being sent:', settings.mode); + console.log('📖 Read permission:', settings.read); + console.log('âœī¸ Write permission:', settings.write); + console.log('đŸˇī¸ Number of tiers:', settings.tiers.length); + console.log('📋 Tiers details:', settings.tiers); + console.log('🌐 Request URL:', `${config.baseURL}/api/settings`); + console.log('📄 Request body (stringified):', JSON.stringify(nestedSettings, null, 2)); + console.groupEnd(); const response = await fetch(`${config.baseURL}/api/settings`, { method: 'POST', @@ -118,22 +116,38 @@ export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings) const text = await response.text(); + // Log response details + console.group('đŸ“Ĩ [API] Settings Update Response'); + console.log('📊 Response status:', response.status); + console.log('✅ Response OK:', response.ok); + console.log('📄 Response text:', text); + console.groupEnd(); + if (!response.ok) { + console.error('❌ [API] Settings update failed:', { + status: response.status, + statusText: response.statusText, + responseText: text, + sentPayload: nestedSettings + }); throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); } try { - return JSON.parse(text) || { success: true, message: 'Settings updated successfully' }; + const parsedResponse = JSON.parse(text); + console.log('✅ [API] Settings update successful:', parsedResponse); + return parsedResponse; } catch (jsonError) { // If response is not JSON, assume success if status was OK + console.log('â„šī¸ [API] Non-JSON response, assuming success'); return { success: true, message: 'Settings updated successfully' }; } }; -// Read NPUBs Management -export const getReadNpubs = async (page = 1, pageSize = 20): Promise => { +// Unified User Management - Using correct invite-only endpoints +export const getAllowedUsers = async (page = 1, pageSize = 20): Promise => { const token = readToken(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/read?page=${page}&pageSize=${pageSize}`, { + const response = await fetch(`${config.baseURL}/api/allowed/users?page=${page}&pageSize=${pageSize}`, { headers: { 'Authorization': `Bearer ${token}`, }, @@ -144,54 +158,82 @@ export const getReadNpubs = async (page = 1, pageSize = 20): Promise ({ - ...npub, - tier: npub.tier_name || npub.tier || 'basic' - })); return { - npubs: transformedNpubs, - total: data.pagination?.total || 0, - page: data.pagination?.page || page, - pageSize: data.pagination?.pageSize || pageSize + allowed_users: data.allowed_users || [], + pagination: data.pagination || { + page: page, + page_size: pageSize, + total_pages: 1, + total_items: 0 + } }; } catch (jsonError) { throw new Error(`Invalid JSON response: ${text}`); } }; -export const addReadNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { +export const addAllowedUser = async (request: AddAllowedUserRequest): Promise => { const token = readToken(); - const requestBody = { npub, tier }; + // Backend expects NPUB format (not hex), so keep as-is + console.group('👤 [API] Adding Allowed User'); + console.log('📤 Request payload:', request); + console.log('🌐 Request URL:', `${config.baseURL}/api/allowed/add`); + console.log('📄 Request body (stringified):', JSON.stringify(request, null, 2)); + console.log('🔑 Authorization token present:', !!token); + console.groupEnd(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/read`, { + // Using correct POST method with request body + const response = await fetch(`${config.baseURL}/api/allowed/add`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify(requestBody), + body: JSON.stringify(request), }); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const text = await response.text(); + + // Log response details + console.group('đŸ“Ĩ [API] Add User Response'); + console.log('📊 Response status:', response.status); + console.log('✅ Response OK:', response.ok); + console.log('📄 Response text:', text); + console.groupEnd(); + + if (!response.ok) { + console.error('❌ [API] Add user failed:', { + status: response.status, + statusText: response.statusText, + responseText: text, + sentPayload: request + }); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } + try { - return JSON.parse(text); + const parsedResponse = JSON.parse(text); + console.log('✅ [API] Add user successful:', parsedResponse); + return parsedResponse; } catch (jsonError) { - throw new Error(`Invalid JSON response: ${text}`); + console.log('â„šī¸ [API] Non-JSON response, assuming success'); + return { success: true, message: 'User added successfully' }; } }; -export const removeReadNpub = async (npub: string): Promise<{ success: boolean, message: string }> => { +export const removeAllowedUser = async (request: RemoveAllowedUserRequest): Promise => { const token = readToken(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/read/${npub}`, { + + // Using correct DELETE method with request body + const response = await fetch(`${config.baseURL}/api/allowed/remove`, { method: 'DELETE', headers: { + 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, + body: JSON.stringify(request), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); @@ -204,98 +246,102 @@ export const removeReadNpub = async (npub: string): Promise<{ success: boolean, } }; -// Write NPUBs Management -export const getWriteNpubs = async (page = 1, pageSize = 20): Promise => { +// Relay Owner Management API (for only-me mode) +export const getRelayOwner = async (): Promise => { const token = readToken(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/write?page=${page}&pageSize=${pageSize}`, { + + console.group('🔍 [API] Getting Relay Owner'); + console.log('🌐 Request URL:', `${config.baseURL}/api/admin/owner`); + console.log('🔑 Authorization token present:', !!token); + console.groupEnd(); + + const response = await fetch(`${config.baseURL}/api/admin/owner`, { headers: { 'Authorization': `Bearer ${token}`, }, }); + const text = await response.text(); + + console.group('đŸ“Ĩ [API] Get Owner Response'); + console.log('📊 Response status:', response.status); + console.log('✅ Response OK:', response.ok); + console.log('📄 Response text:', text); + console.groupEnd(); + 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 - map tier_name to tier - const transformedNpubs = (data.npubs || []).map((npub: any) => ({ - ...npub, - tier: npub.tier_name || npub.tier || 'basic' - })); - - return { - npubs: transformedNpubs, - total: data.pagination?.total || 0, - page: data.pagination?.page || page, - pageSize: data.pagination?.pageSize || pageSize - }; + const parsedResponse = JSON.parse(text); + console.log('✅ [API] Get owner successful:', parsedResponse); + return parsedResponse; } catch (jsonError) { throw new Error(`Invalid JSON response: ${text}`); } }; -export const addWriteNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => { +export const setRelayOwner = async (request: SetRelayOwnerRequest): Promise => { const token = readToken(); - const requestBody = { npub, tier }; + console.group('👤 [API] Setting Relay Owner'); + console.log('📤 Request payload:', request); + console.log('🌐 Request URL:', `${config.baseURL}/api/admin/owner`); + console.groupEnd(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/write`, { + const response = await fetch(`${config.baseURL}/api/admin/owner`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: JSON.stringify(requestBody), + body: JSON.stringify(request), }); - 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}`); + console.group('đŸ“Ĩ [API] Set Owner Response'); + console.log('📊 Response status:', response.status); + console.log('✅ Response OK:', response.ok); + console.log('📄 Response text:', text); + console.groupEnd(); + + if (!response.ok) { + console.error('❌ [API] Set owner failed:', { + status: response.status, + statusText: response.statusText, + responseText: text, + sentPayload: request + }); + throw new Error(`HTTP error! status: ${response.status}, response: ${text}`); + } - const text = await response.text(); try { - return JSON.parse(text); + const parsedResponse = JSON.parse(text); + console.log('✅ [API] Set owner successful:', parsedResponse); + return parsedResponse; } catch (jsonError) { - throw new Error(`Invalid JSON response: ${text}`); + console.log('â„šī¸ [API] Non-JSON response, assuming success'); + return { success: true, message: 'Relay owner set successfully' }; } }; -// Bulk Import -export const bulkImportNpubs = async (importData: BulkImportRequest): Promise<{ success: boolean, message: string, imported: number, failed: number }> => { +export const removeRelayOwner = async (): Promise => { const token = readToken(); - const response = await fetch(`${config.baseURL}/api/allowed-npubs/bulk-import`, { - method: 'POST', + + const response = await fetch(`${config.baseURL}/api/admin/owner`, { + method: 'DELETE', 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); + const parsedResponse = JSON.parse(text); + return parsedResponse; } catch (jsonError) { - throw new Error(`Invalid JSON response: ${text}`); + return { success: true, message: 'Relay owner removed successfully' }; } -}; \ No newline at end of file +}; diff --git a/src/api/paymentNotifications.api.ts b/src/api/paymentNotifications.api.ts index 7f7bdc1b..94499a38 100644 --- a/src/api/paymentNotifications.api.ts +++ b/src/api/paymentNotifications.api.ts @@ -70,7 +70,7 @@ export const markNotificationAsRead = async (id: number): Promise => { // Mark all notifications as read for a user export const markAllNotificationsAsRead = async (pubkey?: string): Promise => { - await httpApi.post('/api/payment/notifications/read-all', pubkey ? { pubkey } : undefined); + await httpApi.post('/api/payment/notifications/read-all', pubkey ? { pubkey } : {}); }; // Get payment statistics diff --git a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx index 5cfa794f..d16e6004 100644 --- a/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx +++ b/src/components/allowed-users/components/ModeSelector/ModeSelector.tsx @@ -10,25 +10,25 @@ interface ModeSelectorProps { } const MODE_INFO = { - personal: { + 'only-me': { label: 'Only Me', subtitle: '[Free]', description: 'Personal relay for single user with unlimited access', color: '#fa541c' }, - exclusive: { + 'invite-only': { label: 'Invite Only', subtitle: '[Free]', description: 'Invite-only access with manual NPUB management', color: '#722ed1' }, - free: { + 'public': { label: 'Public Relay', subtitle: '[Free]', description: 'Open access with optional free tiers', color: '#1890ff' }, - paid: { + 'subscription': { label: 'Subscription', subtitle: '[Paid]', description: 'Subscription-based access control', @@ -44,7 +44,7 @@ export const ModeSelector: React.FC = ({ return ( - {(['personal', 'exclusive', 'free', 'paid'] as AllowedUsersMode[]).map((mode) => { + {(['only-me', 'invite-only', 'public', 'subscription'] as AllowedUsersMode[]).map((mode) => { const info = MODE_INFO[mode]; const isActive = currentMode === mode; @@ -76,9 +76,9 @@ export const ModeSelector: React.FC = ({ - {MODE_INFO[currentMode].label}: {MODE_INFO[currentMode].description} + {MODE_INFO[currentMode]?.label || 'Unknown Mode'}: {MODE_INFO[currentMode]?.description || 'No description available'} ); -}; \ 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 620c9c14..950af278 100644 --- a/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx +++ b/src/components/allowed-users/components/NPubManagement/NPubManagement.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Button, Input, Table, Space, Modal, Form, Select, message, Popconfirm } from 'antd'; -import { PlusOutlined, UploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined } from '@ant-design/icons'; -import { useAllowedUsersNpubs, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; -import { AllowedUsersSettings, AllowedUsersMode } from '@app/types/allowedUsers.types'; +import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { useAllowedUsersList, useAllowedUsersValidation } from '@app/hooks/useAllowedUsers'; +import { AllowedUsersSettings, AllowedUsersMode, AllowedUser } from '@app/types/allowedUsers.types'; import * as S from './NPubManagement.styles'; interface NPubManagementProps { @@ -10,19 +10,9 @@ interface NPubManagementProps { mode: AllowedUsersMode; } -interface AddNpubFormData { +interface AddUserFormData { 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 = ({ @@ -31,52 +21,13 @@ export const NPubManagement: React.FC = ({ }) => { const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false); - const [isBulkModalVisible, setIsBulkModalVisible] = useState(false); - const [bulkText, setBulkText] = useState(''); - const [unifiedUsers, setUnifiedUsers] = useState([]); - const [editingUser, setEditingUser] = useState(null); - const [addForm] = Form.useForm(); - const [editForm] = Form.useForm(); + const [editingUser, setEditingUser] = useState(null); + const [addForm] = Form.useForm(); + const [editForm] = Form.useForm(); - const readNpubs = useAllowedUsersNpubs('read'); - const writeNpubs = useAllowedUsersNpubs('write'); + const { users, loading, addUser, removeUser, pagination } = useAllowedUsersList(); 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 || settings.tiers[0]?.name || 'basic', - 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; - // Preserve the tier from read access, or use write tier, or fallback - existing.tier = existing.tier || npub.tier || settings.tiers[0]?.name || 'basic'; - } else { - allNpubs.set(npub.npub, { - npub: npub.npub, - tier: npub.tier || settings.tiers[0]?.name || 'basic', - readAccess: false, - writeAccess: true, - added_at: npub.added_at - }); - } - }); - - setUnifiedUsers(Array.from(allNpubs.values())); - }, [readNpubs.npubs, writeNpubs.npubs, settings.tiers]); const tierOptions = settings.tiers.map(tier => { const displayFormat = tier.unlimited ? 'unlimited' @@ -88,20 +39,10 @@ export const NPubManagement: React.FC = ({ }; }); - const handleAddNpub = async () => { + const handleAddUser = 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); - } - + await addUser(values.npub, values.tier); setIsAddModalVisible(false); addForm.resetFields(); } catch (error) { @@ -109,42 +50,14 @@ 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; - - // Ensure we have a valid tier - fallback to first available tier if undefined - const tierToUse = user.tier || settings.tiers[0]?.name || 'basic'; - - try { - if (type === 'read') { - if (enabled) { - await readNpubs.addNpub(npub, tierToUse); - } else { - await readNpubs.removeNpub(npub); - } - } else { - if (enabled) { - await writeNpubs.addNpub(npub, tierToUse); - } else { - await writeNpubs.removeNpub(npub); - } - } - } catch (error) { - message.error(`Failed to update ${type} access`); - } - }; - - const handleEditUser = (user: UnifiedUser) => { + const handleEditUser = (user: AllowedUser) => { setEditingUser(user); setIsEditModalVisible(true); // Set form values after modal is visible setTimeout(() => { editForm.setFieldsValue({ npub: user.npub, - tier: user.tier, - readAccess: user.readAccess, - writeAccess: user.writeAccess + tier: user.tier }); }, 0); }; @@ -152,40 +65,11 @@ export const NPubManagement: React.FC = ({ const handleSaveEdit = async () => { try { const values = await editForm.validateFields(); - const originalUser = editingUser!; - - // Check what actually changed - const readChanged = originalUser.readAccess !== values.readAccess; - const writeChanged = originalUser.writeAccess !== values.writeAccess; - const tierChanged = originalUser.tier !== values.tier; + if (!editingUser) return; - // Handle read access changes - if (readChanged || (tierChanged && values.readAccess)) { - if (values.readAccess) { - // Remove old entry if exists and re-add with new tier - if (originalUser.readAccess) { - await readNpubs.removeNpub(values.npub); - } - await readNpubs.addNpub(values.npub, values.tier); - } else { - // Remove read access - await readNpubs.removeNpub(values.npub); - } - } - - // Handle write access changes - if (writeChanged || (tierChanged && values.writeAccess)) { - if (values.writeAccess) { - // Remove old entry if exists and re-add with new tier - if (originalUser.writeAccess) { - await writeNpubs.removeNpub(values.npub); - } - await writeNpubs.addNpub(values.npub, values.tier); - } else { - // Remove write access - await writeNpubs.removeNpub(values.npub); - } - } + // Remove the old user and add with new tier + await removeUser(editingUser.npub); + await addUser(values.npub, values.tier); setIsEditModalVisible(false); setEditingUser(null); @@ -198,83 +82,12 @@ export const NPubManagement: React.FC = ({ 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 */ }) - ]); + await removeUser(npub); } catch (error) { message.error('Failed to remove user'); } }; - const handleBulkImport = async () => { - if (!bulkText.trim()) { - message.error('Please enter NPUBs to import'); - return; - } - - const lines = bulkText.split('\n').filter(line => line.trim()); - const defaultTier = settings.tiers[0]?.name || 'basic'; - - try { - for (const line of lines) { - const trimmedLine = line.trim(); - const parts = trimmedLine.split(':'); - - const npub = parts[0]; - const tier = parts[1] || defaultTier; - const permissions = parts[2] || 'r'; // default to read only - - const hasReadAccess = permissions.includes('r'); - const hasWriteAccess = permissions.includes('w'); - - // Add to read list if read access - if (hasReadAccess) { - try { - await readNpubs.addNpub(npub, tier); - } catch (error) { - // Might already exist, continue - } - } - - // Add to write list if write access - if (hasWriteAccess) { - try { - await writeNpubs.addNpub(npub, tier); - } catch (error) { - // Might already exist, continue - } - } - } - - message.success('Bulk import completed'); - setIsBulkModalVisible(false); - setBulkText(''); - } catch (error) { - message.error('Bulk import failed'); - } - }; - - const handleExport = () => { - const data = unifiedUsers.map(user => { - let permissions = ''; - if (user.readAccess) permissions += 'r'; - if (user.writeAccess) permissions += 'w'; - return `${user.npub}:${user.tier}:${permissions}`; - }).join('\n'); - - const blob = new Blob([data], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'allowed-users.txt'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - const columns = [ { title: 'NPUB', @@ -291,41 +104,21 @@ export const NPubManagement: React.FC = ({ 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 By', + dataIndex: 'created_by', + key: 'created_by', + render: (createdBy: string) => createdBy || 'admin' }, { - title: 'Added', - dataIndex: 'added_at', - key: 'added_at', + title: 'Date Added', + dataIndex: 'created_at', + key: 'created_at', render: (date: string) => new Date(date).toLocaleDateString() }, { title: 'Actions', key: 'actions', - render: (_: any, record: UnifiedUser) => ( + render: (_: any, record: AllowedUser) => ( - - `Total ${total} users` }} @@ -394,14 +176,14 @@ export const NPubManagement: React.FC = ({ { setIsAddModalVisible(false); addForm.resetFields(); }} destroyOnClose > -
+ = ({ > - - - -
- - - - Read Access -
-
- - - - Write Access -
-
-
- - {/* Bulk Import Modal */} - { - setIsBulkModalVisible(false); - setBulkText(''); - }} - width={600} - > - -

Enter NPUBs, one per line. Format options:

-
    -
  • npub1... (will use default tier and read access only)
  • -
  • npub1...:tier_name (specify tier, read access only)
  • -
  • npub1...:tier_name:rw (specify tier with read+write access)
  • -
- - setBulkText(e.target.value)} - placeholder="npub1abc123... npub1def456...:premium npub1ghi789...:basic:rw" - rows={10} - /> -
-
); -}; \ No newline at end of file +}; diff --git a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts index 303e3529..8698ec08 100644 --- a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts +++ b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.styles.ts @@ -61,3 +61,65 @@ export const NoteItem = styled.div` color: var(--text-main-color); } `; + +export const PermissionExplanations = styled.div` + margin-top: 1rem; + padding: 1rem; + background: var(--background-color-light); + border-radius: 6px; + border-left: 4px solid var(--primary-color); + + h4 { + margin: 0 0 0.75rem 0; + color: var(--text-main-color); + font-size: 14px; + font-weight: 600; + } + + ul { + margin: 0; + padding-left: 1.25rem; + + li { + margin-bottom: 0.5rem; + font-size: 13px; + color: var(--text-secondary-color); + line-height: 1.4; + + &:last-child { + margin-bottom: 0; + } + + strong { + color: var(--text-main-color); + } + } + } +`; + +export const ForcedSelectWrapper = styled.div<{ $isForced: boolean }>` + .ant-select { + width: 100%; + + .ant-select-selector { + background-color: ${props => props.$isForced ? '#1a1d35' : '#25284B'} !important; + border: ${props => props.$isForced ? '1px solid #434343' : '1px solid #d9d9d9'} !important; + color: ${props => props.$isForced ? '#8c8c8c' : '#d9d9d9'} !important; + } + + .ant-select-selection-item { + color: ${props => props.$isForced ? '#8c8c8c' : '#d9d9d9'} !important; + } + + &.ant-select-disabled { + .ant-select-selector { + background-color: ${props => props.$isForced ? '#1a1d35' : '#25284B'} !important; + border: ${props => props.$isForced ? '1px solid #434343' : '1px solid #d9d9d9'} !important; + } + + .ant-select-selection-item { + color: ${props => props.$isForced ? '#8c8c8c' : '#d9d9d9'} !important; + } + } + } +`; diff --git a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx index 02315b7e..708c2e7c 100644 --- a/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx +++ b/src/components/allowed-users/components/PermissionsConfig/PermissionsConfig.tsx @@ -1,160 +1,115 @@ import React from 'react'; -import { Switch, Select, Row, Col, Alert } from 'antd'; -import { AllowedUsersSettings, AllowedUsersMode, MODE_CONFIGURATIONS } from '@app/types/allowedUsers.types'; +import { Form, Select, Space, Alert } from 'antd'; +import { AllowedUsersSettings, AllowedUsersMode, PermissionType, MODE_CONFIGURATIONS, getPermissionLabel } from '@app/types/allowedUsers.types'; import * as S from './PermissionsConfig.styles'; interface PermissionsConfigProps { settings: AllowedUsersSettings; - mode: AllowedUsersMode; onSettingsChange: (settings: AllowedUsersSettings) => void; disabled?: boolean; } export const PermissionsConfig: React.FC = ({ settings, - mode, onSettingsChange, disabled = false }) => { - const modeConfig = MODE_CONFIGURATIONS[mode]; + const modeConfig = MODE_CONFIGURATIONS[settings.mode]; - const handleReadEnabledChange = (enabled: boolean) => { - const updatedSettings = { - ...settings, - read_access: { - ...settings.read_access, - enabled - } - }; - onSettingsChange(updatedSettings); - }; + // Safety check for undefined modeConfig + if (!modeConfig) { + return ( + + + + ); + } - const handleReadScopeChange = (scope: string) => { - const updatedSettings = { + const handleReadPermissionChange = (value: PermissionType) => { + onSettingsChange({ ...settings, - read_access: { - ...settings.read_access, - scope: scope as any - } - }; - onSettingsChange(updatedSettings); + read: value + }); }; - const handleWriteEnabledChange = (enabled: boolean) => { - const updatedSettings = { + const handleWritePermissionChange = (value: PermissionType) => { + onSettingsChange({ ...settings, - write_access: { - ...settings.write_access, - enabled - } - }; - onSettingsChange(updatedSettings); + write: value + }); }; - const handleWriteScopeChange = (scope: string) => { - const updatedSettings = { - ...settings, - write_access: { - ...settings.write_access, - scope: scope as any - } - }; - onSettingsChange(updatedSettings); - }; + // Create options for read permissions + const readOptions = modeConfig.readOptions.map(permission => ({ + value: permission, + label: getPermissionLabel(permission) + })); - const showPublicReadWarning = settings.read_access.enabled && settings.read_access.scope === 'all_users'; + // Create options for write permissions + const writeOptions = modeConfig.writeOptions.map(permission => ({ + value: permission, + label: getPermissionLabel(permission) + })); + + // Check if permissions are forced by mode + const isReadForced = !!modeConfig.forcedRead; + const isWriteForced = !!modeConfig.forcedWrite; return ( - {showPublicReadWarning && ( + + {/* Mode description */} - )} - -
- - Read: - - + {/* Read Permission */} + Read Permission} + help={{isReadForced ? "This permission is automatically set based on the selected mode" : "Who can read from this relay"}} + > + + - {modeConfig.readOptions.map(option => ( - - {option.label} - - ))} - - )} - - - + + - - - Write: - - Write Permission} + help={{isWriteForced ? "This permission is automatically set based on the selected mode" : "Who can write to this relay"}} + > + + - {modeConfig.writeOptions.map(option => ( - - {option.label} - - ))} - - )} - - - - - - - - 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/RelayOwnerConfig/RelayOwnerConfig.styles.ts b/src/components/allowed-users/components/RelayOwnerConfig/RelayOwnerConfig.styles.ts new file mode 100644 index 00000000..60c4e2d8 --- /dev/null +++ b/src/components/allowed-users/components/RelayOwnerConfig/RelayOwnerConfig.styles.ts @@ -0,0 +1,48 @@ +import styled from 'styled-components'; +import { media } from '../../../../styles/themes/constants'; + +export const Container = styled.div` + width: 100%; +`; + +export const NpubSection = styled.div` + margin-top: 1rem; +`; + +export const SectionTitle = styled.h3` + margin: 0 0 0.5rem 0; + color: var(--text-main-color); + font-size: 16px; + font-weight: 600; +`; + +export const SectionDescription = styled.p` + margin: 0 0 1rem 0; + color: var(--text-secondary-color); + font-size: 14px; + line-height: 1.4; +`; + +export const InputContainer = styled.div` + margin-bottom: 0.75rem; +`; + +export const AutoDetectedIndicator = styled.div` + display: flex; + align-items: center; + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(82, 196, 26, 0.1); + border-radius: 4px; + border: 1px solid rgba(82, 196, 26, 0.3); +`; + +export const ErrorText = styled.div` + color: #ff4d4f; + font-size: 13px; + margin-top: 0.5rem; + padding: 0.5rem; + background: rgba(255, 77, 79, 0.1); + border-radius: 4px; + border: 1px solid rgba(255, 77, 79, 0.3); +`; diff --git a/src/components/allowed-users/components/RelayOwnerConfig/RelayOwnerConfig.tsx b/src/components/allowed-users/components/RelayOwnerConfig/RelayOwnerConfig.tsx new file mode 100644 index 00000000..75bff3af --- /dev/null +++ b/src/components/allowed-users/components/RelayOwnerConfig/RelayOwnerConfig.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect } from 'react'; +import { Input, Alert, Button, Space, Typography, message } from 'antd'; +import { CheckOutlined, UserOutlined, SaveOutlined } from '@ant-design/icons'; +import { AllowedUsersSettings, RelayOwner } from '@app/types/allowedUsers.types'; +import { getRelayOwner, setRelayOwner, removeRelayOwner } from '@app/api/allowedUsers.api'; +import { nip19 } from 'nostr-tools'; +import * as S from './RelayOwnerConfig.styles'; + +const { Text } = Typography; + +interface RelayOwnerConfigProps { + settings: AllowedUsersSettings; + onSettingsChange: (settings: AllowedUsersSettings) => void; + disabled?: boolean; +} + +export const RelayOwnerConfig: React.FC = ({ + settings, + onSettingsChange, + disabled = false +}) => { + const [npubValue, setNpubValue] = useState(''); + const [currentOwner, setCurrentOwner] = useState(null); + const [isAutoDetected, setIsAutoDetected] = useState(false); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadCurrentOwner(); + }, []); + + const loadCurrentOwner = async () => { + try { + setLoading(true); + const response = await getRelayOwner(); + + if (response.relay_owner) { + setCurrentOwner(response.relay_owner); + try { + const npubEncoded = nip19.npubEncode(response.relay_owner.npub); + setNpubValue(npubEncoded); + } catch (error) { + console.error('Failed to encode npub:', error); + setNpubValue(response.relay_owner.npub); + } + setIsAutoDetected(false); + } else { + setCurrentOwner(null); + setNpubValue(''); + setIsAutoDetected(false); + } + } catch (error) { + console.error('Failed to load current owner:', error); + message.error('Failed to load relay owner'); + } finally { + setLoading(false); + } + }; + + const handleSaveOwner = async () => { + if (!validateNpub(npubValue)) { + message.error('Please enter a valid NPUB'); + return; + } + + try { + setSaving(true); + + let hexValue = npubValue; + try { + if (npubValue.startsWith('npub1')) { + const decoded = nip19.decode(npubValue); + hexValue = decoded.data as string; + } + } catch (error) { + console.error('Failed to decode npub:', error); + message.error('Invalid NPUB format'); + return; + } + + await setRelayOwner({ npub: hexValue }); + await loadCurrentOwner(); + setIsAutoDetected(false); + message.success('Relay owner set successfully'); + + } catch (error) { + console.error('❌ [RelayOwnerConfig] Failed to save owner:', error); + message.error('Failed to save relay owner'); + } finally { + setSaving(false); + } + }; + + const handleRemoveOwner = async () => { + if (!currentOwner) return; + + try { + setSaving(true); + await removeRelayOwner(); + setCurrentOwner(null); + setNpubValue(''); + message.success('Relay owner cleared successfully'); + } catch (error) { + console.error('Failed to remove owner:', error); + message.error('Failed to remove relay owner'); + } finally { + setSaving(false); + } + }; + + const validateNpub = (value: string): boolean => { + if (!value) return false; + return value.startsWith('npub1') && value.length === 63; + }; + + const isValid = validateNpub(npubValue); + // Compare with the current encoded npub value + const currentNpubEncoded = currentOwner ? + (() => { + try { + return nip19.npubEncode(currentOwner.npub); + } catch { + return currentOwner.npub; + } + })() : ''; + const hasChanges = npubValue !== currentNpubEncoded; + + return ( + + + + + + Relay Owner + + +
+ + {currentOwner ? 'Current relay owner:' : 'Set your NPUB to identify yourself as the relay operator:'} + + + + setNpubValue(e.target.value)} + placeholder="Enter your NPUB (e.g., npub1abc...def123)" + disabled={disabled || loading} + status={npubValue && !isValid ? 'error' : undefined} + style={{ + fontFamily: 'monospace', + fontSize: '14px', + backgroundColor: 'var(--background-color-secondary)', + border: '1px solid var(--border-color-base)', + color: 'var(--text-main-color)' + }} + /> + + + + + {currentOwner && ( + + )} + +
+ + {isAutoDetected && ( + + + + Auto-detected from Nestr + + + )} + + {npubValue && !isValid && ( + + Invalid NPUB format. Must start with "npub1" and be 63 characters long. + + )} +
+
+ ); +}; diff --git a/src/components/allowed-users/components/TierEditor/TierEditor.tsx b/src/components/allowed-users/components/TierEditor/TierEditor.tsx index d905b5c4..2a8c8789 100644 --- a/src/components/allowed-users/components/TierEditor/TierEditor.tsx +++ b/src/components/allowed-users/components/TierEditor/TierEditor.tsx @@ -51,12 +51,11 @@ export const TierEditor: React.FC = ({ price_sats: priceSats, monthly_limit_bytes: displayFormat.unlimited ? 0 : Math.round(displayFormat.value * getUnitMultiplier(displayFormat.unit)), - unlimited: displayFormat.unlimited, - active: tier.active // Preserve active state + unlimited: displayFormat.unlimited }; onTierChange(updatedTier); } - }, [displayFormat, name, priceSats, isValid, onTierChange, tier.active]); + }, [displayFormat, name, priceSats, isValid, onTierChange]); const getUnitMultiplier = (unit: DataUnit): number => { switch (unit) { @@ -187,4 +186,4 @@ export const TierEditor: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx index 37d3601c..c7a27bdf 100644 --- a/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx +++ b/src/components/allowed-users/components/TiersConfig/TiersConfig.tsx @@ -25,8 +25,10 @@ export const TiersConfig: React.FC = ({ const [editingIndex, setEditingIndex] = useState(null); const [currentTier, setCurrentTier] = useState(null); - const isPaidMode = mode === 'paid'; - const isFreeMode = mode === 'free'; + const isPaidMode = mode === 'subscription'; + const isPublicMode = mode === 'public'; + const isInviteMode = mode === 'invite-only'; + const isOnlyMeMode = mode === 'only-me'; const handleFreeTierChange = (tierName: string) => { const updatedTiers = settings.tiers.map(tier => ({ @@ -42,11 +44,27 @@ export const TiersConfig: React.FC = ({ onSettingsChange(updatedSettings); }; + const handleSelectActiveTier = (tierIndex: number) => { + // For public mode, mark the selected tier as active and others as inactive + if (isPublicMode) { + const updatedTiers = settings.tiers.map((tier, index) => ({ + ...tier, + active: index === tierIndex + })); + + const updatedSettings = { + ...settings, + tiers: updatedTiers + }; + onSettingsChange(updatedSettings); + } + }; + const handleAddTier = () => { setEditingIndex(null); setCurrentTier({ name: '', - price_sats: isFreeMode ? 0 : 1000, + price_sats: isPublicMode ? 0 : 1000, monthly_limit_bytes: 1073741824, // 1 GB default unlimited: false }); @@ -80,10 +98,10 @@ export const TiersConfig: React.FC = ({ return; // TierEditor should show validation error } - // Force price to 0 for free mode + // Force price to 0 for public mode and only-me mode (free tiers) const finalTier = { ...currentTier, - price_sats: isFreeMode ? 0 : currentTier.price_sats + price_sats: (isPublicMode || isOnlyMeMode) ? 0 : currentTier.price_sats }; let newTiers: AllowedUsersTier[]; @@ -171,8 +189,8 @@ export const TiersConfig: React.FC = ({ {isPaidMode && ( = ({ /> )} - {isFreeMode && ( + {isPublicMode && ( = ({ /> )} - {mode === 'exclusive' && ( + {isInviteMode && ( = ({ }} /> )} + + {isOnlyMeMode && ( + + )} - {isFreeMode ? 'Free Tier Selection' : 'Subscription Tiers'} + {isPublicMode ? 'Free Tier Configuration' : + isOnlyMeMode ? 'Personal Tier' : + 'Subscription Tiers'} - {!isFreeMode && ( + {!isOnlyMeMode && (
= ({ {isPaidMode && ( + )} + + {isPublicMode && ( + )} - {isFreeMode && ( + {isOnlyMeMode && ( )} ); -}; \ No newline at end of file +}; diff --git a/src/components/allowed-users/layouts/AllowedUsersLayout.tsx b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx index 7c30bc3f..b88cf1b4 100644 --- a/src/components/allowed-users/layouts/AllowedUsersLayout.tsx +++ b/src/components/allowed-users/layouts/AllowedUsersLayout.tsx @@ -1,22 +1,24 @@ -import React, { useState } from 'react'; -import { Card, Row, Col, Spin, Alert, Button, Space } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { Card, Row, Col, Spin, Alert, Button, Space, message } 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 { RelayOwnerConfig } from '../components/RelayOwnerConfig/RelayOwnerConfig'; import { AllowedUsersMode, MODE_CONFIGURATIONS, AllowedUsersSettings, DEFAULT_TIERS } from '@app/types/allowedUsers.types'; +import { getRelayOwner } from '@app/api/allowedUsers.api'; import * as S from './AllowedUsersLayout.styles'; export const AllowedUsersLayout: React.FC = () => { const { settings, loading, error, updateSettings } = useAllowedUsersSettings(); - const [currentMode, setCurrentMode] = useState('free'); + const [currentMode, setCurrentMode] = useState('public'); const [localSettings, setLocalSettings] = useState(null); const [hasChanges, setHasChanges] = useState(false); const [saving, setSaving] = useState(false); - React.useEffect(() => { + useEffect(() => { if (settings) { setCurrentMode(settings.mode); setLocalSettings(settings); @@ -29,70 +31,36 @@ export const AllowedUsersLayout: React.FC = () => { const modeConfig = MODE_CONFIGURATIONS[mode]; - // Use mode-specific default tiers or existing tiers if they're compatible - let tiers = localSettings.tiers; + // Comprehensive logging for mode changes + console.group('🔄 [UI] Mode Change Handler'); + console.log('đŸŽ¯ Selected mode:', mode); + console.log('âš™ī¸ Mode configuration:', modeConfig); + console.log('📋 Current local settings:', localSettings); + console.log('🔒 Forced read permission:', modeConfig.forcedRead); + console.log('🔒 Forced write permission:', modeConfig.forcedWrite); + console.log('📖 Available read options:', modeConfig.readOptions); + console.log('âœī¸ Available write options:', modeConfig.writeOptions); + console.log('đŸˇī¸ Default tiers for mode:', DEFAULT_TIERS[mode]); + console.groupEnd(); - // Check if current tiers are compatible with the new mode - const isCompatibleTiers = (currentTiers: typeof tiers, targetMode: AllowedUsersMode): boolean => { - if (currentTiers.length === 0) return false; - - return currentTiers.every(tier => { - const hasValidName = tier.name && tier.name.trim() !== ''; - - if (targetMode === 'paid') { - // Paid mode requires at least one tier with non-zero price - return hasValidName && tier.price_sats > 0; - } else if (targetMode === 'free') { - // Free mode should have price 0 - return hasValidName && tier.price_sats === 0; - } else if (targetMode === 'exclusive') { - // Exclusive mode can have any price - return hasValidName; - } - - return hasValidName; - }); - }; - - // Each mode should use its own defaults when switching modes - // Only preserve existing tiers if we're already in the target mode (backend data) - const currentMode = localSettings.mode; - - if (currentMode === mode) { - // We're already in this mode (from backend), keep existing tiers if compatible - if (isCompatibleTiers(localSettings.tiers, mode)) { - tiers = localSettings.tiers; - if (mode === 'free') { - // Ensure all prices are "0" for free mode - tiers = tiers.map(tier => ({ - ...tier, - price: '0' - })); - } - } else { - // Backend data isn't compatible with mode, use defaults - tiers = DEFAULT_TIERS[mode]; - } - } else { - // Switching between different modes, always use mode-specific defaults - tiers = DEFAULT_TIERS[mode]; - } - - const updatedSettings = { + // Apply mode-specific forced permissions and defaults + const updatedSettings: AllowedUsersSettings = { ...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 - } + read: modeConfig.forcedRead || modeConfig.readOptions[0], + write: modeConfig.forcedWrite || modeConfig.writeOptions[0], + tiers: DEFAULT_TIERS[mode] + // Note: relay_owner_npub is no longer managed in settings - it's handled via /api/allowed-users }; + console.group('📝 [UI] Updated Settings After Mode Change'); + console.log('🆕 New settings object:', updatedSettings); + console.log('đŸŽ¯ Final mode:', updatedSettings.mode); + console.log('📖 Final read permission:', updatedSettings.read); + console.log('âœī¸ Final write permission:', updatedSettings.write); + console.log('đŸˇī¸ Final tiers:', updatedSettings.tiers); + console.groupEnd(); + setLocalSettings(updatedSettings); setCurrentMode(mode); setHasChanges(true); @@ -106,10 +74,35 @@ export const AllowedUsersLayout: React.FC = () => { const handleSave = async () => { if (!localSettings) return; + // Check if owner exists when trying to save "only-me" mode + if (localSettings.mode === 'only-me') { + try { + const ownerResponse = await getRelayOwner(); + if (!ownerResponse.relay_owner) { + message.error('Cannot save "Only Me" mode: Please set a relay owner first.'); + return; + } + } catch (error) { + console.error('Failed to check relay owner:', error); + message.error('Cannot verify relay owner. Please ensure owner is set before saving "Only Me" mode.'); + return; + } + } + setSaving(true); try { await updateSettings(localSettings); setHasChanges(false); + } catch (error) { + // If save fails due to wallet service, reset to previous mode + if (localSettings.mode === 'subscription' && + error instanceof Error && + error.message.includes('wallet service')) { + console.log('Reverting to previous mode due to wallet service error'); + setLocalSettings(settings); + setCurrentMode(settings.mode); + setHasChanges(false); + } } finally { setSaving(false); } @@ -148,10 +141,10 @@ export const AllowedUsersLayout: React.FC = () => { 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'; + // Determine what sections to show based on mode and permissions + const showNpubManagement = localSettings.read === 'allowed_users' || localSettings.write === 'allowed_users'; + const showTiers = currentMode === 'subscription' || currentMode === 'invite-only' || currentMode === 'public' || currentMode === 'only-me'; + const showRelayOwnerConfig = currentMode === 'only-me'; return ( @@ -172,19 +165,34 @@ export const AllowedUsersLayout: React.FC = () => { - + + {showRelayOwnerConfig && ( + + + + + + )} + {showTiers && ( - + { {showNpubManagement && ( - + { ); -}; \ No newline at end of file +}; diff --git a/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx b/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx index 57b4d54a..1b198437 100644 --- a/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx +++ b/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx @@ -3,6 +3,7 @@ import { BaseInput } from '@app/components/common/inputs/BaseInput/BaseInput'; import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; import { BaseSpin } from '@app/components/common/BaseSpin/BaseSpin'; +import { Alert } from 'antd'; import * as S from './SendForm.styles'; import { truncateString } from '@app/utils/utils'; import useBalanceData from '@app/hooks/useBalanceData'; @@ -29,7 +30,7 @@ export type tiers = 'low' | 'med' | 'high'; const SendForm: React.FC = ({ onSend }) => { const { balanceData, isLoading } = useBalanceData(); - const { isAuthenticated, login, token, loading: authLoading } = useWalletAuth(); // Use the auth hook + const { isAuthenticated, login, token, loading: authLoading, checkWalletHealth, walletHealth, healthLoading } = useWalletAuth(); // Use the auth hook const [loading, setLoading] = useState(false); @@ -48,6 +49,7 @@ const SendForm: React.FC = ({ onSend }) => { }); const [txSize, setTxSize] = useState(null); + const [txSizeCalculating, setTxSizeCalculating] = useState(false); const [enableRBF, setEnableRBF] = useState(false); // Default to false @@ -59,18 +61,42 @@ const SendForm: React.FC = ({ onSend }) => { return validateBech32Address(address); }, []); + // Health check when component mounts or when authentication status changes + useEffect(() => { + if (isAuthenticated) { + checkWalletHealth(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]); // Only depend on isAuthenticated to prevent excessive calls + // First useEffect - Transaction size calculation useEffect(() => { const debounceTimeout = setTimeout(() => { const fetchTransactionSize = async () => { if (isValidAddress(formData.address) && isDetailsOpen) { + // Prevent multiple simultaneous transaction size calculations + if (txSizeCalculating) { + console.log('Transaction size calculation already in progress, skipping'); + return; + } + try { + setTxSizeCalculating(true); + if (!isAuthenticated) { console.log('Not Authenticated.'); await login(); + return; + } + + // Check wallet health before making transaction calculations + const health = await checkWalletHealth(); + if (!health || health.status !== 'healthy' || !health.chain_synced) { + console.log('Wallet not ready (unhealthy or not synced), skipping transaction calculation'); + return; } - const response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { + let response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -83,14 +109,34 @@ const SendForm: React.FC = ({ onSend }) => { }), }); + // Handle 401 by re-authenticating and retrying if (response.status === 401) { const errorText = await response.text(); if (errorText.includes('Token expired') || errorText.includes('Unauthorized: Invalid token')) { - console.log('Session expired. Please log in again.'); + console.log('Session expired. Re-authenticating and retrying...'); deleteWalletToken(); await login(); + + // Retry the request with the new token + response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + recipient_address: formData.address, + spend_amount: parseInt(formData.amount), + priority_rate: feeRate, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } else { + throw new Error(errorText); } - throw new Error(errorText); } const result = await response.json(); @@ -98,6 +144,8 @@ const SendForm: React.FC = ({ onSend }) => { } catch (error) { console.error('Error fetching transaction size:', error); setTxSize(null); + } finally { + setTxSizeCalculating(false); } } }; @@ -106,7 +154,8 @@ const SendForm: React.FC = ({ onSend }) => { }, 500); return () => clearTimeout(debounceTimeout); - }, [formData.address, formData.amount, feeRate, isAuthenticated, login, token, isDetailsOpen, isValidAddress]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData.address, formData.amount, feeRate, isDetailsOpen]); // Only depend on actual form changes // Second useEffect - Fee calculation useEffect(() => { @@ -172,6 +221,12 @@ const SendForm: React.FC = ({ onSend }) => { const handleSend = async () => { if (loading || inValidAmount) return; + // Check wallet health before allowing transaction + if (!isAuthenticated || !walletHealth || walletHealth.status !== 'healthy' || + !walletHealth.chain_synced) { + return; // Don't proceed if wallet is not ready + } + setLoading(true); const selectedFee = feeRate; // The user-selected fee rate @@ -308,7 +363,7 @@ const SendForm: React.FC = ({ onSend }) => { = ({ onSend }) => { ); + // Check if wallet is ready for transactions (healthy + synced = ready) + const isWalletReady = isAuthenticated && walletHealth && walletHealth.status === 'healthy' && + walletHealth.chain_synced; + + // Render wallet status indicator + const renderWalletStatus = () => { + if (!isAuthenticated) { + return ( + + ); + } + + if (healthLoading) { + return ( + + ); + } + + if (!walletHealth) { + return ( + + ); + } + + if (walletHealth.status !== 'healthy') { + return ( + + ); + } + + if (!walletHealth.chain_synced) { + return ( + + ); + } + + // Wallet is healthy and synced + return ( + + ); + }; + return ( Send + {renderWalletStatus()} {isDetailsOpen ? ( <> diff --git a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx index ab1749b1..7837a5d1 100644 --- a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx +++ b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx @@ -47,7 +47,7 @@ export const PaidSubscribers: React.FC = () => { setAllSubscribers([...subscribers]); // Start with current subscribers // Fetch more subscribers if available - let currentSubscribers = [...subscribers]; + const currentSubscribers = [...subscribers]; let canFetchMore = hasMore; while (canFetchMore) { @@ -385,4 +385,4 @@ export const PaidSubscribers: React.FC = () => { ); }; -export default PaidSubscribers; \ No newline at end of file +export default PaidSubscribers; diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 84579739..726f277a 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -97,7 +97,6 @@ export const DesktopLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, - onAddKind, onRemoveKind, // Media props photos, @@ -172,7 +171,6 @@ export const DesktopLayout: React.FC = ({ onKindsActiveChange={onKindsActiveChange} onKindsChange={onKindsChange} onDynamicKindsChange={onDynamicKindsChange} - onAddKind={onAddKind} onRemoveKind={onRemoveKind} /> diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index 542242b4..af7f9c0c 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -94,7 +94,6 @@ export const MobileLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, - onAddKind, onRemoveKind, // Media props photos, @@ -163,7 +162,6 @@ export const MobileLayout: React.FC = ({ onKindsActiveChange={onKindsActiveChange} onKindsChange={onKindsChange} onDynamicKindsChange={onDynamicKindsChange} - onAddKind={onAddKind} onRemoveKind={onRemoveKind} /> diff --git a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx index 7c7bfa07..87e62334 100644 --- a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx +++ b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx @@ -5,7 +5,6 @@ import { BaseSwitch } from '@app/components/common/BaseSwitch/BaseSwitch'; import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; import { KindsList } from './components/KindsList'; -import { AddKindForm } from './components/AddKindForm'; import { DynamicKindsList } from './components/DynamicKindsList'; export interface KindsSectionProps { @@ -17,7 +16,6 @@ export interface KindsSectionProps { onKindsActiveChange: (active: boolean) => void; onKindsChange: (values: string[]) => void; onDynamicKindsChange: (values: string[]) => void; - onAddKind: (kind: string) => void; onRemoveKind: (kind: string) => void; } @@ -30,7 +28,6 @@ export const KindsSection: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, - onAddKind, onRemoveKind, }) => { const header = mode !== 'whitelist' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; @@ -57,11 +54,6 @@ export const KindsSection: React.FC = ({ onKindsChange={onKindsChange} /> - - void; - mode: string; -} - -export const AddKindForm: React.FC = ({ onAddKind, mode }) => { - const [newKind, setNewKind] = useState(''); - - const handleAddKind = () => { - if (newKind) { - onAddKind(newKind); - setNewKind(''); - } - }; - - if (mode === 'whitelist') { - return null; - } - - return ( -
-

{'Add to Blacklist'}

-
- setNewKind(e.target.value)} - placeholder="Enter new kind" - /> - - Add Kind - -
-
- ); -}; - -export default AddKindForm; diff --git a/src/hooks/useAllowedUsers.ts b/src/hooks/useAllowedUsers.ts index cf03a751..7e042f5b 100644 --- a/src/hooks/useAllowedUsers.ts +++ b/src/hooks/useAllowedUsers.ts @@ -3,23 +3,26 @@ import { message } from 'antd'; import { getAllowedUsersSettings, updateAllowedUsersSettings, - getReadNpubs, - getWriteNpubs, - addReadNpub, - addWriteNpub, - removeReadNpub, - removeWriteNpub, - bulkImportNpubs + getAllowedUsers, + addAllowedUser, + removeAllowedUser } from '@app/api/allowedUsers.api'; import { AllowedUsersSettings, - AllowedUsersNpub, AllowedUsersMode, - BulkImportRequest + AllowedUser, + AllowedUsersResponse, + DEFAULT_TIERS } from '@app/types/allowedUsers.types'; +// Hook for managing allowed users settings export const useAllowedUsersSettings = () => { - const [settings, setSettings] = useState(null); + const [settings, setSettings] = useState({ + mode: 'public', + read: 'all_users', + write: 'all_users', + tiers: DEFAULT_TIERS['public'] + }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -32,27 +35,7 @@ export const useAllowedUsersSettings = () => { } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch settings'; setError(errorMessage); - - // Don't show error message if it's just that the endpoint doesn't exist yet - if (!errorMessage.includes('404') && !errorMessage.includes('not valid JSON')) { - message.error(errorMessage); - } - - // Set default settings if API is not available yet - setSettings({ - mode: 'free', - read_access: { - enabled: true, - scope: 'all_users' - }, - write_access: { - enabled: true, - scope: 'all_users' - }, - tiers: [ - { name: 'Basic', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false } - ] - }); + message.error(errorMessage); } finally { setLoading(false); } @@ -62,21 +45,25 @@ export const useAllowedUsersSettings = () => { 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); - } + await updateAllowedUsersSettings(newSettings); + setSettings(newSettings); + message.success('Settings updated successfully'); } 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'); + + // Handle specific wallet service error for subscription mode + if (errorMessage.includes('wallet service is not available') || + errorMessage.includes('cannot switch to subscription mode')) { + setError('Subscription mode requires active wallet service'); + message.error({ + content: 'Subscription mode requires Bitcoin payments, but the relay wallet service is not running. Please start the wallet service to generate Bitcoin addresses for user payments before enabling subscription mode.', + duration: 8 + }); } else { + setError(errorMessage); message.error(errorMessage); } + throw err; } finally { setLoading(false); } @@ -95,167 +82,104 @@ export const useAllowedUsersSettings = () => { }; }; -export const useAllowedUsersNpubs = (type: 'read' | 'write') => { - const [npubs, setNpubs] = useState([]); - const [total, setTotal] = useState(0); +// Hook for managing allowed users list +export const useAllowedUsersList = () => { + const [users, setUsers] = useState([]); 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) => { + const [pagination, setPagination] = useState({ + page: 1, + page_size: 20, + total_pages: 1, + total_items: 0 + }); + + const fetchUsers = useCallback(async (page = 1, pageSize = 20) => { 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); + const response: AllowedUsersResponse = await getAllowedUsers(page, pageSize); + setUsers(response.allowed_users); + setPagination(response.pagination); } catch (err) { - const errorMessage = err instanceof Error ? err.message : `Failed to fetch ${type} NPUBs`; + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'; 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) => { + const addUser = useCallback(async (npub: string, tier: string) => { setLoading(true); + setError(null); 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); - } + await addAllowedUser({ npub, tier }); + message.success('User added successfully'); + // Refresh the list + await fetchUsers(pagination.page, pagination.page_size); } catch (err) { - const errorMessage = err instanceof Error ? err.message : `Failed to remove NPUB from ${type} list`; + const errorMessage = err instanceof Error ? err.message : 'Failed to add user'; + setError(errorMessage); message.error(errorMessage); + throw err; } finally { setLoading(false); } - }, [type, page, fetchNpubs]); + }, [fetchUsers, pagination.page, pagination.page_size]); - const bulkImport = useCallback(async (npubsData: string[]) => { + const removeUser = useCallback(async (npub: string) => { setLoading(true); + setError(null); 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); - } + await removeAllowedUser({ npub }); + message.success('User removed successfully'); + // Refresh the list + await fetchUsers(pagination.page, pagination.page_size); } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Bulk import failed'; + const errorMessage = err instanceof Error ? err.message : 'Failed to remove user'; + setError(errorMessage); message.error(errorMessage); + throw err; } finally { setLoading(false); } - }, [type, fetchNpubs]); - - const changePage = useCallback((newPage: number) => { - setPage(newPage); - fetchNpubs(newPage); - }, [fetchNpubs]); + }, [fetchUsers, pagination.page, pagination.page_size]); useEffect(() => { - fetchNpubs(); - }, [fetchNpubs]); + fetchUsers(); + }, [fetchUsers]); return { - npubs, - total, + users, loading, error, - page, - pageSize, - addNpub, - removeNpub, - bulkImport, - changePage, - refetch: fetchNpubs + pagination, + addUser, + removeUser, + refetch: fetchUsers + }; +}; + +// Legacy hook for backward compatibility - will be removed +export const useAllowedUsersNpubs = (type: 'read' | 'write') => { + const { users, loading, error, addUser, removeUser, refetch } = useAllowedUsersList(); + + return { + npubs: users, + loading, + error, + addNpub: addUser, + removeNpub: removeUser, + refetch }; }; // Validation hook export const useAllowedUsersValidation = () => { - const validateSettings = useCallback((settings: AllowedUsersSettings): string[] => { - const errors: string[] = []; - - // Mode validation - if (!['free', 'paid', 'exclusive'].includes(settings.mode)) { - errors.push('Invalid mode selected'); - } - - // Tier validation - if (settings.mode === 'paid' && settings.tiers.some(t => t.price_sats === 0)) { - errors.push('Paid mode cannot have free tiers'); - } - - // Scope validation - if (settings.mode === 'paid' && settings.write_access.scope !== 'paid_users') { - errors.push('Paid mode write access must be limited to paid users'); - } - - if (settings.mode === 'exclusive' && settings.write_access.scope !== 'allowed_users') { - errors.push('Exclusive mode write access must be limited to allowed users'); - } - - // Tiers validation - if (settings.tiers.length === 0) { - errors.push('At least one tier must be configured'); - } - - return errors; - }, []); - const validateNpub = useCallback((npub: string): string | null => { - if (!npub.trim()) { - return 'NPUB cannot be empty'; + if (!npub) { + return 'NPUB is required'; } if (!npub.startsWith('npub1')) { @@ -266,11 +190,30 @@ export const useAllowedUsersValidation = () => { return 'NPUB must be 63 characters long'; } + // Basic bech32 validation + const validChars = /^[a-z0-9]+$/; + const npubWithoutPrefix = npub.slice(5); + if (!validChars.test(npubWithoutPrefix)) { + return 'NPUB contains invalid characters'; + } + return null; }, []); return { - validateSettings, validateNpub }; -}; \ No newline at end of file +}; + +// Main hook that combines settings and users +export const useAllowedUsers = () => { + const settingsHook = useAllowedUsersSettings(); + const usersHook = useAllowedUsersList(); + const validation = useAllowedUsersValidation(); + + return { + ...settingsHook, + ...usersHook, + ...validation + }; +}; diff --git a/src/hooks/usePaymentNotifications.ts b/src/hooks/usePaymentNotifications.ts index 8f7ec627..9da1da92 100644 --- a/src/hooks/usePaymentNotifications.ts +++ b/src/hooks/usePaymentNotifications.ts @@ -245,7 +245,7 @@ export const usePaymentNotifications = (initialParams?: PaymentNotificationParam 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, - body: pubkey ? JSON.stringify({ pubkey }) : undefined, + body: JSON.stringify(pubkey ? { pubkey } : {}), }); if (!response.ok) { diff --git a/src/hooks/useWalletAuth.ts b/src/hooks/useWalletAuth.ts index e3c90c57..b5027b7a 100644 --- a/src/hooks/useWalletAuth.ts +++ b/src/hooks/useWalletAuth.ts @@ -1,11 +1,23 @@ import { useEffect, useState } from 'react'; import { persistWalletToken, readWalletToken, deleteWalletToken } from '@app/services/localStorage.service'; // Import the wallet-specific functions import { notificationController } from '@app/controllers/notificationController'; +import config from '@app/config/config'; + +interface WalletHealth { + status: 'healthy' | 'unhealthy'; + timestamp: string; + wallet_locked: boolean; + chain_synced: boolean; + peer_count: number; +} const useWalletAuth = () => { const [token, setToken] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(false); + const [walletHealth, setWalletHealth] = useState(null); + const [healthLoading, setHealthLoading] = useState(false); + const [healthCheckInProgress, setHealthCheckInProgress] = useState(false); // Fetch the wallet token from localStorage on mount useEffect(() => { @@ -79,11 +91,71 @@ const useWalletAuth = () => { } }; + // Check wallet health + const checkWalletHealth = async () => { + if (!isAuthenticated || !token) { + console.log('Not authenticated, skipping health check'); + return null; + } + + // Prevent multiple simultaneous health checks + if (healthCheckInProgress) { + console.log('Health check already in progress, skipping'); + return walletHealth; + } + + setHealthCheckInProgress(true); + setHealthLoading(true); + try { + let response = await fetch(`${config.walletBaseURL}/panel-health`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + // Handle 401 by re-authenticating and retrying (same as calculate-tx-size) + if (response.status === 401) { + console.log('Health check failed: token expired. Re-authenticating and retrying...'); + deleteWalletToken(); + await login(); + + // Retry the request with the new token + response = await fetch(`${config.walletBaseURL}/panel-health`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const healthData: WalletHealth = await response.json(); + setWalletHealth(healthData); + return healthData; + } catch (error) { + console.error('Error checking wallet health:', error); + setWalletHealth(null); + return null; + } finally { + setHealthLoading(false); + setHealthCheckInProgress(false); + } + }; + // Logout and clear wallet token const logout = () => { deleteWalletToken(); // Use the wallet-specific token deletion setToken(null); setIsAuthenticated(false); + setWalletHealth(null); }; return { @@ -92,6 +164,9 @@ const useWalletAuth = () => { login, logout, loading, + checkWalletHealth, + walletHealth, + healthLoading, }; }; diff --git a/src/store/slices/allowedUsersSlice.ts b/src/store/slices/allowedUsersSlice.ts index 1c587a3a..97f7bfe8 100644 --- a/src/store/slices/allowedUsersSlice.ts +++ b/src/store/slices/allowedUsersSlice.ts @@ -1,18 +1,16 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { AllowedUsersSettings, AllowedUsersNpub } from '@app/types/allowedUsers.types'; +import { AllowedUsersSettings, AllowedUser } from '@app/types/allowedUsers.types'; interface AllowedUsersState { settings: AllowedUsersSettings | null; - readNpubs: AllowedUsersNpub[]; - writeNpubs: AllowedUsersNpub[]; + users: AllowedUser[]; loading: boolean; error: string | null; } const initialState: AllowedUsersState = { settings: null, - readNpubs: [], - writeNpubs: [], + users: [], loading: false, error: null, }; @@ -30,28 +28,24 @@ const allowedUsersSlice = createSlice({ setSettings: (state, action: PayloadAction) => { state.settings = action.payload; }, - setReadNpubs: (state, action: PayloadAction) => { - state.readNpubs = action.payload; + setUsers: (state, action: PayloadAction) => { + state.users = action.payload; }, - setWriteNpubs: (state, action: PayloadAction) => { - state.writeNpubs = action.payload; + addUser: (state, action: PayloadAction) => { + state.users.push(action.payload); }, - addReadNpub: (state, action: PayloadAction) => { - state.readNpubs.push(action.payload); + removeUser: (state, action: PayloadAction) => { + state.users = state.users.filter(user => user.npub !== 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); + updateUser: (state, action: PayloadAction<{ npub: string; updates: Partial }>) => { + const index = state.users.findIndex(user => user.npub === action.payload.npub); + if (index !== -1) { + state.users[index] = { ...state.users[index], ...action.payload.updates }; + } }, clearState: (state) => { state.settings = null; - state.readNpubs = []; - state.writeNpubs = []; + state.users = []; state.loading = false; state.error = null; }, @@ -62,13 +56,11 @@ export const { setLoading, setError, setSettings, - setReadNpubs, - setWriteNpubs, - addReadNpub, - addWriteNpub, - removeReadNpub, - removeWriteNpub, + setUsers, + addUser, + removeUser, + updateUser, clearState, } = allowedUsersSlice.actions; -export default allowedUsersSlice.reducer; \ No newline at end of file +export default allowedUsersSlice.reducer; diff --git a/src/types/allowedUsers.types.ts b/src/types/allowedUsers.types.ts index eda582b8..e2999884 100644 --- a/src/types/allowedUsers.types.ts +++ b/src/types/allowedUsers.types.ts @@ -1,130 +1,152 @@ -export type AllowedUsersMode = 'free' | 'paid' | 'exclusive' | 'personal'; +// Updated mode names to match backend +export type AllowedUsersMode = 'only-me' | 'invite-only' | 'public' | 'subscription'; -export type AccessScope = 'all_users' | 'paid_users' | 'allowed_users'; +// Permission types as defined by backend +export type PermissionType = 'all_users' | 'paid_users' | 'allowed_users' | 'only_me'; +// Tier structure remains the same export interface AllowedUsersTier { name: string; price_sats: number; monthly_limit_bytes: number; unlimited: boolean; - active?: boolean; // For free mode - only one tier can be active at a time -} - -// Legacy interface - kept for migration purposes -export interface AllowedUsersTierLegacy { - data_limit: string; - price: string; - active?: boolean; -} - -export interface AllowedUsersAccessConfig { - enabled: boolean; - scope: AccessScope; + active?: boolean; // Optional field for public mode tier selection } +// Updated settings structure to match backend export interface AllowedUsersSettings { mode: AllowedUsersMode; - read_access: AllowedUsersAccessConfig; - write_access: AllowedUsersAccessConfig; + read: PermissionType; + write: PermissionType; tiers: AllowedUsersTier[]; + relay_owner_npub?: string; // Optional field for only-me mode } -export interface AllowedUsersNpub { +// Simplified user structure - no more per-user permissions +export interface AllowedUser { npub: string; tier: string; - added_at: string; + created_at: string; + created_by: string; } -export interface AllowedUsersNpubsResponse { - npubs: AllowedUsersNpub[]; - total: number; +// Pagination structure for API responses +export interface PaginationInfo { page: number; - pageSize: number; + page_size: number; + total_pages: number; + total_items: number; +} + +// API response for getting allowed users +export interface AllowedUsersResponse { + allowed_users: AllowedUser[]; + pagination: PaginationInfo; +} + +// Request structure for adding a user +export interface AddAllowedUserRequest { + npub: string; + tier: string; +} + +// Request structure for removing a user +export interface RemoveAllowedUserRequest { + npub: string; } -export interface BulkImportRequest { - type: 'read' | 'write'; - npubs: string[]; // Format: "npub1...:tier" +// API response structure +export interface ApiResponse { + success: boolean; + message: string; } -export interface AllowedUsersApiResponse { - allowed_users: AllowedUsersSettings; +// Relay Owner Management (only-me mode) +export interface RelayOwner { + npub: string; + created_at: string; + created_by: string; } -// Mode-specific option configurations -export interface ModeOptions { - readOptions: { value: AccessScope; label: string }[]; - writeOptions: { value: AccessScope; label: string }[]; - allowsFreeTiers: boolean; - requiresNpubManagement: boolean; +export interface RelayOwnerResponse { + relay_owner: RelayOwner | null; } -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 +export interface SetRelayOwnerRequest { + npub: string; +} + +// Mode-specific configurations with validation rules +export interface ModeConfiguration { + readOptions: PermissionType[]; + writeOptions: PermissionType[]; + forcedRead?: PermissionType; + forcedWrite?: PermissionType; + description: string; +} + +export const MODE_CONFIGURATIONS: Record = { + 'only-me': { + readOptions: ['only_me', 'all_users', 'allowed_users'], + writeOptions: ['only_me'], + forcedWrite: 'only_me', + description: 'Personal relay for single user' }, - 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 + 'invite-only': { + readOptions: ['all_users', 'allowed_users'], + writeOptions: ['allowed_users'], + forcedWrite: 'allowed_users', + description: 'Private community relay' }, - 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 + 'public': { + readOptions: ['all_users'], + writeOptions: ['all_users'], + forcedRead: 'all_users', + forcedWrite: 'all_users', + description: 'Public relay with no restrictions' }, - personal: { - readOptions: [ - { value: 'allowed_users', label: 'Only Me' } - ], - writeOptions: [ - { value: 'allowed_users', label: 'Only Me' } - ], - allowsFreeTiers: true, - requiresNpubManagement: true + 'subscription': { + readOptions: ['all_users', 'paid_users'], + writeOptions: ['paid_users'], + forcedWrite: 'paid_users', + description: 'Commercial relay with subscription tiers' } }; // Default tier configurations for each mode export const DEFAULT_TIERS: Record = { - free: [ - { name: 'Basic', price_sats: 0, monthly_limit_bytes: 104857600, unlimited: false, active: false }, // 100 MB - { name: 'Standard', price_sats: 0, monthly_limit_bytes: 524288000, unlimited: false, active: true }, // 500 MB - default active - { name: 'Plus', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false, active: false } // 1 GB - ], - paid: [ - { name: 'Starter', price_sats: 1000, monthly_limit_bytes: 1073741824, unlimited: false }, // 1 GB - { name: 'Professional', price_sats: 5000, monthly_limit_bytes: 5368709120, unlimited: false }, // 5 GB - { name: 'Business', price_sats: 10000, monthly_limit_bytes: 10737418240, unlimited: false } // 10 GB + 'only-me': [ + { name: 'Personal', price_sats: 0, monthly_limit_bytes: 0, unlimited: true } ], - exclusive: [ + 'invite-only': [ { name: 'Member', price_sats: 0, monthly_limit_bytes: 5368709120, unlimited: false }, // 5 GB { name: 'VIP', price_sats: 0, monthly_limit_bytes: 53687091200, unlimited: false }, // 50 GB { name: 'Unlimited', price_sats: 0, monthly_limit_bytes: 0, unlimited: true } ], - personal: [ - { name: 'Personal', price_sats: 0, monthly_limit_bytes: 0, unlimited: true, active: true } // Unlimited and free + 'public': [ + { name: 'Basic', price_sats: 0, monthly_limit_bytes: 104857600, unlimited: false, active: true }, // 100 MB - default active + { name: 'Standard', price_sats: 0, monthly_limit_bytes: 524288000, unlimited: false }, // 500 MB + { name: 'Plus', price_sats: 0, monthly_limit_bytes: 1073741824, unlimited: false } // 1 GB + ], + 'subscription': [ + { name: 'Starter', price_sats: 1000, monthly_limit_bytes: 1073741824, unlimited: false }, // 1 GB + { name: 'Professional', price_sats: 5000, monthly_limit_bytes: 5368709120, unlimited: false }, // 5 GB + { name: 'Business', price_sats: 10000, monthly_limit_bytes: 10737418240, unlimited: false } // 10 GB ] -}; \ No newline at end of file +}; + +// Helper function to get permission label +export const getPermissionLabel = (permission: PermissionType): string => { + switch (permission) { + case 'all_users': + return 'All Users'; + case 'paid_users': + return 'Paid Users'; + case 'allowed_users': + return 'Allowed Users'; + case 'only_me': + return 'Only Me'; + default: + return permission; + } +};