Skip to content

Commit b714f50

Browse files
committed
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
1 parent 6713956 commit b714f50

21 files changed

Lines changed: 2078 additions & 1 deletion

.claude/settings.local.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"enableAllProjectMcpServers": false
3+
}

src/api/allowedUsers.api.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import config from '@app/config/config';
2+
import { readToken } from '@app/services/localStorage.service';
3+
import {
4+
AllowedUsersSettings,
5+
AllowedUsersApiResponse,
6+
AllowedUsersNpubsResponse,
7+
BulkImportRequest,
8+
AllowedUsersNpub
9+
} from '@app/types/allowedUsers.types';
10+
11+
// Settings Management
12+
export const getAllowedUsersSettings = async (): Promise<AllowedUsersSettings> => {
13+
const token = readToken();
14+
const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, {
15+
headers: {
16+
'Authorization': `Bearer ${token}`,
17+
},
18+
});
19+
20+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
21+
22+
const text = await response.text();
23+
try {
24+
const data: AllowedUsersApiResponse = JSON.parse(text);
25+
26+
// Transform tiers from backend format to frontend format
27+
const transformedSettings = {
28+
...data.allowed_users,
29+
tiers: data.allowed_users.tiers.map(tier => ({
30+
data_limit: (tier as any).datalimit || tier.data_limit || '',
31+
price: tier.price
32+
}))
33+
};
34+
35+
return transformedSettings;
36+
} catch (jsonError) {
37+
throw new Error(`Invalid JSON response: ${text}`);
38+
}
39+
};
40+
41+
export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings): Promise<{ success: boolean, message: string }> => {
42+
const token = readToken();
43+
44+
// Transform to nested format as expected by backend
45+
const nestedSettings = {
46+
"allowed_users": {
47+
"mode": settings.mode,
48+
"read_access": {
49+
"enabled": settings.read_access.enabled,
50+
"scope": settings.read_access.scope
51+
},
52+
"write_access": {
53+
"enabled": settings.write_access.enabled,
54+
"scope": settings.write_access.scope
55+
},
56+
"tiers": settings.tiers.map(tier => ({
57+
"datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values
58+
"price": tier.price || "0"
59+
}))
60+
}
61+
};
62+
63+
console.log('Sending to backend:', JSON.stringify(nestedSettings, null, 2));
64+
65+
const response = await fetch(`${config.baseURL}/api/settings/allowed_users`, {
66+
method: 'POST',
67+
headers: {
68+
'Content-Type': 'application/json',
69+
'Authorization': `Bearer ${token}`,
70+
},
71+
body: JSON.stringify(nestedSettings),
72+
});
73+
74+
const text = await response.text();
75+
console.log('Backend response:', response.status, text);
76+
77+
if (!response.ok) {
78+
console.error('Backend error:', response.status, text);
79+
throw new Error(`HTTP error! status: ${response.status}, response: ${text}`);
80+
}
81+
82+
try {
83+
return JSON.parse(text);
84+
} catch (jsonError) {
85+
throw new Error(`Invalid JSON response: ${text}`);
86+
}
87+
};
88+
89+
// Read NPUBs Management
90+
export const getReadNpubs = async (page = 1, pageSize = 20): Promise<AllowedUsersNpubsResponse> => {
91+
const token = readToken();
92+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/read?page=${page}&pageSize=${pageSize}`, {
93+
headers: {
94+
'Authorization': `Bearer ${token}`,
95+
},
96+
});
97+
98+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
99+
100+
const text = await response.text();
101+
try {
102+
const data = JSON.parse(text);
103+
// Transform backend response to expected format
104+
return {
105+
npubs: data.npubs || [],
106+
total: data.pagination?.total || 0,
107+
page: data.pagination?.page || page,
108+
pageSize: data.pagination?.pageSize || pageSize
109+
};
110+
} catch (jsonError) {
111+
throw new Error(`Invalid JSON response: ${text}`);
112+
}
113+
};
114+
115+
export const addReadNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => {
116+
const token = readToken();
117+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/read`, {
118+
method: 'POST',
119+
headers: {
120+
'Content-Type': 'application/json',
121+
'Authorization': `Bearer ${token}`,
122+
},
123+
body: JSON.stringify({ npub, tier }),
124+
});
125+
126+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
127+
128+
const text = await response.text();
129+
try {
130+
return JSON.parse(text);
131+
} catch (jsonError) {
132+
throw new Error(`Invalid JSON response: ${text}`);
133+
}
134+
};
135+
136+
export const removeReadNpub = async (npub: string): Promise<{ success: boolean, message: string }> => {
137+
const token = readToken();
138+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/read/${npub}`, {
139+
method: 'DELETE',
140+
headers: {
141+
'Authorization': `Bearer ${token}`,
142+
},
143+
});
144+
145+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
146+
147+
const text = await response.text();
148+
try {
149+
return JSON.parse(text);
150+
} catch (jsonError) {
151+
throw new Error(`Invalid JSON response: ${text}`);
152+
}
153+
};
154+
155+
// Write NPUBs Management
156+
export const getWriteNpubs = async (page = 1, pageSize = 20): Promise<AllowedUsersNpubsResponse> => {
157+
const token = readToken();
158+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/write?page=${page}&pageSize=${pageSize}`, {
159+
headers: {
160+
'Authorization': `Bearer ${token}`,
161+
},
162+
});
163+
164+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
165+
166+
const text = await response.text();
167+
try {
168+
const data = JSON.parse(text);
169+
// Transform backend response to expected format
170+
return {
171+
npubs: data.npubs || [],
172+
total: data.pagination?.total || 0,
173+
page: data.pagination?.page || page,
174+
pageSize: data.pagination?.pageSize || pageSize
175+
};
176+
} catch (jsonError) {
177+
throw new Error(`Invalid JSON response: ${text}`);
178+
}
179+
};
180+
181+
export const addWriteNpub = async (npub: string, tier: string): Promise<{ success: boolean, message: string }> => {
182+
const token = readToken();
183+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/write`, {
184+
method: 'POST',
185+
headers: {
186+
'Content-Type': 'application/json',
187+
'Authorization': `Bearer ${token}`,
188+
},
189+
body: JSON.stringify({ npub, tier }),
190+
});
191+
192+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
193+
194+
const text = await response.text();
195+
try {
196+
return JSON.parse(text);
197+
} catch (jsonError) {
198+
throw new Error(`Invalid JSON response: ${text}`);
199+
}
200+
};
201+
202+
export const removeWriteNpub = async (npub: string): Promise<{ success: boolean, message: string }> => {
203+
const token = readToken();
204+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/write/${npub}`, {
205+
method: 'DELETE',
206+
headers: {
207+
'Authorization': `Bearer ${token}`,
208+
},
209+
});
210+
211+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
212+
213+
const text = await response.text();
214+
try {
215+
return JSON.parse(text);
216+
} catch (jsonError) {
217+
throw new Error(`Invalid JSON response: ${text}`);
218+
}
219+
};
220+
221+
// Bulk Import
222+
export const bulkImportNpubs = async (importData: BulkImportRequest): Promise<{ success: boolean, message: string, imported: number, failed: number }> => {
223+
const token = readToken();
224+
const response = await fetch(`${config.baseURL}/api/allowed-npubs/bulk-import`, {
225+
method: 'POST',
226+
headers: {
227+
'Content-Type': 'application/json',
228+
'Authorization': `Bearer ${token}`,
229+
},
230+
body: JSON.stringify(importData),
231+
});
232+
233+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
234+
235+
const text = await response.text();
236+
try {
237+
return JSON.parse(text);
238+
} catch (jsonError) {
239+
throw new Error(`Invalid JSON response: ${text}`);
240+
}
241+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import React from 'react';
2+
3+
export const MigrationHelper: React.FC = () => {
4+
// No migration needed since system is not live yet
5+
return null;
6+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import styled from 'styled-components';
2+
import { Button } from 'antd';
3+
import { media } from '@app/styles/themes/constants';
4+
5+
export const Container = styled.div`
6+
width: 100%;
7+
`;
8+
9+
export const ModeGrid = styled.div`
10+
display: grid;
11+
grid-template-columns: repeat(3, 1fr);
12+
gap: 1rem;
13+
margin-bottom: 1.5rem;
14+
15+
${media.md} {
16+
grid-template-columns: 1fr;
17+
gap: 0.75rem;
18+
}
19+
`;
20+
21+
interface ModeButtonProps {
22+
$isActive: boolean;
23+
$color: string;
24+
}
25+
26+
export const ModeButton = styled(Button)<ModeButtonProps>`
27+
height: 60px;
28+
border-radius: 8px;
29+
font-weight: 600;
30+
transition: all 0.3s ease;
31+
32+
${({ $isActive, $color }) => $isActive && `
33+
background-color: ${$color} !important;
34+
border-color: ${$color} !important;
35+
box-shadow: 0 4px 12px ${$color}33;
36+
`}
37+
38+
&:hover:not(:disabled) {
39+
transform: translateY(-2px);
40+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
41+
}
42+
43+
${media.md} {
44+
height: 50px;
45+
}
46+
`;
47+
48+
export const ModeDescription = styled.div`
49+
padding: 1rem;
50+
background: var(--background-color-secondary);
51+
border-radius: 8px;
52+
border: 1px solid var(--border-color-base);
53+
`;
54+
55+
export const DescriptionText = styled.p`
56+
margin: 0;
57+
color: var(--text-main-color);
58+
font-size: 14px;
59+
line-height: 1.5;
60+
`;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
import { Button, Space, Tooltip } from 'antd';
3+
import { AllowedUsersMode } from '@app/types/allowedUsers.types';
4+
import * as S from './ModeSelector.styles';
5+
6+
interface ModeSelectorProps {
7+
currentMode: AllowedUsersMode;
8+
onModeChange: (mode: AllowedUsersMode) => void;
9+
disabled?: boolean;
10+
}
11+
12+
const MODE_INFO = {
13+
free: {
14+
label: 'Free Mode',
15+
description: 'Open access with optional free tiers',
16+
color: '#1890ff'
17+
},
18+
paid: {
19+
label: 'Paid Mode',
20+
description: 'Subscription-based access control',
21+
color: '#52c41a'
22+
},
23+
exclusive: {
24+
label: 'Exclusive Mode',
25+
description: 'Invite-only access with manual NPUB management',
26+
color: '#722ed1'
27+
}
28+
};
29+
30+
export const ModeSelector: React.FC<ModeSelectorProps> = ({
31+
currentMode,
32+
onModeChange,
33+
disabled = false
34+
}) => {
35+
return (
36+
<S.Container>
37+
<S.ModeGrid>
38+
{(Object.keys(MODE_INFO) as AllowedUsersMode[]).map((mode) => {
39+
const info = MODE_INFO[mode];
40+
const isActive = currentMode === mode;
41+
42+
return (
43+
<Tooltip key={mode} title={info.description}>
44+
<S.ModeButton
45+
type={isActive ? 'primary' : 'default'}
46+
size="large"
47+
onClick={() => onModeChange(mode)}
48+
disabled={disabled}
49+
$isActive={isActive}
50+
$color={info.color}
51+
>
52+
{info.label}
53+
</S.ModeButton>
54+
</Tooltip>
55+
);
56+
})}
57+
</S.ModeGrid>
58+
59+
<S.ModeDescription>
60+
<S.DescriptionText>
61+
<strong>{MODE_INFO[currentMode].label}:</strong> {MODE_INFO[currentMode].description}
62+
</S.DescriptionText>
63+
</S.ModeDescription>
64+
</S.Container>
65+
);
66+
};

0 commit comments

Comments
 (0)