main
ts 446 lines 13 KB
Raw
1 import { useEffect, useMemo, useState } from "react";
2 import { useList, type BaseServer } from "@/hooks/useList";
3 import type { BanFilter } from "@/types/filters";
4 import { BROWSER_API_PATHS } from "@/lib/apiPaths";
5 import { APIClientError, apiClient } from "@/lib/apiClient";
6 import {
7 parseLeaseMetadata,
8 resolveLeasePayment,
9 resolveLeaseThumbnail,
10 } from "@/lib/metadata";
11 import type {
12 ApprovalMode,
13 IPPolicyUpdate,
14 LeasePolicyUpdate,
15 PolicyLease,
16 PolicyPortSettings,
17 PolicySettings,
18 PolicyStateResponse,
19 } from "@/types/api";
20
21 export type { ApprovalMode } from "@/types/api";
22
23 type LeaseAction = "approve" | "deny" | "ban";
24
25 export interface AdminServer extends BaseServer {
26 identityKey: string;
27 address: string;
28 isBanned: boolean;
29 bps: number;
30 isApproved: boolean;
31 isDenied: boolean;
32 ip: string;
33 displayIP: string;
34 isIPBanned: boolean;
35 }
36
37 export interface UDPSettings {
38 enabled: boolean;
39 maxLeases: number;
40 }
41
42 export interface TCPPortSettings {
43 enabled: boolean;
44 maxLeases: number;
45 }
46
47 const DEFAULT_POLICY_SETTINGS: PolicySettings = {
48 approval_mode: "auto",
49 landing_page_enabled: false,
50 udp: { enabled: false, max_leases: 0 },
51 tcp_port: { enabled: false, max_leases: 0 },
52 };
53
54 const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
55 invalid_mode: "Invalid approval mode. Choose auto or manual and retry.",
56 invalid_address: "Selected address is invalid. Refresh and try again.",
57 invalid_request: "Selected lease is invalid. Refresh and try again.",
58 lease_rejected: "Request was rejected by policy. Review conflicts and retry.",
59 ip_banned: "Request denied because the source IP is banned.",
60 unauthorized: "Admin authorization failed. Sign in again and retry.",
61 method_not_allowed: "This action is not supported by the current server version.",
62 };
63
64 function toAdminErrorMessage(error: unknown, fallback: string): string {
65 if (error instanceof APIClientError) {
66 const mappedMessage = ADMIN_ERROR_MESSAGE_BY_CODE[error.code];
67 if (mappedMessage) {
68 return mappedMessage;
69 }
70
71 if (error.status === 401 || error.status === 403) {
72 return "Admin authorization failed. Sign in again and retry.";
73 }
74 if (error.status === 409) {
75 return "Request was rejected by policy. Refresh and retry.";
76 }
77
78 const message = error.message.trim();
79 return message || fallback;
80 }
81
82 if (error instanceof Error) {
83 const message = error.message.trim();
84 return message || fallback;
85 }
86
87 return fallback;
88 }
89
90 function toAdminServer(
91 row: PolicyLease,
92 ): AdminServer {
93 const metadata = parseLeaseMetadata(row.metadata);
94 const payment = resolveLeasePayment(metadata);
95 const hostname = row.hostname || "";
96 const serviceName = row.name || "";
97 const address = row.address.trim();
98
99 return {
100 id: hostname,
101 name: serviceName || hostname || "(unnamed)",
102 description: metadata.description,
103 tags: metadata.tags,
104 thumbnail: resolveLeaseThumbnail(metadata, hostname),
105 owner: metadata.owner,
106 online: (row.ready || 0) > 0,
107 dns: hostname,
108 link: hostname ? `https://${hostname}/` : "",
109 lastUpdated: row.last_seen_at || undefined,
110 firstSeen: row.first_seen_at || undefined,
111 paymentEnabled: payment.enabled,
112 paymentLabel: payment.label,
113 identityKey: row.identity_key.trim(),
114 address,
115 isBanned: row.is_banned,
116 bps: row.bps,
117 isApproved: row.is_approved,
118 isDenied: row.is_denied,
119 ip: row.client_ip,
120 displayIP: row.reported_ip || row.client_ip,
121 isIPBanned: row.is_ip_banned,
122 };
123 }
124
125 function normalizeApprovalMode(value: string | undefined): ApprovalMode {
126 return value === "manual" ? "manual" : "auto";
127 }
128
129 function normalizePolicySettings(settings: PolicySettings | undefined): PolicySettings {
130 return {
131 approval_mode: normalizeApprovalMode(settings?.approval_mode),
132 landing_page_enabled:
133 settings?.landing_page_enabled ?? DEFAULT_POLICY_SETTINGS.landing_page_enabled,
134 udp: {
135 enabled: settings?.udp?.enabled ?? DEFAULT_POLICY_SETTINGS.udp.enabled,
136 max_leases: settings?.udp?.max_leases ?? DEFAULT_POLICY_SETTINGS.udp.max_leases,
137 },
138 tcp_port: {
139 enabled: settings?.tcp_port?.enabled ?? DEFAULT_POLICY_SETTINGS.tcp_port.enabled,
140 max_leases: settings?.tcp_port?.max_leases ?? DEFAULT_POLICY_SETTINGS.tcp_port.max_leases,
141 },
142 };
143 }
144
145 interface PolicyViewState {
146 serverData: PolicyLease[];
147 settings: PolicySettings;
148 }
149
150 async function loadPolicyState(): Promise<PolicyViewState> {
151 const state = await apiClient.get<PolicyStateResponse>(BROWSER_API_PATHS.policy.state);
152 const normalizedLeases = Array.isArray(state?.leases) ? state.leases : [];
153
154 return {
155 serverData: normalizedLeases,
156 settings: normalizePolicySettings(state?.policy),
157 };
158 }
159
160 export function useAdmin(enabled = true) {
161 const [serverData, setServerData] = useState<PolicyLease[]>([]);
162 const [policySettings, setPolicySettings] = useState<PolicySettings>(DEFAULT_POLICY_SETTINGS);
163 const [loading, setLoading] = useState(true);
164 const [error, setError] = useState("");
165
166 const [banFilter, setBanFilter] = useState<BanFilter>("all");
167
168 const applyPolicyState = (state: PolicyViewState) => {
169 setServerData(state.serverData);
170 setPolicySettings(state.settings);
171 };
172
173 const fetchData = async () => {
174 setError("");
175
176 try {
177 applyPolicyState(await loadPolicyState());
178 } catch (err: unknown) {
179 setError(toAdminErrorMessage(err, "Failed to load admin data"));
180 }
181 };
182
183 useEffect(() => {
184 let mounted = true;
185 if (!enabled) {
186 setError("");
187 setLoading(false);
188 return () => {
189 mounted = false;
190 };
191 }
192
193 const loadInitialData = async () => {
194 setError("");
195 setLoading(true);
196 try {
197 const state = await loadPolicyState();
198 if (!mounted) {
199 return;
200 }
201 applyPolicyState(state);
202 } catch (err: unknown) {
203 if (!mounted) {
204 return;
205 }
206 setError(toAdminErrorMessage(err, "Failed to load admin data"));
207 } finally {
208 if (mounted) {
209 setLoading(false);
210 }
211 }
212 };
213
214 void loadInitialData();
215 return () => {
216 mounted = false;
217 };
218 }, [enabled]);
219
220 const servers: AdminServer[] = useMemo(() => {
221 return serverData.map((row) => toAdminServer(row));
222 }, [serverData]);
223
224 const additionalFilter = (server: AdminServer) => {
225 switch (banFilter) {
226 case "banned":
227 return server.isBanned;
228 case "active":
229 return !server.isBanned;
230 default:
231 return true;
232 }
233 };
234
235 const listState = useList({
236 servers,
237 storageKey: "adminFavorites",
238 additionalFilter,
239 });
240
241 const runAdminAction = async (action: () => Promise<void>) => {
242 setError("");
243 try {
244 await action();
245 await fetchData();
246 } catch (err: unknown) {
247 const message = toAdminErrorMessage(err, "Action failed");
248 console.error(err);
249 setError(message);
250 throw err;
251 }
252 };
253
254 const postPolicySettings = async (settings: PolicySettings) => {
255 const response = await apiClient.post<PolicySettings>(BROWSER_API_PATHS.policy.root, settings);
256 setPolicySettings(normalizePolicySettings(response));
257 };
258
259 const currentPolicySettings = (overrides: Partial<PolicySettings> = {}): PolicySettings => ({
260 ...policySettings,
261 ...overrides,
262 });
263
264 const updateLeasePolicy = async (
265 identityKey: string,
266 policy: Omit<LeasePolicyUpdate, "identity_key">,
267 ) => {
268 if (!identityKey) {
269 throw new Error("Missing lease identity");
270 }
271 await apiClient.post<unknown>(BROWSER_API_PATHS.policy.leases, {
272 identity_key: identityKey,
273 ...policy,
274 } satisfies LeasePolicyUpdate);
275 };
276
277 const handleBanFilterChange = (value: BanFilter) => {
278 setBanFilter(value);
279 };
280
281 const handleBanStatus = (identityKey: string, isBan: boolean) =>
282 runAdminAction(() => updateLeasePolicy(identityKey, { is_banned: isBan }));
283
284 const handleBPSChange = async (identityKey: string, bps: number) => {
285 if (!identityKey) {
286 throw new Error("Missing lease identity");
287 }
288
289 const normalizedBPS = Math.max(0, Math.trunc(bps));
290 const previousBPS =
291 serverData.find((row) => row.identity_key.trim() === identityKey)?.bps ?? 0;
292
293 setServerData((prev) =>
294 prev.map((row) =>
295 row.identity_key.trim() === identityKey
296 ? { ...row, bps: normalizedBPS }
297 : row
298 )
299 );
300
301 try {
302 await runAdminAction(async () => {
303 if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
304 await updateLeasePolicy(identityKey, { bps: 0 });
305 return;
306 }
307 await updateLeasePolicy(identityKey, { bps: normalizedBPS });
308 });
309 } catch (err) {
310 setServerData((prev) =>
311 prev.map((row) =>
312 row.identity_key.trim() === identityKey
313 ? { ...row, bps: previousBPS }
314 : row
315 )
316 );
317 throw err;
318 }
319 };
320
321 const handleApprovalModeChange = async (mode: ApprovalMode) => {
322 await runAdminAction(async () => {
323 await postPolicySettings(currentPolicySettings({ approval_mode: mode }));
324 });
325 };
326
327 const handleSettingsChange = (key: "udp" | "tcp_port") =>
328 async (settings: { enabled: boolean; maxLeases: number }) => {
329 await runAdminAction(async () => {
330 const nextPortSettings: PolicyPortSettings = {
331 enabled: settings.enabled,
332 max_leases: settings.maxLeases,
333 };
334 const nextSettings =
335 key === "udp"
336 ? currentPolicySettings({ udp: nextPortSettings })
337 : currentPolicySettings({ tcp_port: nextPortSettings });
338 await postPolicySettings(nextSettings);
339 });
340 };
341
342 const handleUDPSettingsChange = handleSettingsChange("udp");
343 const handleTCPPortSettingsChange = handleSettingsChange("tcp_port");
344
345 const handleLandingPageEnabledChange = async (enabled: boolean) => {
346 await runAdminAction(async () => {
347 await postPolicySettings(currentPolicySettings({ landing_page_enabled: enabled }));
348 });
349 };
350
351 const handleApproveStatus = (identityKey: string, approve: boolean) =>
352 runAdminAction(() => updateLeasePolicy(identityKey, { is_approved: approve }));
353
354 const handleDenyStatus = (identityKey: string, deny: boolean) =>
355 runAdminAction(() => updateLeasePolicy(identityKey, { is_denied: deny }));
356
357 const handleIPBanStatus = (ip: string, isBan: boolean) =>
358 runAdminAction(async () => {
359 const normalizedIP = ip.trim();
360 if (!normalizedIP) {
361 throw new Error("Missing IP address");
362 }
363 await apiClient.post<unknown>(BROWSER_API_PATHS.policy.ips, {
364 ip: normalizedIP,
365 is_banned: isBan,
366 } satisfies IPPolicyUpdate);
367 });
368
369 const runBulkLeaseAction = async (identityKeys: string[], action: LeaseAction) => {
370 const normalizedIdentityKeys = [...new Set(
371 identityKeys.filter((identityKey) => identityKey.length > 0)
372 )];
373 if (normalizedIdentityKeys.length === 0) {
374 throw new Error("No valid leases selected");
375 }
376
377 const results = await Promise.allSettled(
378 normalizedIdentityKeys.map((identityKey) => {
379 const policy: LeasePolicyUpdate =
380 action === "approve"
381 ? { identity_key: identityKey, is_approved: true }
382 : action === "deny"
383 ? { identity_key: identityKey, is_denied: true }
384 : { identity_key: identityKey, is_banned: true };
385 return apiClient.post<unknown>(BROWSER_API_PATHS.policy.leases, policy);
386 })
387 );
388
389 const failed = results.find(
390 (
391 result
392 ): result is PromiseRejectedResult =>
393 result.status === "rejected"
394 );
395 if (failed) {
396 throw failed.reason instanceof Error
397 ? failed.reason
398 : new Error(String(failed.reason));
399 }
400 };
401
402 const handleBulkAction = (identityKeys: string[], action: LeaseAction) =>
403 runAdminAction(() => runBulkLeaseAction(identityKeys, action));
404
405 const handleBulkApprove = (identityKeys: string[]) => handleBulkAction(identityKeys, "approve");
406
407 const handleBulkDeny = (identityKeys: string[]) => handleBulkAction(identityKeys, "deny");
408
409 const handleBulkBan = (identityKeys: string[]) => handleBulkAction(identityKeys, "ban");
410
411 const approvalMode = normalizeApprovalMode(policySettings.approval_mode);
412 const landingPageEnabled = policySettings.landing_page_enabled;
413 const udpSettings: UDPSettings = {
414 enabled: policySettings.udp.enabled,
415 maxLeases: policySettings.udp.max_leases,
416 };
417 const tcpPortSettings: TCPPortSettings = {
418 enabled: policySettings.tcp_port.enabled,
419 maxLeases: policySettings.tcp_port.max_leases,
420 };
421
422 return {
423 servers,
424 ...listState,
425 banFilter,
426 approvalMode,
427 landingPageEnabled,
428 udpSettings,
429 tcpPortSettings,
430 loading,
431 error,
432 handleBanFilterChange,
433 handleBanStatus,
434 handleBPSChange,
435 handleApprovalModeChange,
436 handleLandingPageEnabledChange,
437 handleUDPSettingsChange,
438 handleTCPPortSettingsChange,
439 handleApproveStatus,
440 handleDenyStatus,
441 handleIPBanStatus,
442 handleBulkApprove,
443 handleBulkDeny,
444 handleBulkBan,
445 };
446 }