| 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 | } |