feat: Add admin approval mode and IP management features
Hee Sung Son committed
Dec 12, 2025 at 16:00 UTC
fc96473cf5d092f21b95dfcbc24b0829f7ebd106
18 files changed
+1512
-250
cmd/relay-server/admin.go
+286
-5
@@ -37,10 +37,20 @@ func handleAdminRequest(w http.ResponseWriter, r *http.Request, serv *portal.Rel
37
"leases_count": len(serv.GetAllLeaseEntries()),
38
"uptime": "TODO",
39
})
40
+ case route == "settings" && r.Method == http.MethodGet:
41
+ handleGetSettings(w)
42
+ case route == "settings/approval-mode":
43
+ handleApprovalModeRequest(w, r, serv)
44
case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/ban"):
45
handleLeaseBanRequest(w, r, serv, route)
46
case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/bps"):
47
handleLeaseBPSRequest(w, r, serv, route)
48
+ case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/approve"):
49
+ handleLeaseApproveRequest(w, r, serv, route)
50
+ case strings.HasPrefix(route, "leases/") && strings.HasSuffix(route, "/deny"):
51
+ handleLeaseDenyRequest(w, r, serv, route)
52
+ case strings.HasPrefix(route, "ips/") && strings.HasSuffix(route, "/ban"):
53
+ handleIPBanRequest(w, r, serv, route)
54
default:
55
http.NotFound(w, r)
56
}
@@ -73,6 +83,103 @@ func handleLeaseBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.
83
}
84
}
85
86
+func handleGetSettings(w http.ResponseWriter) {
87
+ writeJSON(w, map[string]interface{}{
88
+ "approval_mode": getApprovalMode(),
89
+ "approved_leases": getApprovedLeases(),
90
+ "denied_leases": getDeniedLeases(),
91
+ })
92
+}
93
+
94
+func handleApprovalModeRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
95
+ switch r.Method {
96
+ case http.MethodGet:
97
+ writeJSON(w, map[string]interface{}{
98
+ "approval_mode": getApprovalMode(),
99
+ })
100
+ case http.MethodPost:
101
+ var req struct {
102
+ Mode string `json:"mode"`
103
+ }
104
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
105
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
106
+ return
107
+ }
108
+ mode := ApprovalMode(req.Mode)
109
+ if mode != ApprovalModeAuto && mode != ApprovalModeManual {
110
+ http.Error(w, "Invalid mode (must be 'auto' or 'manual')", http.StatusBadRequest)
111
+ return
112
+ }
113
+ setApprovalMode(mode)
114
+ saveAdminSettings(serv, globalBPSManager)
115
+ log.Info().Str("mode", string(mode)).Msg("[Admin] Approval mode changed")
116
+ writeJSON(w, map[string]interface{}{
117
+ "approval_mode": mode,
118
+ })
119
+ default:
120
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
121
+ }
122
+}
123
+
124
+func handleLeaseApproveRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
125
+ parts := strings.Split(route, "/")
126
+ if len(parts) != 3 {
127
+ http.NotFound(w, r)
128
+ return
129
+ }
130
+
131
+ leaseID, ok := decodeLeaseID(parts[1])
132
+ if !ok {
133
+ http.Error(w, "Invalid lease ID", http.StatusBadRequest)
134
+ return
135
+ }
136
+
137
+ switch r.Method {
138
+ case http.MethodPost:
139
+ approveLease(leaseID)
140
+ undenyLease(leaseID) // Remove from denied if exists
141
+ saveAdminSettings(serv, globalBPSManager)
142
+ log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approved")
143
+ w.WriteHeader(http.StatusOK)
144
+ case http.MethodDelete:
145
+ revokeLease(leaseID)
146
+ saveAdminSettings(serv, globalBPSManager)
147
+ log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease approval revoked")
148
+ w.WriteHeader(http.StatusOK)
149
+ default:
150
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
151
+ }
152
+}
153
+
154
+func handleLeaseDenyRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
155
+ parts := strings.Split(route, "/")
156
+ if len(parts) != 3 {
157
+ http.NotFound(w, r)
158
+ return
159
+ }
160
+
161
+ leaseID, ok := decodeLeaseID(parts[1])
162
+ if !ok {
163
+ http.Error(w, "Invalid lease ID", http.StatusBadRequest)
164
+ return
165
+ }
166
+
167
+ switch r.Method {
168
+ case http.MethodPost:
169
+ denyLease(leaseID)
170
+ saveAdminSettings(serv, globalBPSManager)
171
+ log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denied")
172
+ w.WriteHeader(http.StatusOK)
173
+ case http.MethodDelete:
174
+ undenyLease(leaseID)
175
+ saveAdminSettings(serv, globalBPSManager)
176
+ log.Info().Str("lease_id", leaseID).Msg("[Admin] Lease denial removed")
177
+ w.WriteHeader(http.StatusOK)
178
+ default:
179
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
180
+ }
181
+}
182
+
183
func handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
184
parts := strings.Split(route, "/")
185
if len(parts) != 3 {
@@ -118,6 +225,41 @@ func handleLeaseBPSRequest(w http.ResponseWriter, r *http.Request, serv *portal.
225
}
226
}
227
228
+func handleIPBanRequest(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer, route string) {
229
+ // Route format: ips/{ip}/ban
230
+ parts := strings.Split(route, "/")
231
+ if len(parts) != 3 {
232
+ http.NotFound(w, r)
233
+ return
234
+ }
235
+
236
+ ip := parts[1]
237
+ if ip == "" {
238
+ http.Error(w, "Invalid IP address", http.StatusBadRequest)
239
+ return
240
+ }
241
+
242
+ if globalIPManager == nil {
243
+ http.Error(w, "IP manager not initialized", http.StatusInternalServerError)
244
+ return
245
+ }
246
+
247
+ switch r.Method {
248
+ case http.MethodPost:
249
+ globalIPManager.BanIP(ip)
250
+ saveAdminSettings(serv, globalBPSManager)
251
+ log.Info().Str("ip", ip).Msg("[Admin] IP banned")
252
+ w.WriteHeader(http.StatusOK)
253
+ case http.MethodDelete:
254
+ globalIPManager.UnbanIP(ip)
255
+ saveAdminSettings(serv, globalBPSManager)
256
+ log.Info().Str("ip", ip).Msg("[Admin] IP unbanned")
257
+ w.WriteHeader(http.StatusOK)
258
+ default:
259
+ http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
260
+ }
261
+}
262
+
263
func decodeLeaseID(encoded string) (string, bool) {
264
idBytes, err := base64.URLEncoding.DecodeString(encoded)
265
if err != nil {
@@ -206,6 +348,16 @@ func convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
348
349
bps := globalBPSManager.GetBPSLimit(identityID)
350
351
+ // Get IP info for this lease
352
+ var ip string
353
+ var isIPBanned bool
354
+ if globalIPManager != nil {
355
+ ip = globalIPManager.GetLeaseIP(identityID)
356
+ if ip != "" {
357
+ isIPBanned = globalIPManager.IsIPBanned(ip)
358
+ }
359
+ }
360
+
361
rows = append(rows, leaseRow{
362
Peer: identityID,
363
Name: name,
@@ -221,16 +373,112 @@ func convertLeaseEntriesToAdminRows(serv *portal.RelayServer) []leaseRow {
373
Hide: leaseEntry.ParsedMetadata != nil && leaseEntry.ParsedMetadata.Hide,
374
Metadata: lease.Metadata,
375
BPS: bps,
376
+ IsApproved: getApprovalMode() == ApprovalModeAuto || isLeaseApproved(identityID),
377
+ IsDenied: isLeaseDenied(identityID),
378
+ IP: ip,
379
+ IsIPBanned: isIPBanned,
380
})
381
}
382
383
return rows
384
}
385
386
+// ApprovalMode represents the approval mode for new connections
387
+type ApprovalMode string
388
+
389
+const (
390
+ ApprovalModeAuto ApprovalMode = "auto"
391
+ ApprovalModeManual ApprovalMode = "manual"
392
+)
393
+
394
// AdminSettings stores persistent admin configuration
395
type AdminSettings struct {
232
- BannedLeases []string `json:"banned_leases"`
233
- BPSLimits map[string]int64 `json:"bps_limits"`
396
+ BannedLeases []string `json:"banned_leases"`
397
+ BPSLimits map[string]int64 `json:"bps_limits"`
398
+ ApprovalMode ApprovalMode `json:"approval_mode"`
399
+ ApprovedLeases []string `json:"approved_leases,omitempty"`
400
+ DeniedLeases []string `json:"denied_leases,omitempty"`
401
+ BannedIPs []string `json:"banned_ips,omitempty"`
402
+}
403
+
404
+// Global approval mode (default: auto)
405
+var (
406
+ globalApprovalMode ApprovalMode = ApprovalModeAuto
407
+ globalApprovedLeases = make(map[string]struct{})
408
+ globalDeniedLeases = make(map[string]struct{})
409
+ approvalMu sync.RWMutex
410
+)
411
+
412
+func getApprovalMode() ApprovalMode {
413
+ approvalMu.RLock()
414
+ defer approvalMu.RUnlock()
415
+ return globalApprovalMode
416
+}
417
+
418
+func setApprovalMode(mode ApprovalMode) {
419
+ approvalMu.Lock()
420
+ defer approvalMu.Unlock()
421
+ globalApprovalMode = mode
422
+}
423
+
424
+func isLeaseApproved(leaseID string) bool {
425
+ approvalMu.RLock()
426
+ defer approvalMu.RUnlock()
427
+ _, ok := globalApprovedLeases[leaseID]
428
+ return ok
429
+}
430
+
431
+func approveLease(leaseID string) {
432
+ approvalMu.Lock()
433
+ defer approvalMu.Unlock()
434
+ globalApprovedLeases[leaseID] = struct{}{}
435
+}
436
+
437
+func revokeLease(leaseID string) {
438
+ approvalMu.Lock()
439
+ defer approvalMu.Unlock()
440
+ delete(globalApprovedLeases, leaseID)
441
+}
442
+
443
+func getApprovedLeases() []string {
444
+ approvalMu.RLock()
445
+ defer approvalMu.RUnlock()
446
+ result := make([]string, 0, len(globalApprovedLeases))
447
+ for id := range globalApprovedLeases {
448
+ result = append(result, id)
449
+ }
450
+ return result
451
+}
452
+
453
+func isLeaseDenied(leaseID string) bool {
454
+ approvalMu.RLock()
455
+ defer approvalMu.RUnlock()
456
+ _, ok := globalDeniedLeases[leaseID]
457
+ return ok
458
+}
459
+
460
+func denyLease(leaseID string) {
461
+ approvalMu.Lock()
462
+ defer approvalMu.Unlock()
463
+ globalDeniedLeases[leaseID] = struct{}{}
464
+ // Remove from approved if exists
465
+ delete(globalApprovedLeases, leaseID)
466
+}
467
+
468
+func undenyLease(leaseID string) {
469
+ approvalMu.Lock()
470
+ defer approvalMu.Unlock()
471
+ delete(globalDeniedLeases, leaseID)
472
+}
473
+
474
+func getDeniedLeases() []string {
475
+ approvalMu.RLock()
476
+ defer approvalMu.RUnlock()
477
+ result := make([]string, 0, len(globalDeniedLeases))
478
+ for id := range globalDeniedLeases {
479
+ result = append(result, id)
480
+ }
481
+ return result
482
}
483
484
var (
@@ -259,9 +507,18 @@ func saveAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
507
508
bpsLimits := bpsManager.GetAllBPSLimits()
509
510
+ var bannedIPs []string
511
+ if globalIPManager != nil {
512
+ bannedIPs = globalIPManager.GetBannedIPs()
513
+ }
514
+
515
settings := AdminSettings{
263
- BannedLeases: banned,
264
- BPSLimits: bpsLimits,
516
+ BannedLeases: banned,
517
+ BPSLimits: bpsLimits,
518
+ ApprovalMode: getApprovalMode(),
519
+ ApprovedLeases: getApprovedLeases(),
520
+ DeniedLeases: getDeniedLeases(),
521
+ BannedIPs: bannedIPs,
522
}
523
524
data, err := json.MarshalIndent(settings, "", " ")
@@ -286,7 +543,7 @@ func saveAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
543
log.Debug().Str("path", adminSettingsPath).Msg("[Admin] Saved admin settings")
544
}
545
289
-func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
546
+func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager, ipManager *IPManager) {
547
adminSettingsMu.Lock()
548
defer adminSettingsMu.Unlock()
549
@@ -316,8 +573,32 @@ func loadAdminSettings(serv *portal.RelayServer, bpsManager *BPSManager) {
573
bpsManager.SetBPSLimit(leaseID, bps)
574
}
575
576
+ // Load approval mode
577
+ if settings.ApprovalMode != "" {
578
+ setApprovalMode(settings.ApprovalMode)
579
+ }
580
+
581
+ // Load approved leases
582
+ for _, leaseID := range settings.ApprovedLeases {
583
+ approveLease(leaseID)
584
+ }
585
+
586
+ // Load denied leases
587
+ for _, leaseID := range settings.DeniedLeases {
588
+ denyLease(leaseID)
589
+ }
590
+
591
+ // Load banned IPs
592
+ if ipManager != nil && len(settings.BannedIPs) > 0 {
593
+ ipManager.SetBannedIPs(settings.BannedIPs)
594
+ }
595
+
596
log.Info().
597
Int("banned_count", len(settings.BannedLeases)).
598
Int("bps_limits_count", len(settings.BPSLimits)).
599
+ Str("approval_mode", string(getApprovalMode())).
600
+ Int("approved_count", len(settings.ApprovedLeases)).
601
+ Int("denied_count", len(settings.DeniedLeases)).
602
+ Int("banned_ips_count", len(settings.BannedIPs)).
603
Msg("[Admin] Loaded admin settings")
604
}
cmd/relay-server/frontend/src/components/CloseIcon.tsx
new
+17
@@ -0,0 +1,17 @@
1
+import clsx from "clsx";
2
+
3
+export const CloseIcon = ({ className }: { className?: string }) => (
4
+ <svg
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ className={clsx("w-5 h-5", className)}
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ strokeWidth="2"
11
+ strokeLinecap="round"
12
+ strokeLinejoin="round"
13
+ >
14
+ <line x1="18" y1="6" x2="6" y2="18" />
15
+ <line x1="6" y1="6" x2="18" y2="18" />
16
+ </svg>
17
+);
cmd/relay-server/frontend/src/components/FloatingActionBar.tsx
new
+101
@@ -0,0 +1,101 @@
1
+import { useState } from "react";
2
+import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+} from "@/components/ui/select";
9
+import { Button } from "@/components/ui/button";
10
+
11
+type BulkAction = "approve" | "deny" | "ban";
12
+
13
+interface FloatingActionBarProps {
14
+ selectedCount: number;
15
+ totalCount: number;
16
+ isAllSelected: boolean;
17
+ onSelectAll: () => void;
18
+ onApprove: () => void;
19
+ onDeny: () => void;
20
+ onBan: () => void;
21
+}
22
+
23
+export const FloatingActionBar = ({
24
+ selectedCount,
25
+ totalCount,
26
+ isAllSelected,
27
+ onSelectAll,
28
+ onApprove,
29
+ onDeny,
30
+ onBan,
31
+}: FloatingActionBarProps) => {
32
+ const [selectedAction, setSelectedAction] = useState<BulkAction>("approve");
33
+
34
+ const handleExecute = () => {
35
+ switch (selectedAction) {
36
+ case "approve":
37
+ onApprove();
38
+ break;
39
+ case "deny":
40
+ onDeny();
41
+ break;
42
+ case "ban":
43
+ onBan();
44
+ break;
45
+ }
46
+ };
47
+
48
+ const getExecuteButtonStyle = () => {
49
+ switch (selectedAction) {
50
+ case "approve":
51
+ return "bg-green-600 hover:bg-green-700";
52
+ case "deny":
53
+ return "bg-red-600 hover:bg-red-700";
54
+ case "ban":
55
+ return "bg-orange-600 hover:bg-orange-700";
56
+ }
57
+ };
58
+
59
+ return (
60
+ <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in duration-200">
61
+ <div className="flex items-center gap-2 px-3 py-2 bg-background rounded-xl shadow-2xl border border-foreground/20">
62
+ <button
63
+ onClick={onSelectAll}
64
+ className={`px-3 h-10 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
65
+ isAllSelected
66
+ ? "bg-primary text-primary-foreground"
67
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
68
+ }`}
69
+ >
70
+ {isAllSelected ? "Deselect" : "Select All"}
71
+ </button>
72
+ <span className="text-sm font-medium text-foreground whitespace-nowrap px-1">
73
+ {selectedCount}/{totalCount}
74
+ </span>
75
+ {selectedCount > 0 && (
76
+ <>
77
+ <Select
78
+ value={selectedAction}
79
+ onValueChange={(v) => setSelectedAction(v as BulkAction)}
80
+ >
81
+ <SelectTrigger className="w-[100px] h-10">
82
+ <SelectValue />
83
+ </SelectTrigger>
84
+ <SelectContent>
85
+ <SelectItem value="approve">Approve</SelectItem>
86
+ <SelectItem value="deny">Deny</SelectItem>
87
+ <SelectItem value="ban">Ban</SelectItem>
88
+ </SelectContent>
89
+ </Select>
90
+ <Button
91
+ onClick={handleExecute}
92
+ className={`h-10 px-4 text-white ${getExecuteButtonStyle()}`}
93
+ >
94
+ Run
95
+ </Button>
96
+ </>
97
+ )}
98
+ </div>
99
+ </div>
100
+ );
101
+};
cmd/relay-server/frontend/src/components/Header.tsx
+4
-2
@@ -2,12 +2,14 @@ import { useEffect, useState } from "react";
2
import { Moon, Sun } from "lucide-react";
3
import { Button } from "@/components/ui/button";
4
import { TunnelCommandModal } from "@/components/TunnelCommandModal";
5
+import clsx from "clsx";
6
7
interface HeaderProps {
8
title?: string;
9
+ isAdmin?: boolean;
10
}
11
10
-export function Header({ title = "PORTAL" }: HeaderProps) {
12
+export function Header({ title = "PORTAL", isAdmin }: HeaderProps) {
13
const [theme, setTheme] = useState<"light" | "dark">("dark");
14
15
useEffect(() => {
@@ -87,7 +89,7 @@ export function Header({ title = "PORTAL" }: HeaderProps) {
89
</button>
90
<TunnelCommandModal
91
trigger={
90
- <Button>
92
+ <Button className={clsx(isAdmin && "hidden sm:block")}>
93
<span className="truncate">Add Your Server</span>
94
</Button>
95
}
cmd/relay-server/frontend/src/components/SearchBar.tsx
+46
-45
@@ -1,14 +1,10 @@
1
-import { Search } from "lucide-react";
1
+import { Search, Settings } from "lucide-react";
2
import { Input } from "@/components/ui/input";
3
-import {
4
- Select,
5
- SelectContent,
6
- SelectItem,
7
- SelectTrigger,
8
- SelectValue,
9
-} from "@/components/ui/select";
3
import type { SortOption, StatusFilter } from "@/types/filters";
4
import { TagCombobox } from "@/components/TagCombobox";
5
+import { Dispatch, SetStateAction } from "react";
6
+import { StatusSelect } from "@/components/select/StatusSelect";
7
+import { SortbySelect } from "@/components/select/SortbySelect";
8
9
interface SearchBarProps {
10
searchQuery: string;
@@ -21,6 +17,8 @@ interface SearchBarProps {
17
selectedTags: string[];
18
onAddTag: (tag: string) => void;
19
onRemoveTag: (tag: string) => void;
20
+ hideFiltersOnMobile?: boolean;
21
+ setShowFilterModal: Dispatch<SetStateAction<boolean>>;
22
}
23
24
export function SearchBar({
@@ -34,49 +32,52 @@ export function SearchBar({
32
selectedTags,
33
onAddTag,
34
onRemoveTag,
35
+ hideFiltersOnMobile = false,
36
+ setShowFilterModal,
37
}: SearchBarProps) {
38
return (
39
<div className="flex flex-wrap mt-4 sm:mt-6 items-center gap-3 px-4 sm:px-6">
40
- <label className="flex min-w-[220px] flex-1 items-stretch h-10">
41
- <div className="text-text-muted flex items-center justify-center pl-3 pr-2 rounded-l-md bg-border">
42
- <Search className="w-4 h-4" />
43
- </div>
44
- <Input
45
- placeholder="Search by server name..."
46
- value={searchQuery}
47
- onChange={(e) => onSearchChange(e.target.value)}
48
- className="rounded-l-none h-10"
49
- />
50
- </label>
40
+ <div className="flex gap-2 items-center w-full">
41
+ <label className="flex min-w-[220px] flex-1 items-stretch h-10">
42
+ <div className="text-text-muted flex items-center justify-center pl-3 pr-2 rounded-l-md bg-border">
43
+ <Search className="w-4 h-4" />
44
+ </div>
45
+ <Input
46
+ placeholder="Search by server name..."
47
+ value={searchQuery}
48
+ onChange={(e) => onSearchChange(e.target.value)}
49
+ className="rounded-l-none h-10"
50
+ />
51
+ </label>
52
+ {/* Mobile filter button - only show for admin */}
53
+ {hideFiltersOnMobile && (
54
+ <button
55
+ onClick={() => setShowFilterModal(true)}
56
+ className="sm:hidden flex items-center justify-center w-10 h-10 rounded-lg bg-secondary hover:bg-secondary/80 transition-colors"
57
+ aria-label="Filter settings"
58
+ >
59
+ <Settings className="w-5 h-5 text-secondary-foreground" />
60
+ </button>
61
+ )}
62
+ </div>
63
52
- <Select value={status} onValueChange={onStatusChange}>
53
- <SelectTrigger className="w-[130px] h-10">
54
- <SelectValue placeholder="Status" />
55
- </SelectTrigger>
56
- <SelectContent>
57
- <SelectItem value="all">All Status</SelectItem>
58
- <SelectItem value="online">Online</SelectItem>
59
- <SelectItem value="offline">Offline</SelectItem>
60
- </SelectContent>
61
- </Select>
64
+ <StatusSelect
65
+ status={status}
66
+ onStatusChange={onStatusChange}
67
+ hideFiltersOnMobile={hideFiltersOnMobile}
68
+ />
69
63
- <Select value={sortBy} onValueChange={onSortByChange}>
64
- <SelectTrigger className="w-[150px] h-10">
65
- <SelectValue placeholder="Sort By" />
66
- </SelectTrigger>
67
- <SelectContent>
68
- <SelectItem value="default">Default</SelectItem>
69
- <SelectItem value="name-asc">Name (A-Z)</SelectItem>
70
- <SelectItem value="name-desc">Name (Z-A)</SelectItem>
71
- <SelectItem value="updated">Recently Updated</SelectItem>
72
- <SelectItem value="duration">Duration (Maintained)</SelectItem>
73
- <SelectItem value="description">Description</SelectItem>
74
- <SelectItem value="tags">Tags</SelectItem>
75
- <SelectItem value="owner">Owner</SelectItem>
76
- </SelectContent>
77
- </Select>
70
+ <SortbySelect
71
+ sortBy={sortBy}
72
+ onSortByChange={onSortByChange}
73
+ hideFiltersOnMobile={hideFiltersOnMobile}
74
+ />
75
79
- <div className="relative flex w-full sm:w-auto sm:min-w-[320px] flex-1">
76
+ <div
77
+ className={`relative flex w-full sm:w-auto sm:min-w-[320px] flex-1 ${
78
+ hideFiltersOnMobile ? "hidden sm:flex" : ""
79
+ }`}
80
+ >
81
<TagCombobox
82
availableTags={availableTags}
83
selectedTags={selectedTags}
cmd/relay-server/frontend/src/components/ServerCard.tsx
+214
-119
@@ -2,6 +2,15 @@ import { Link } from "react-router-dom";
2
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
3
import clsx from "clsx";
4
import { ReactNode, useState, useMemo } from "react";
5
+import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogDescription,
11
+ DialogFooter,
12
+} from "@/components/ui/dialog";
13
+import { Button } from "@/components/ui/button";
14
15
interface ServerCardProps {
16
serverId: number;
@@ -22,9 +31,19 @@ interface ServerCardProps {
31
showAdminControls?: boolean;
32
leaseId?: string;
33
isBanned?: boolean;
34
+ isApproved?: boolean;
35
+ isDenied?: boolean;
36
bps?: number;
37
+ ip?: string;
38
+ isIPBanned?: boolean;
39
onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
40
onBPSChange?: (leaseId: string, bps: number) => void;
41
+ onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
42
+ onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
43
+ onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
44
+ // Selection for bulk actions
45
+ isSelected?: boolean;
46
+ onToggleSelect?: (leaseId: string) => void;
47
}
48
49
export function ServerCard({
@@ -44,9 +63,19 @@ export function ServerCard({
63
showAdminControls = false,
64
leaseId,
65
isBanned = false,
66
+ isApproved = false,
67
+ isDenied = false,
68
bps = 0,
69
+ ip = "",
70
+ isIPBanned = false,
71
onBanStatusChange,
72
onBPSChange,
73
+ onApproveStatusChange,
74
+ onDenyStatusChange,
75
+ onIPBanStatusChange,
76
+ // Selection for bulk actions
77
+ isSelected = false,
78
+ onToggleSelect,
79
}: ServerCardProps) {
80
const [showBPSModal, setShowBPSModal] = useState(false);
81
const [bpsInput, setBpsInput] = useState(bps.toString());
@@ -79,6 +108,14 @@ export function ServerCard({
108
onToggleFavorite?.(serverId);
109
};
110
111
+ const handleSelectClick = (e: React.MouseEvent) => {
112
+ e.preventDefault();
113
+ e.stopPropagation();
114
+ if (leaseId && onToggleSelect) {
115
+ onToggleSelect(leaseId);
116
+ }
117
+ };
118
+
119
const handleBanClick = (e: React.MouseEvent) => {
120
e.preventDefault();
121
e.stopPropagation();
@@ -87,6 +124,30 @@ export function ServerCard({
124
}
125
};
126
127
+ const handleApproveClick = (e: React.MouseEvent) => {
128
+ e.preventDefault();
129
+ e.stopPropagation();
130
+ if (leaseId && onApproveStatusChange) {
131
+ onApproveStatusChange(leaseId, !isApproved);
132
+ }
133
+ };
134
+
135
+ const handleDenyClick = (e: React.MouseEvent) => {
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ if (leaseId && onDenyStatusChange) {
139
+ onDenyStatusChange(leaseId, !isDenied);
140
+ }
141
+ };
142
+
143
+ const handleIPBanClick = (e: React.MouseEvent) => {
144
+ e.preventDefault();
145
+ e.stopPropagation();
146
+ if (ip && onIPBanStatusChange) {
147
+ onIPBanStatusChange(ip, !isIPBanned);
148
+ }
149
+ };
150
+
151
const handleBPSSettingsClick = (e: React.MouseEvent) => {
152
e.preventDefault();
153
e.stopPropagation();
@@ -117,12 +178,6 @@ export function ServerCard({
178
return value.toString();
179
};
180
120
- const handleBPSModalClose = (e: React.MouseEvent) => {
121
- e.preventDefault();
122
- e.stopPropagation();
123
- setShowBPSModal(false);
124
- };
125
-
181
const formatBPS = (value: number): string => {
182
if (value === 0) return "Unlimited";
183
if (value >= 1_000_000_000)
@@ -172,26 +227,54 @@ export function ServerCard({
227
)}
228
style={{ ...(thumbnail && { backgroundImage: `url(${thumbnail})` }) }}
229
>
175
- {/* Favorite button */}
176
- <button
177
- onClick={handleFavoriteClick}
178
- className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200 cursor-pointer"
179
- aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
180
- >
181
- <svg
182
- xmlns="http://www.w3.org/2000/svg"
183
- viewBox="0 0 24 24"
184
- className="w-5 h-5 transition-colors duration-200"
185
- fill={isFavorite ? "currentColor" : "none"}
186
- stroke="currentColor"
187
- strokeWidth="2"
188
- strokeLinecap="round"
189
- strokeLinejoin="round"
190
- style={{ color: isFavorite ? "var(--primary)" : "currentColor" }}
230
+ {/* Admin mode: Checkbox for selection / Normal mode: Favorite star */}
231
+ {showAdminControls ? (
232
+ <button
233
+ onClick={handleSelectClick}
234
+ className={clsx(
235
+ "absolute top-3 right-3 z-10 p-1.5 rounded-full transition-colors duration-200 cursor-pointer border",
236
+ isSelected
237
+ ? "bg-primary border-primary"
238
+ : "bg-background/80 hover:bg-background border-foreground/30"
239
+ )}
240
+ aria-label={isSelected ? "Deselect" : "Select"}
241
+ >
242
+ <svg
243
+ xmlns="http://www.w3.org/2000/svg"
244
+ viewBox="0 0 24 24"
245
+ className="w-5 h-5 transition-colors duration-200"
246
+ fill="none"
247
+ stroke={isSelected ? "white" : "currentColor"}
248
+ strokeWidth="3"
249
+ strokeLinecap="round"
250
+ strokeLinejoin="round"
251
+ >
252
+ {isSelected && <polyline points="20 6 9 17 4 12" />}
253
+ </svg>
254
+ </button>
255
+ ) : (
256
+ <button
257
+ onClick={handleFavoriteClick}
258
+ className="absolute top-3 right-3 z-10 p-2 rounded-full bg-background/80 hover:bg-background transition-colors duration-200 cursor-pointer"
259
+ aria-label={
260
+ isFavorite ? "Remove from favorites" : "Add to favorites"
261
+ }
262
>
192
- <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
193
- </svg>
194
- </button>
263
+ <svg
264
+ xmlns="http://www.w3.org/2000/svg"
265
+ viewBox="0 0 24 24"
266
+ className="w-5 h-5 transition-colors duration-200"
267
+ fill={isFavorite ? "currentColor" : "none"}
268
+ stroke="currentColor"
269
+ strokeWidth="2"
270
+ strokeLinecap="round"
271
+ strokeLinejoin="round"
272
+ style={{ color: isFavorite ? "var(--primary)" : "currentColor" }}
273
+ >
274
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
275
+ </svg>
276
+ </button>
277
+ )}
278
279
{/* Content overlay - not part of hero transition */}
280
<div className="relative h-full w-full bg-background/80 rounded-xl flex flex-col gap-4 p-4 items-start text-start">
@@ -211,18 +294,12 @@ export function ServerCard({
294
)}
295
>
296
{online ? "Online" : "Offline"}
297
+ {formattedDuration && online && ` (${formattedDuration})`}
298
</p>
299
</div>
216
- <div className="flex items-center justify-between gap-2 max-w-full">
217
- <p className="text-foreground text-lg font-bold leading-tight truncate flex-1">
218
- {name}
219
- </p>
220
- {formattedDuration && online && (
221
- <span className="text-xs text-text-muted font-medium whitespace-nowrap">
222
- ({formattedDuration})
223
- </span>
224
- )}
225
- </div>
300
+ <p className="text-foreground text-lg font-bold leading-tight truncate max-w-full">
301
+ {name}
302
+ </p>
303
{description && (
304
<p className="text-text-muted text-sm font-normal leading-normal truncate max-w-full">
305
{description}
@@ -266,18 +343,50 @@ export function ServerCard({
343
Settings
344
</button>
345
</div>
269
- {/* Ban button */}
270
- <button
271
- onClick={handleBanClick}
272
- className={clsx(
273
- "w-full px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white",
274
- isBanned
275
- ? "bg-green-600 hover:bg-green-700"
276
- : "bg-red-600 hover:bg-red-700"
277
- )}
278
- >
279
- {isBanned ? "Unban" : "Ban"}
280
- </button>
346
+ {/* IP display (for approved items in both auto and manual mode) */}
347
+ {isApproved && ip && (
348
+ <div className="text-xs text-text-muted">
349
+ IP: <span className="font-mono">{ip}</span>
350
+ {isIPBanned && (
351
+ <span className="ml-2 text-red-500">(Banned)</span>
352
+ )}
353
+ </div>
354
+ )}
355
+ {/* Approve/Deny buttons: show only for unapproved items (both manual and auto mode) */}
356
+ {!isApproved && !isDenied ? (
357
+ <div className="flex gap-2 w-full">
358
+ <button
359
+ onClick={handleApproveClick}
360
+ className="flex-1 px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white bg-green-600 hover:bg-green-700"
361
+ >
362
+ Approve
363
+ </button>
364
+ <button
365
+ onClick={handleDenyClick}
366
+ className="flex-1 px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white bg-red-600 hover:bg-red-700"
367
+ >
368
+ Deny
369
+ </button>
370
+ </div>
371
+ ) : (
372
+ <button
373
+ onClick={ip ? handleIPBanClick : handleBanClick}
374
+ className={clsx(
375
+ "w-full px-4 py-2 rounded font-medium transition-colors cursor-pointer text-white",
376
+ (ip ? isIPBanned : isBanned)
377
+ ? "bg-green-600 hover:bg-green-700"
378
+ : "bg-red-600 hover:bg-red-700"
379
+ )}
380
+ >
381
+ {ip
382
+ ? isIPBanned
383
+ ? "Unban IP"
384
+ : "Ban IP"
385
+ : isBanned
386
+ ? "Unban"
387
+ : "Ban"}
388
+ </button>
389
+ )}
390
</div>
391
)}
392
</div>
@@ -286,84 +395,70 @@ export function ServerCard({
395
<div className="absolute top-2 left-2 h-full w-full bg-secondary/70 rounded-xl z-0" />
396
397
{/* BPS Settings Modal */}
289
- {showBPSModal && (
290
- <div
291
- className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
292
- onClick={handleBPSModalClose}
293
- >
294
- <div
295
- className="bg-background rounded-md p-6 w-96 shadow-xl border border-foreground/20"
296
- onClick={(e) => e.stopPropagation()}
297
- >
298
- <h3 className="text-lg font-bold mb-4">BPS Settings</h3>
299
- <p className="text-sm text-text-muted mb-2">
398
+ <Dialog open={showBPSModal} onOpenChange={setShowBPSModal}>
399
+ <DialogContent className="max-w-sm rounded-sm">
400
+ <DialogHeader>
401
+ <DialogTitle>BPS Settings</DialogTitle>
402
+ <DialogDescription>
403
Set bytes-per-second limit (0 = unlimited)
301
- </p>
302
- {/* Current value display */}
303
- <div className="text-center text-xl font-bold mb-4 text-primary">
304
- {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
305
- </div>
306
- {/* Slider */}
404
+ </DialogDescription>
405
+ </DialogHeader>
406
+ {/* Current value display */}
407
+ <div className="text-center text-xl font-bold text-primary">
408
+ {formatSliderLabel(parseInt(bpsInput, 10) || 0)}
409
+ </div>
410
+ {/* Slider */}
411
+ <input
412
+ type="range"
413
+ min="0"
414
+ max={bpsSteps.length - 1}
415
+ value={sliderIndex}
416
+ onChange={(e) => {
417
+ const idx = parseInt(e.target.value, 10);
418
+ handleSliderChange(idx);
419
+ }}
420
+ className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer"
421
+ />
422
+ {/* Step labels */}
423
+ <div className="flex justify-between text-xs text-text-muted">
424
+ {bpsSteps.map((step, idx) => (
425
+ <span
426
+ key={idx}
427
+ className={clsx(
428
+ "cursor-pointer hover:text-foreground transition-colors",
429
+ sliderIndex === idx && "text-primary font-medium"
430
+ )}
431
+ onClick={() => handleSliderChange(idx)}
432
+ >
433
+ {formatStepLabel(step)}
434
+ </span>
435
+ ))}
436
+ </div>
437
+ {/* Manual input */}
438
+ <div>
439
+ <label className="text-xs text-text-muted mb-1 block">
440
+ Custom value (B/s)
441
+ </label>
442
<input
308
- type="range"
309
- min="0"
310
- max={bpsSteps.length - 1}
311
- value={sliderIndex}
443
+ type="number"
444
+ value={bpsInput}
445
onChange={(e) => {
313
- const idx = parseInt(e.target.value, 10);
314
- handleSliderChange(idx);
446
+ setBpsInput(e.target.value);
447
+ syncSliderFromInput(parseInt(e.target.value, 10) || 0);
448
}}
316
- className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer mb-2"
449
+ className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
450
+ placeholder="Enter BPS limit"
451
+ min="0"
452
/>
318
- {/* Step labels */}
319
- <div className="flex justify-between text-xs text-text-muted mb-4">
320
- {bpsSteps.map((step, idx) => (
321
- <span
322
- key={idx}
323
- className={clsx(
324
- "cursor-pointer hover:text-foreground transition-colors",
325
- sliderIndex === idx && "text-primary font-medium"
326
- )}
327
- onClick={() => handleSliderChange(idx)}
328
- >
329
- {formatStepLabel(step)}
330
- </span>
331
- ))}
332
- </div>
333
- {/* Manual input */}
334
- <div className="mb-4">
335
- <label className="text-xs text-text-muted mb-1 block">
336
- Custom value (B/s)
337
- </label>
338
- <input
339
- type="number"
340
- value={bpsInput}
341
- onChange={(e) => {
342
- setBpsInput(e.target.value);
343
- syncSliderFromInput(parseInt(e.target.value, 10) || 0);
344
- }}
345
- className="w-full px-3 py-2 border border-foreground/20 rounded bg-background text-foreground"
346
- placeholder="Enter BPS limit"
347
- min="0"
348
- />
349
- </div>
350
- <div className="flex gap-2">
351
- <button
352
- onClick={handleBPSModalClose}
353
- className="flex-1 px-4 py-2 rounded bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors cursor-pointer"
354
- >
355
- Cancel
356
- </button>
357
- <button
358
- onClick={handleBPSSave}
359
- className="flex-1 px-4 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground transition-colors cursor-pointer"
360
- >
361
- Save
362
- </button>
363
- </div>
453
</div>
365
- </div>
366
- )}
454
+ <DialogFooter className="gap-2 sm:gap-0">
455
+ <Button variant="secondary" onClick={() => setShowBPSModal(false)}>
456
+ Cancel
457
+ </Button>
458
+ <Button onClick={handleBPSSave}>Save</Button>
459
+ </DialogFooter>
460
+ </DialogContent>
461
+ </Dialog>
462
</Wrapper>
463
);
464
}
cmd/relay-server/frontend/src/components/ServerListView.tsx
+253
-47
@@ -1,9 +1,22 @@
1
+import { useState } from "react";
2
import { Header } from "@/components/Header";
3
import { SearchBar } from "@/components/SearchBar";
4
import { ServerCard } from "@/components/ServerCard";
5
+import { TagCombobox } from "@/components/TagCombobox";
6
import type { ClientServer } from "@/hooks/useServerList";
5
-import type { AdminServer } from "@/hooks/useAdmin";
7
+import type { AdminServer, ApprovalMode } from "@/hooks/useAdmin";
8
import type { SortOption, StatusFilter } from "@/types/filters";
9
+import { StatusSelect } from "@/components/select/StatusSelect";
10
+import { BanStatusButtons } from "@/components/button/BanStatusButtons";
11
+import { SortbySelect } from "@/components/select/SortbySelect";
12
+import { ApprovalModeToggle } from "@/components/button/ApprovalModeToggle";
13
+import { FloatingActionBar } from "@/components/FloatingActionBar";
14
+import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogHeader,
18
+ DialogTitle,
19
+} from "@/components/ui/dialog";
20
21
// Admin-specific filter for ban status
22
export type BanFilter = "all" | "banned" | "active";
@@ -29,9 +42,18 @@ interface ServerListViewProps {
42
// Admin mode (optional)
43
isAdmin?: boolean;
44
banFilter?: BanFilter;
45
+ approvalMode?: ApprovalMode;
46
onBanFilterChange?: (value: BanFilter) => void;
47
onBanStatusChange?: (leaseId: string, isBan: boolean) => void;
48
onBPSChange?: (leaseId: string, bps: number) => void;
49
+ onApprovalModeChange?: (mode: ApprovalMode) => void;
50
+ onApproveStatusChange?: (leaseId: string, approve: boolean) => void;
51
+ onDenyStatusChange?: (leaseId: string, deny: boolean) => void;
52
+ onIPBanStatusChange?: (ip: string, isBan: boolean) => void;
53
+ // Bulk action handlers
54
+ onBulkApprove?: (leaseIds: string[]) => void;
55
+ onBulkDeny?: (leaseIds: string[]) => void;
56
+ onBulkBan?: (leaseIds: string[]) => void;
57
}
58
59
function isAdminServer(
@@ -57,61 +79,153 @@ export function ServerListView({
79
// Admin props
80
isAdmin = false,
81
banFilter = "all",
82
+ approvalMode = "auto",
83
onBanFilterChange,
84
onBanStatusChange,
85
onBPSChange,
86
+ onApprovalModeChange,
87
+ onApproveStatusChange,
88
+ onDenyStatusChange,
89
+ onIPBanStatusChange,
90
+ // Bulk action handlers
91
+ onBulkApprove,
92
+ onBulkDeny,
93
+ onBulkBan,
94
}: ServerListViewProps) {
95
+ const [showFilterModal, setShowFilterModal] = useState(false);
96
+ const [selectedLeaseIds, setSelectedLeaseIds] = useState<Set<string>>(
97
+ new Set()
98
+ );
99
+
100
+ // Toggle selection for a single card
101
+ const handleToggleSelect = (leaseId: string) => {
102
+ setSelectedLeaseIds((prev) => {
103
+ const next = new Set(prev);
104
+ if (next.has(leaseId)) {
105
+ next.delete(leaseId);
106
+ } else {
107
+ next.add(leaseId);
108
+ }
109
+ return next;
110
+ });
111
+ };
112
+
113
+ // Clear all selections
114
+ const handleClearSelection = () => {
115
+ setSelectedLeaseIds(new Set());
116
+ };
117
+
118
+ // Get all selectable lease IDs from filtered servers
119
+ const allLeaseIds = (filteredServers as (ClientServer | AdminServer)[])
120
+ .filter(isAdminServer)
121
+ .map((server) => server.peerId);
122
+
123
+ // Check if all items are selected
124
+ const isAllSelected =
125
+ allLeaseIds.length > 0 &&
126
+ allLeaseIds.every((id) => selectedLeaseIds.has(id));
127
+
128
+ // Select all / Deselect all
129
+ const handleSelectAll = () => {
130
+ if (isAllSelected) {
131
+ setSelectedLeaseIds(new Set());
132
+ } else {
133
+ setSelectedLeaseIds(new Set(allLeaseIds));
134
+ }
135
+ };
136
+
137
+ // Bulk action handlers
138
+ const handleBulkApprove = () => {
139
+ if (onBulkApprove && selectedLeaseIds.size > 0) {
140
+ onBulkApprove(Array.from(selectedLeaseIds));
141
+ handleClearSelection();
142
+ }
143
+ };
144
+
145
+ const handleBulkDeny = () => {
146
+ if (onBulkDeny && selectedLeaseIds.size > 0) {
147
+ onBulkDeny(Array.from(selectedLeaseIds));
148
+ handleClearSelection();
149
+ }
150
+ };
151
+
152
+ const handleBulkBan = () => {
153
+ if (onBulkBan && selectedLeaseIds.size > 0) {
154
+ onBulkBan(Array.from(selectedLeaseIds));
155
+ handleClearSelection();
156
+ }
157
+ };
158
+
159
+ // Admin filter content (Ban Status + Approval) - for desktop only
160
+ const AdminFilterContent = () => (
161
+ <>
162
+ {/* Ban Status Filter Buttons */}
163
+ {onBanFilterChange && (
164
+ <div className="flex items-center gap-3">
165
+ <span className="text-sm font-medium text-text-muted">
166
+ Ban Status
167
+ </span>
168
+ <BanStatusButtons
169
+ banFilter={banFilter}
170
+ onBanFilterChange={onBanFilterChange}
171
+ />
172
+ </div>
173
+ )}
174
+
175
+ {/* Approval Mode Toggle */}
176
+ {onApprovalModeChange && (
177
+ <div className="flex items-center gap-3">
178
+ <span className="text-sm font-medium text-text-muted">Approval</span>
179
+ <ApprovalModeToggle
180
+ approvalMode={approvalMode}
181
+ onApprovalModeChange={onApprovalModeChange}
182
+ />
183
+ </div>
184
+ )}
185
+ </>
186
+ );
187
+
188
return (
189
<div className="relative flex h-auto min-h-screen w-full flex-col">
190
<div className="flex h-full grow flex-col">
191
<div className="flex flex-1 justify-center">
68
- <div className="flex flex-col w-full max-w-6xl flex-1 px-4 md:px-8">
192
+ <div className="flex flex-col w-full max-w-6xl flex-1 px-0 md:px-8">
193
<div className="sticky top-0 z-10 bg-background pb-4 pt-5">
70
- <Header title={title} />
71
- <SearchBar
72
- searchQuery={searchQuery}
73
- onSearchChange={onSearchChange}
74
- status={status}
75
- onStatusChange={onStatusChange}
76
- sortBy={sortBy}
77
- onSortByChange={onSortByChange}
78
- availableTags={availableTags}
79
- selectedTags={selectedTags}
80
- onAddTag={onTagToggle}
81
- onRemoveTag={onTagToggle}
82
- />
83
- {isAdmin && onBanFilterChange && (
84
- <div className="flex gap-2 mt-4 px-4 sm:px-6">
85
- <button
86
- onClick={() => onBanFilterChange("all")}
87
- className={`px-4 py-2 rounded font-medium transition-colors ${
88
- banFilter === "all"
89
- ? "bg-primary text-primary-foreground"
90
- : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
91
- }`}
92
- >
93
- All
94
- </button>
95
- <button
96
- onClick={() => onBanFilterChange("active")}
97
- className={`px-4 py-2 rounded font-medium transition-colors ${
98
- banFilter === "active"
99
- ? "bg-green-600 text-white"
100
- : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
101
- }`}
102
- >
103
- Active
104
- </button>
105
- <button
106
- onClick={() => onBanFilterChange("banned")}
107
- className={`px-4 py-2 rounded font-medium transition-colors ${
108
- banFilter === "banned"
109
- ? "bg-red-600 text-white"
110
- : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
111
- }`}
112
- >
113
- Banned
114
- </button>
194
+ <Header title={title} isAdmin={isAdmin} />
195
+ <div className="flex items-center gap-2">
196
+ <div className="flex-1">
197
+ <SearchBar
198
+ searchQuery={searchQuery}
199
+ onSearchChange={onSearchChange}
200
+ status={status}
201
+ onStatusChange={onStatusChange}
202
+ sortBy={sortBy}
203
+ onSortByChange={onSortByChange}
204
+ availableTags={availableTags}
205
+ selectedTags={selectedTags}
206
+ onAddTag={onTagToggle}
207
+ onRemoveTag={onTagToggle}
208
+ hideFiltersOnMobile={isAdmin}
209
+ setShowFilterModal={setShowFilterModal}
210
+ />
211
+ </div>
212
+ </div>
213
+ {/* Desktop filters - hidden on mobile */}
214
+ {isAdmin && (
215
+ <div className="hidden sm:flex flex-wrap items-center gap-6 mt-4 px-4 sm:px-6">
216
+ <AdminFilterContent />
217
+ </div>
218
+ )}
219
+ {/* Mobile-only Approval filter - always visible outside modal */}
220
+ {isAdmin && onApprovalModeChange && (
221
+ <div className="sm:hidden flex items-center gap-3 mt-4 px-4">
222
+ <span className="text-sm font-medium text-text-muted">
223
+ Approval
224
+ </span>
225
+ <ApprovalModeToggle
226
+ approvalMode={approvalMode}
227
+ onApprovalModeChange={onApprovalModeChange}
228
+ />
229
</div>
230
)}
231
</div>
@@ -152,9 +266,29 @@ export function ServerListView({
266
isBanned={
267
isAdminServer(server) ? server.isBanned : undefined
268
}
269
+ isApproved={
270
+ isAdminServer(server) ? server.isApproved : undefined
271
+ }
272
+ isDenied={
273
+ isAdminServer(server) ? server.isDenied : undefined
274
+ }
275
bps={isAdminServer(server) ? server.bps : undefined}
276
+ ip={isAdminServer(server) ? server.ip : undefined}
277
+ isIPBanned={
278
+ isAdminServer(server) ? server.isIPBanned : undefined
279
+ }
280
onBanStatusChange={onBanStatusChange}
281
onBPSChange={onBPSChange}
282
+ onApproveStatusChange={onApproveStatusChange}
283
+ onDenyStatusChange={onDenyStatusChange}
284
+ onIPBanStatusChange={onIPBanStatusChange}
285
+ // Selection for bulk actions
286
+ isSelected={
287
+ isAdminServer(server)
288
+ ? selectedLeaseIds.has(server.peerId)
289
+ : false
290
+ }
291
+ onToggleSelect={handleToggleSelect}
292
/>
293
))
294
) : (
@@ -169,6 +303,78 @@ export function ServerListView({
303
</div>
304
</div>
305
</div>
306
+
307
+ {/* Filter Modal for mobile - contains SearchBar filters (Status, Sort, Tag) */}
308
+ <Dialog open={showFilterModal} onOpenChange={setShowFilterModal}>
309
+ <DialogContent className="sm:hidden max-w-sm rounded-sm">
310
+ <DialogHeader>
311
+ <DialogTitle>Filters</DialogTitle>
312
+ </DialogHeader>
313
+ <div className="flex flex-col gap-4">
314
+ {/* Online/Offline Status Filter - Select style */}
315
+ <div className="flex flex-col gap-2">
316
+ <span className="text-sm font-medium text-text-muted">
317
+ Status
318
+ </span>
319
+ <StatusSelect
320
+ status={status}
321
+ onStatusChange={onStatusChange}
322
+ className="w-full!"
323
+ />
324
+ </div>
325
+
326
+ {/* Admin Ban Status Filter - All/Active/Banned (button group style) */}
327
+ {isAdmin && onBanFilterChange && (
328
+ <div className="flex flex-col gap-2">
329
+ <span className="text-sm font-medium text-text-muted">
330
+ Ban Status
331
+ </span>
332
+ <BanStatusButtons
333
+ className="[&>button]:w-full"
334
+ banFilter={banFilter}
335
+ onBanFilterChange={onBanFilterChange}
336
+ />
337
+ </div>
338
+ )}
339
+
340
+ {/* Sort By */}
341
+ <div className="flex flex-col gap-2">
342
+ <span className="text-sm font-medium text-text-muted">
343
+ Sort By
344
+ </span>
345
+ <SortbySelect
346
+ className="w-full!"
347
+ sortBy={sortBy}
348
+ onSortByChange={onSortByChange}
349
+ />
350
+ </div>
351
+
352
+ {/* Tag Filter */}
353
+ <div className="flex flex-col gap-2">
354
+ <span className="text-sm font-medium text-text-muted">Tags</span>
355
+ <TagCombobox
356
+ availableTags={availableTags}
357
+ selectedTags={selectedTags}
358
+ onAdd={onTagToggle}
359
+ onRemove={onTagToggle}
360
+ />
361
+ </div>
362
+ </div>
363
+ </DialogContent>
364
+ </Dialog>
365
+
366
+ {/* Floating Action Bar - shows when items are selected in admin mode */}
367
+ {isAdmin && (
368
+ <FloatingActionBar
369
+ selectedCount={selectedLeaseIds.size}
370
+ totalCount={allLeaseIds.length}
371
+ isAllSelected={isAllSelected}
372
+ onSelectAll={handleSelectAll}
373
+ onApprove={handleBulkApprove}
374
+ onDeny={handleBulkDeny}
375
+ onBan={handleBulkBan}
376
+ />
377
+ )}
378
</div>
379
);
380
}
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+8
-3
@@ -55,7 +55,11 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
55
if (e.key === "Enter") {
56
e.preventDefault();
57
addRelayUrl(urlInput);
58
- } else if (e.key === "Backspace" && urlInput === "" && relayUrls.length > 0) {
58
+ } else if (
59
+ e.key === "Backspace" &&
60
+ urlInput === "" &&
61
+ relayUrls.length > 0
62
+ ) {
63
// Remove last URL when backspace on empty input
64
setRelayUrls(relayUrls.slice(0, -1));
65
}
@@ -65,7 +69,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
69
const command = useMemo(() => {
70
const hostVal = host === "" ? defaultHost : host;
71
const nameVal = name === "" ? defaultName : name;
68
- const relayUrlVal = relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
72
+ const relayUrlVal =
73
+ relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
74
return `curl -fsSL ${currentOrigin}/tunnel | HOST=${hostVal} NAME=${nameVal} RELAY_URL="${relayUrlVal}" sh`;
75
}, [currentOrigin, host, name, relayUrls]);
76
@@ -88,7 +93,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
93
</Button>
94
)}
95
</DialogTrigger>
91
- <DialogContent className="sm:max-w-[500px]">
96
+ <DialogContent className="sm:max-w-[500px] rounded-sm">
97
<DialogHeader>
98
<DialogTitle className="flex items-center gap-2">
99
<Terminal className="w-5 h-5" />
cmd/relay-server/frontend/src/components/button/ApprovalModeToggle.tsx
new
+34
@@ -0,0 +1,34 @@
1
+import type { ApprovalMode } from "@/hooks/useAdmin";
2
+
3
+interface ApprovalModeToggleProps {
4
+ approvalMode: ApprovalMode;
5
+ onApprovalModeChange: (mode: ApprovalMode) => void;
6
+}
7
+
8
+export const ApprovalModeToggle = ({
9
+ approvalMode,
10
+ onApprovalModeChange,
11
+}: ApprovalModeToggleProps) => (
12
+ <div className="flex rounded-lg overflow-hidden border border-foreground/20">
13
+ <button
14
+ onClick={() => onApprovalModeChange("auto")}
15
+ className={`px-4 h-10 text-sm font-medium transition-colors ${
16
+ approvalMode === "auto"
17
+ ? "bg-primary text-primary-foreground"
18
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
19
+ }`}
20
+ >
21
+ Auto
22
+ </button>
23
+ <button
24
+ onClick={() => onApprovalModeChange("manual")}
25
+ className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
26
+ approvalMode === "manual"
27
+ ? "bg-primary text-primary-foreground"
28
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
29
+ }`}
30
+ >
31
+ Manual
32
+ </button>
33
+ </div>
34
+);
cmd/relay-server/frontend/src/components/button/BanStatusButtons.tsx
new
+52
@@ -0,0 +1,52 @@
1
+import { BanFilter } from "@/components/ServerListView";
2
+import clsx from "clsx";
3
+
4
+interface BanStatusButtonsProps {
5
+ banFilter: string;
6
+ onBanFilterChange: (value: BanFilter) => void;
7
+ className?: string;
8
+}
9
+
10
+export const BanStatusButtons = ({
11
+ banFilter,
12
+ onBanFilterChange,
13
+ className,
14
+}: BanStatusButtonsProps) => (
15
+ <div
16
+ className={clsx(
17
+ "flex rounded-lg overflow-hidden border border-foreground/20",
18
+ className
19
+ )}
20
+ >
21
+ <button
22
+ onClick={() => onBanFilterChange("all")}
23
+ className={`px-4 h-10 text-sm font-medium transition-colors ${
24
+ banFilter === "all"
25
+ ? "bg-primary text-primary-foreground"
26
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
27
+ }`}
28
+ >
29
+ All
30
+ </button>
31
+ <button
32
+ onClick={() => onBanFilterChange("active")}
33
+ className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
34
+ banFilter === "active"
35
+ ? "bg-green-600 text-white"
36
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
37
+ }`}
38
+ >
39
+ Active
40
+ </button>
41
+ <button
42
+ onClick={() => onBanFilterChange("banned")}
43
+ className={`px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
44
+ banFilter === "banned"
45
+ ? "bg-red-600 text-white"
46
+ : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
47
+ }`}
48
+ >
49
+ Banned
50
+ </button>
51
+ </div>
52
+);
cmd/relay-server/frontend/src/components/select/SortbySelect.tsx
new
+45
@@ -0,0 +1,45 @@
1
+import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+} from "@/components/ui/select";
8
+import { SortOption } from "@/types/filters";
9
+import clsx from "clsx";
10
+
11
+interface SortbySelectProps {
12
+ sortBy: string;
13
+ onSortByChange: (value: SortOption) => void;
14
+ hideFiltersOnMobile?: boolean;
15
+ className?: string;
16
+}
17
+
18
+export const SortbySelect = ({
19
+ sortBy,
20
+ onSortByChange,
21
+ hideFiltersOnMobile,
22
+ className,
23
+}: SortbySelectProps) => (
24
+ <Select value={sortBy} onValueChange={onSortByChange}>
25
+ <SelectTrigger
26
+ className={clsx(
27
+ "w-[150px] h-10",
28
+ hideFiltersOnMobile && "hidden sm:flex",
29
+ className
30
+ )}
31
+ >
32
+ <SelectValue placeholder="Sort By" />
33
+ </SelectTrigger>
34
+ <SelectContent>
35
+ <SelectItem value="default">Default</SelectItem>
36
+ <SelectItem value="name-asc">Name (A-Z)</SelectItem>
37
+ <SelectItem value="name-desc">Name (Z-A)</SelectItem>
38
+ <SelectItem value="updated">Recently Updated</SelectItem>
39
+ <SelectItem value="duration">Duration (Maintained)</SelectItem>
40
+ <SelectItem value="description">Description</SelectItem>
41
+ <SelectItem value="tags">Tags</SelectItem>
42
+ <SelectItem value="owner">Owner</SelectItem>
43
+ </SelectContent>
44
+ </Select>
45
+);
cmd/relay-server/frontend/src/components/select/StatusSelect.tsx
new
+40
@@ -0,0 +1,40 @@
1
+import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+} from "@/components/ui/select";
8
+import { StatusFilter } from "@/types/filters";
9
+import clsx from "clsx";
10
+
11
+interface StatusSelectProps {
12
+ status: string;
13
+ onStatusChange: (value: StatusFilter) => void;
14
+ hideFiltersOnMobile?: boolean;
15
+ className?: string;
16
+}
17
+
18
+export const StatusSelect = ({
19
+ status,
20
+ onStatusChange,
21
+ hideFiltersOnMobile,
22
+ className,
23
+}: StatusSelectProps) => (
24
+ <Select value={status} onValueChange={onStatusChange}>
25
+ <SelectTrigger
26
+ className={clsx(
27
+ "w-[130px] h-10",
28
+ hideFiltersOnMobile && "hidden sm:flex",
29
+ className
30
+ )}
31
+ >
32
+ <SelectValue placeholder="Status" />
33
+ </SelectTrigger>
34
+ <SelectContent>
35
+ <SelectItem value="all">All Status</SelectItem>
36
+ <SelectItem value="online">Online</SelectItem>
37
+ <SelectItem value="offline">Offline</SelectItem>
38
+ </SelectContent>
39
+ </Select>
40
+);
cmd/relay-server/frontend/src/hooks/useAdmin.ts
+189
-25
@@ -3,11 +3,18 @@ import type { ServerData, Metadata } from "@/hooks/useSSRData";
3
import { useList, type BaseServer } from "@/hooks/useList";
4
import type { BanFilter } from "@/components/ServerListView";
5
6
+// Approval mode type
7
+export type ApprovalMode = "auto" | "manual";
8
+
9
// Extended BaseServer with admin-specific fields
10
export interface AdminServer extends BaseServer {
11
peerId: string;
12
isBanned: boolean;
13
bps: number; // bytes-per-second limit (0 = unlimited)
14
+ isApproved: boolean; // whether lease is approved (for manual mode)
15
+ isDenied: boolean; // whether lease is denied (for manual mode)
16
+ ip: string; // client IP address (for IP-based ban)
17
+ isIPBanned: boolean; // whether the IP is banned
18
}
19
20
// Convert ServerData (from API) to AdminServer format
@@ -54,12 +61,17 @@ function convertServerDataToAdminServer(
61
peerId: row.Peer,
62
isBanned: bannedLeases.includes(row.Peer),
63
bps: row.BPS || 0,
64
+ isApproved: row.IsApproved || false,
65
+ isDenied: row.IsDenied || false,
66
+ ip: row.IP || "",
67
+ isIPBanned: row.IsIPBanned || false,
68
};
69
}
70
71
export function useAdmin() {
72
const [serverData, setServerData] = useState<ServerData[]>([]);
73
const [bannedLeases, setBannedLeases] = useState<string[]>([]);
74
+ const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
75
const [loading, setLoading] = useState(true);
76
const [error, setError] = useState("");
77
@@ -68,9 +80,10 @@ export function useAdmin() {
80
81
const fetchData = useCallback(async () => {
82
try {
71
- const [leasesRes, bannedRes] = await Promise.all([
83
+ const [leasesRes, bannedRes, settingsRes] = await Promise.all([
84
fetch("/admin/leases"),
85
fetch("/admin/leases/banned"),
86
+ fetch("/admin/settings"),
87
]);
88
89
if (!leasesRes.ok || !bannedRes.ok) {
@@ -90,6 +103,12 @@ export function useAdmin() {
103
}
104
});
105
setBannedLeases(decodedBanned);
106
+
107
+ // Load settings
108
+ if (settingsRes.ok) {
109
+ const settings = await settingsRes.json();
110
+ setApprovalMode(settings.approval_mode || "auto");
111
+ }
112
} catch (err: unknown) {
113
setError(err instanceof Error ? err.message : String(err));
114
} finally {
@@ -131,37 +150,173 @@ export function useAdmin() {
150
setBanFilter(value);
151
}, []);
152
134
- const handleBanStatus = useCallback(async (peerId: string, isBan: boolean) => {
153
+ const handleBanStatus = useCallback(
154
+ async (peerId: string, isBan: boolean) => {
155
+ try {
156
+ // URL-safe base64 encode the peer ID
157
+ const safeId = btoa(peerId)
158
+ .replace(/\+/g, "-")
159
+ .replace(/\//g, "_")
160
+ .replace(/=+$/, "");
161
+ await fetch(`/admin/leases/${safeId}/ban`, {
162
+ method: isBan ? "POST" : "DELETE",
163
+ });
164
+ fetchData();
165
+ } catch (err) {
166
+ console.error(err);
167
+ }
168
+ },
169
+ [fetchData]
170
+ );
171
+
172
+ const handleBPSChange = useCallback(
173
+ async (peerId: string, bps: number) => {
174
+ try {
175
+ // URL-safe base64 encode the peer ID
176
+ const safeId = btoa(peerId)
177
+ .replace(/\+/g, "-")
178
+ .replace(/\//g, "_")
179
+ .replace(/=+$/, "");
180
+ if (bps <= 0) {
181
+ await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
182
+ } else {
183
+ await fetch(`/admin/leases/${safeId}/bps`, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({ bps }),
187
+ });
188
+ }
189
+ fetchData();
190
+ } catch (err) {
191
+ console.error(err);
192
+ }
193
+ },
194
+ [fetchData]
195
+ );
196
+
197
+ const handleApprovalModeChange = useCallback(async (mode: ApprovalMode) => {
198
try {
136
- // URL-safe base64 encode the peer ID
137
- const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
138
- await fetch(`/admin/leases/${safeId}/ban`, {
139
- method: isBan ? "POST" : "DELETE"
199
+ await fetch("/admin/settings/approval-mode", {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ mode }),
203
});
141
- fetchData();
204
+ setApprovalMode(mode);
205
} catch (err) {
206
console.error(err);
207
}
145
- }, [fetchData]);
208
+ }, []);
209
147
- const handleBPSChange = useCallback(async (peerId: string, bps: number) => {
148
- try {
149
- // URL-safe base64 encode the peer ID
150
- const safeId = btoa(peerId).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
151
- if (bps <= 0) {
152
- await fetch(`/admin/leases/${safeId}/bps`, { method: "DELETE" });
153
- } else {
154
- await fetch(`/admin/leases/${safeId}/bps`, {
155
- method: "POST",
156
- headers: { "Content-Type": "application/json" },
157
- body: JSON.stringify({ bps }),
210
+ const handleApproveStatus = useCallback(
211
+ async (peerId: string, approve: boolean) => {
212
+ try {
213
+ const safeId = btoa(peerId)
214
+ .replace(/\+/g, "-")
215
+ .replace(/\//g, "_")
216
+ .replace(/=+$/, "");
217
+ await fetch(`/admin/leases/${safeId}/approve`, {
218
+ method: approve ? "POST" : "DELETE",
219
});
220
+ fetchData();
221
+ } catch (err) {
222
+ console.error(err);
223
}
160
- fetchData();
161
- } catch (err) {
162
- console.error(err);
163
- }
164
- }, [fetchData]);
224
+ },
225
+ [fetchData]
226
+ );
227
+
228
+ const handleDenyStatus = useCallback(
229
+ async (peerId: string, deny: boolean) => {
230
+ try {
231
+ const safeId = btoa(peerId)
232
+ .replace(/\+/g, "-")
233
+ .replace(/\//g, "_")
234
+ .replace(/=+$/, "");
235
+ await fetch(`/admin/leases/${safeId}/deny`, {
236
+ method: deny ? "POST" : "DELETE",
237
+ });
238
+ fetchData();
239
+ } catch (err) {
240
+ console.error(err);
241
+ }
242
+ },
243
+ [fetchData]
244
+ );
245
+
246
+ const handleIPBanStatus = useCallback(
247
+ async (ip: string, isBan: boolean) => {
248
+ try {
249
+ await fetch(`/admin/ips/${ip}/ban`, {
250
+ method: isBan ? "POST" : "DELETE",
251
+ });
252
+ fetchData();
253
+ } catch (err) {
254
+ console.error(err);
255
+ }
256
+ },
257
+ [fetchData]
258
+ );
259
+
260
+ // Bulk action handlers
261
+ const handleBulkApprove = useCallback(
262
+ async (peerIds: string[]) => {
263
+ try {
264
+ await Promise.all(
265
+ peerIds.map((peerId) => {
266
+ const safeId = btoa(peerId)
267
+ .replace(/\+/g, "-")
268
+ .replace(/\//g, "_")
269
+ .replace(/=+$/, "");
270
+ return fetch(`/admin/leases/${safeId}/approve`, { method: "POST" });
271
+ })
272
+ );
273
+ fetchData();
274
+ } catch (err) {
275
+ console.error(err);
276
+ }
277
+ },
278
+ [fetchData]
279
+ );
280
+
281
+ const handleBulkDeny = useCallback(
282
+ async (peerIds: string[]) => {
283
+ try {
284
+ await Promise.all(
285
+ peerIds.map((peerId) => {
286
+ const safeId = btoa(peerId)
287
+ .replace(/\+/g, "-")
288
+ .replace(/\//g, "_")
289
+ .replace(/=+$/, "");
290
+ return fetch(`/admin/leases/${safeId}/deny`, { method: "POST" });
291
+ })
292
+ );
293
+ fetchData();
294
+ } catch (err) {
295
+ console.error(err);
296
+ }
297
+ },
298
+ [fetchData]
299
+ );
300
+
301
+ const handleBulkBan = useCallback(
302
+ async (peerIds: string[]) => {
303
+ try {
304
+ await Promise.all(
305
+ peerIds.map((peerId) => {
306
+ const safeId = btoa(peerId)
307
+ .replace(/\+/g, "-")
308
+ .replace(/\//g, "_")
309
+ .replace(/=+$/, "");
310
+ return fetch(`/admin/leases/${safeId}/ban`, { method: "POST" });
311
+ })
312
+ );
313
+ fetchData();
314
+ } catch (err) {
315
+ console.error(err);
316
+ }
317
+ },
318
+ [fetchData]
319
+ );
320
321
return {
322
// Raw data
@@ -173,6 +328,7 @@ export function useAdmin() {
328
...listState,
329
// Admin-specific filter state
330
banFilter,
331
+ approvalMode,
332
// State
333
loading,
334
error,
@@ -180,6 +336,14 @@ export function useAdmin() {
336
handleBanFilterChange,
337
handleBanStatus,
338
handleBPSChange,
183
- refresh: fetchData
339
+ handleApprovalModeChange,
340
+ handleApproveStatus,
341
+ handleDenyStatus,
342
+ handleIPBanStatus,
343
+ // Bulk action handlers
344
+ handleBulkApprove,
345
+ handleBulkDeny,
346
+ handleBulkBan,
347
+ refresh: fetchData,
348
};
349
}
cmd/relay-server/frontend/src/hooks/useSSRData.ts
+4
@@ -23,6 +23,10 @@ export interface ServerData {
23
Hide: boolean;
24
Metadata: string;
25
BPS?: number; // bytes-per-second limit (0 = unlimited), admin only
26
+ IsApproved?: boolean; // whether lease is approved (for manual mode), admin only
27
+ IsDenied?: boolean; // whether lease is denied (for manual mode), admin only
28
+ IP?: string; // client IP address (for IP-based ban), admin only
29
+ IsIPBanned?: boolean; // whether the IP is banned, admin only
30
}
31
32
/**
cmd/relay-server/frontend/src/pages/Admin.tsx
+16
@@ -11,6 +11,7 @@ export function Admin() {
11
sortBy,
12
selectedTags,
13
banFilter,
14
+ approvalMode,
15
favorites,
16
loading,
17
error,
@@ -22,6 +23,13 @@ export function Admin() {
23
handleToggleFavorite,
24
handleBanStatus,
25
handleBPSChange,
26
+ handleApprovalModeChange,
27
+ handleApproveStatus,
28
+ handleDenyStatus,
29
+ handleIPBanStatus,
30
+ handleBulkApprove,
31
+ handleBulkDeny,
32
+ handleBulkBan,
33
} = useAdmin();
34
35
if (loading) return <div className="p-8 text-foreground">Loading...</div>;
@@ -46,9 +54,17 @@ export function Admin() {
54
// Admin-specific props
55
isAdmin={true}
56
banFilter={banFilter}
57
+ approvalMode={approvalMode}
58
onBanFilterChange={handleBanFilterChange}
59
onBanStatusChange={handleBanStatus}
60
onBPSChange={handleBPSChange}
61
+ onApprovalModeChange={handleApprovalModeChange}
62
+ onApproveStatusChange={handleApproveStatus}
63
+ onDenyStatusChange={handleDenyStatus}
64
+ onIPBanStatusChange={handleIPBanStatus}
65
+ onBulkApprove={handleBulkApprove}
66
+ onBulkDeny={handleBulkDeny}
67
+ onBulkBan={handleBulkBan}
68
/>
69
</SsgoiTransition>
70
);
cmd/relay-server/ip_manager.go
new
+168
@@ -0,0 +1,168 @@
1
+package main
2
+
3
+import (
4
+ "net"
5
+ "net/http"
6
+ "strings"
7
+ "sync"
8
+)
9
+
10
+// IPManager manages IP-based bans and lease-to-IP mapping
11
+type IPManager struct {
12
+ mu sync.RWMutex
13
+ bannedIPs map[string]struct{} // set of banned IPs
14
+ leaseToIP map[string]string // lease ID -> IP address
15
+ ipToLeases map[string][]string // IP -> list of lease IDs (for lookup)
16
+}
17
+
18
+// NewIPManager creates a new IP manager
19
+func NewIPManager() *IPManager {
20
+ return &IPManager{
21
+ bannedIPs: make(map[string]struct{}),
22
+ leaseToIP: make(map[string]string),
23
+ ipToLeases: make(map[string][]string),
24
+ }
25
+}
26
+
27
+// BanIP adds an IP to the ban list
28
+func (m *IPManager) BanIP(ip string) {
29
+ m.mu.Lock()
30
+ defer m.mu.Unlock()
31
+ m.bannedIPs[ip] = struct{}{}
32
+}
33
+
34
+// UnbanIP removes an IP from the ban list
35
+func (m *IPManager) UnbanIP(ip string) {
36
+ m.mu.Lock()
37
+ defer m.mu.Unlock()
38
+ delete(m.bannedIPs, ip)
39
+}
40
+
41
+// IsIPBanned checks if an IP is banned
42
+func (m *IPManager) IsIPBanned(ip string) bool {
43
+ m.mu.RLock()
44
+ defer m.mu.RUnlock()
45
+ _, banned := m.bannedIPs[ip]
46
+ return banned
47
+}
48
+
49
+// GetBannedIPs returns all banned IPs
50
+func (m *IPManager) GetBannedIPs() []string {
51
+ m.mu.RLock()
52
+ defer m.mu.RUnlock()
53
+ result := make([]string, 0, len(m.bannedIPs))
54
+ for ip := range m.bannedIPs {
55
+ result = append(result, ip)
56
+ }
57
+ return result
58
+}
59
+
60
+// SetBannedIPs sets the banned IPs list (for loading from settings)
61
+func (m *IPManager) SetBannedIPs(ips []string) {
62
+ m.mu.Lock()
63
+ defer m.mu.Unlock()
64
+ m.bannedIPs = make(map[string]struct{}, len(ips))
65
+ for _, ip := range ips {
66
+ m.bannedIPs[ip] = struct{}{}
67
+ }
68
+}
69
+
70
+// RegisterLeaseIP associates a lease ID with an IP address
71
+func (m *IPManager) RegisterLeaseIP(leaseID, ip string) {
72
+ m.mu.Lock()
73
+ defer m.mu.Unlock()
74
+
75
+ // Remove old mapping if exists
76
+ if oldIP, exists := m.leaseToIP[leaseID]; exists && oldIP != ip {
77
+ m.removeLeaseFromIP(leaseID, oldIP)
78
+ }
79
+
80
+ m.leaseToIP[leaseID] = ip
81
+ m.ipToLeases[ip] = append(m.ipToLeases[ip], leaseID)
82
+}
83
+
84
+// removeLeaseFromIP removes a lease from IP's lease list (must hold lock)
85
+func (m *IPManager) removeLeaseFromIP(leaseID, ip string) {
86
+ leases := m.ipToLeases[ip]
87
+ for i, id := range leases {
88
+ if id == leaseID {
89
+ m.ipToLeases[ip] = append(leases[:i], leases[i+1:]...)
90
+ break
91
+ }
92
+ }
93
+ if len(m.ipToLeases[ip]) == 0 {
94
+ delete(m.ipToLeases, ip)
95
+ }
96
+}
97
+
98
+// GetLeaseIP returns the IP address for a lease ID
99
+func (m *IPManager) GetLeaseIP(leaseID string) string {
100
+ m.mu.RLock()
101
+ defer m.mu.RUnlock()
102
+ return m.leaseToIP[leaseID]
103
+}
104
+
105
+// GetIPLeases returns all lease IDs for an IP
106
+func (m *IPManager) GetIPLeases(ip string) []string {
107
+ m.mu.RLock()
108
+ defer m.mu.RUnlock()
109
+ result := make([]string, len(m.ipToLeases[ip]))
110
+ copy(result, m.ipToLeases[ip])
111
+ return result
112
+}
113
+
114
+// ExtractClientIP extracts the client IP from an HTTP request
115
+func ExtractClientIP(r *http.Request) string {
116
+ // Check X-Forwarded-For header first (for proxied requests)
117
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
118
+ // X-Forwarded-For can contain multiple IPs, take the first one
119
+ if idx := strings.Index(xff, ","); idx != -1 {
120
+ return strings.TrimSpace(xff[:idx])
121
+ }
122
+ return strings.TrimSpace(xff)
123
+ }
124
+
125
+ // Check X-Real-IP header
126
+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
127
+ return strings.TrimSpace(xri)
128
+ }
129
+
130
+ // Fall back to RemoteAddr
131
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
132
+ if err != nil {
133
+ return r.RemoteAddr
134
+ }
135
+ return ip
136
+}
137
+
138
+// Global IP manager instance
139
+var globalIPManager *IPManager
140
+
141
+// pendingIPs stores recent connection IPs in a circular buffer for lease association
142
+var (
143
+ pendingIPsMu sync.Mutex
144
+ pendingIPsQueue []string
145
+ pendingIPsMax = 100 // Keep last 100 IPs
146
+)
147
+
148
+// storePendingIP stores a client IP for later association with a lease
149
+func storePendingIP(ip string) {
150
+ pendingIPsMu.Lock()
151
+ defer pendingIPsMu.Unlock()
152
+ pendingIPsQueue = append(pendingIPsQueue, ip)
153
+ if len(pendingIPsQueue) > pendingIPsMax {
154
+ pendingIPsQueue = pendingIPsQueue[1:]
155
+ }
156
+}
157
+
158
+// popPendingIP retrieves and removes the oldest pending IP
159
+func popPendingIP() string {
160
+ pendingIPsMu.Lock()
161
+ defer pendingIPsMu.Unlock()
162
+ if len(pendingIPsQueue) == 0 {
163
+ return ""
164
+ }
165
+ ip := pendingIPsQueue[0]
166
+ pendingIPsQueue = pendingIPsQueue[1:]
167
+ return ip
168
+}
cmd/relay-server/main.go
+11
-3
@@ -89,11 +89,19 @@ func runServer() error {
89
bpsManager.SetDefaultBPS(int64(flagLeaseBPS))
90
}
91
92
- // Load persisted admin settings (ban list, BPS limits)
93
- loadAdminSettings(serv, bpsManager)
92
+ // Create IP manager for IP-based bans
93
+ ipManager := NewIPManager()
94
+ globalIPManager = ipManager
95
95
- // Register relay callback for BPS handling
96
+ // Load persisted admin settings (ban list, BPS limits, IP bans)
97
+ loadAdminSettings(serv, bpsManager, ipManager)
98
+
99
+ // Register relay callback for BPS handling and IP tracking
100
serv.SetEstablishRelayCallback(func(clientStream, leaseStream *yamux.Stream, leaseID string) {
101
+ // Associate pending IP with this lease
102
+ if ip := popPendingIP(); ip != "" && globalIPManager != nil {
103
+ globalIPManager.RegisterLeaseIP(leaseID, ip)
104
+ }
105
establishRelayWithBPS(clientStream, leaseStream, leaseID, bpsManager)
106
})
107
cmd/relay-server/view.go
+24
-1
@@ -92,11 +92,25 @@ func serveHTTP(addr string, serv *portal.RelayServer, bpsManager *BPSManager, no
92
return
93
}
94
95
+ // Check if IP is banned
96
+ clientIP := ExtractClientIP(r)
97
+ if globalIPManager != nil && globalIPManager.IsIPBanned(clientIP) {
98
+ log.Warn().Str("ip", clientIP).Msg("[server] connection rejected: IP banned")
99
+ http.Error(w, "forbidden", http.StatusForbidden)
100
+ return
101
+ }
102
+
103
stream, wsConn, err := utils.UpgradeToWSStream(w, r, nil)
104
if err != nil {
105
log.Error().Err(err).Msg("[server] websocket upgrade failed")
106
return
107
}
108
+
109
+ // Store pending IP for lease association (will be linked when lease is registered)
110
+ if globalIPManager != nil && clientIP != "" {
111
+ storePendingIP(clientIP)
112
+ }
113
+
114
if err := serv.HandleConnection(stream); err != nil {
115
log.Error().Err(err).Msg("[server] websocket relay connection error")
116
wsConn.Close()
@@ -200,7 +214,11 @@ type leaseRow struct {
214
StaleRed bool
215
Hide bool
216
Metadata string
203
- BPS int64 // bytes-per-second limit (0 = unlimited)
217
+ BPS int64 // bytes-per-second limit (0 = unlimited)
218
+ IsApproved bool // whether lease is approved (for manual mode)
219
+ IsDenied bool // whether lease is denied (for manual mode)
220
+ IP string // client IP address (for IP-based ban)
221
+ IsIPBanned bool // whether the IP is banned
222
}
223
224
// convertLeaseEntriesToRows converts LeaseEntry data from LeaseManager to leaseRow format for the app page
@@ -236,6 +254,11 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
254
continue
255
}
256
257
+ // Skip unapproved leases in manual mode for user-facing list
258
+ if getApprovalMode() == ApprovalModeManual && !isLeaseApproved(identityID) {
259
+ continue
260
+ }
261
+
262
// Check hidden status
263
if metadata.Hide {
264
continue