Skip to content

Commit 07a70a7

Browse files
committed
Add connection profile and pangolin/proxy support
Introduce a unified ConnectionProfile system and UI for mobile: add lib/connection utilities, a ConnectionProfileForm component, and persistable profile handling. Refactor ApiContext to store a full connection profile (migrating legacy baseUrl/allowInsecure keys), validate drafts, build connection candidates, and attempt connections across candidates. Update API layer to accept a ConnectionProfile: ApiClient now supports proxy-basic (Basic auth + websocket credentials) and Pangolin (HTTP headers + websocket token param), adds verifyConnection, improved response parsing, and decorated websocket URLs. Update OfflineConnectionBanner, LoginPage, and MorePage to use the new profile flow and form. Add and adapt tests for client, context, services, routes, and connection helpers. Bump mobile package version to 2.3.2.
1 parent eaa4a2f commit 07a70a7

19 files changed

Lines changed: 1287 additions & 199 deletions

mobile/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mobile/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "crowdsec-manager-mobile",
33
"private": true,
44
"description": "Mobile companion app for Crowdsec",
5-
"version": "2.3.1",
5+
"version": "2.3.2",
66
"type": "module",
77
"scripts": {
88
"dev": "vite",
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { useState } from 'react';
2+
import { Button } from '@/components/ui/button';
3+
import { Input } from '@/components/ui/input';
4+
import { Switch } from '@/components/ui/switch';
5+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
6+
import {
7+
DEFAULT_PANGOLIN_TOKEN_PARAM,
8+
type ConnectionMode,
9+
type ConnectionProfileDraft,
10+
} from '@/lib/connection';
11+
12+
interface ConnectionProfileFormProps {
13+
value: ConnectionProfileDraft;
14+
onChange: (next: ConnectionProfileDraft) => void;
15+
disabled?: boolean;
16+
}
17+
18+
export function ConnectionProfileForm({
19+
value,
20+
onChange,
21+
disabled = false,
22+
}: ConnectionProfileFormProps) {
23+
const [showAdvanced, setShowAdvanced] = useState(
24+
value.pangolinTokenParam !== DEFAULT_PANGOLIN_TOKEN_PARAM,
25+
);
26+
const shouldShowAdvanced =
27+
showAdvanced || value.pangolinTokenParam !== DEFAULT_PANGOLIN_TOKEN_PARAM;
28+
29+
const update = <K extends keyof ConnectionProfileDraft>(
30+
key: K,
31+
nextValue: ConnectionProfileDraft[K],
32+
) => {
33+
onChange({
34+
...value,
35+
[key]: nextValue,
36+
});
37+
};
38+
39+
const updateMode = (mode: string) => {
40+
onChange({
41+
...value,
42+
mode: mode as ConnectionMode,
43+
});
44+
};
45+
46+
const urlPlaceholder =
47+
value.mode === 'pangolin'
48+
? 'pangolin.example.com'
49+
: value.allowInsecure
50+
? '192.168.1.10:8080'
51+
: 'your-server.example.com';
52+
53+
return (
54+
<div className="space-y-4">
55+
<div className="space-y-2">
56+
<div className="text-sm font-medium">Connection type</div>
57+
<Tabs value={value.mode} onValueChange={updateMode} className="w-full">
58+
<TabsList className="grid w-full grid-cols-3">
59+
<TabsTrigger value="direct" disabled={disabled}>
60+
Direct
61+
</TabsTrigger>
62+
<TabsTrigger value="proxy-basic" disabled={disabled}>
63+
Proxy
64+
</TabsTrigger>
65+
<TabsTrigger value="pangolin" disabled={disabled}>
66+
Pangolin
67+
</TabsTrigger>
68+
</TabsList>
69+
</Tabs>
70+
</div>
71+
72+
<div className="space-y-2">
73+
<label htmlFor="connection-url" className="text-sm font-medium">
74+
{value.mode === 'pangolin' ? 'Pangolin URL' : 'Server URL'}
75+
</label>
76+
<Input
77+
id="connection-url"
78+
type="text"
79+
inputMode="url"
80+
autoCapitalize="none"
81+
autoCorrect="off"
82+
spellCheck={false}
83+
placeholder={urlPlaceholder}
84+
value={value.baseUrl}
85+
onChange={(event) => update('baseUrl', event.target.value)}
86+
className="h-12 rounded-lg bg-card"
87+
disabled={disabled}
88+
/>
89+
<p className="text-xs text-muted-foreground">
90+
Domain, IP, or host:port. Include http:// or https:// only if you want
91+
to force the scheme.
92+
</p>
93+
</div>
94+
95+
{value.mode === 'proxy-basic' && (
96+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
97+
<div className="space-y-2">
98+
<label htmlFor="proxy-username" className="text-sm font-medium">
99+
Proxy username
100+
</label>
101+
<Input
102+
id="proxy-username"
103+
type="text"
104+
autoCapitalize="none"
105+
autoCorrect="off"
106+
spellCheck={false}
107+
autoComplete="username"
108+
value={value.proxyUsername}
109+
onChange={(event) => update('proxyUsername', event.target.value)}
110+
disabled={disabled}
111+
/>
112+
</div>
113+
<div className="space-y-2">
114+
<label htmlFor="proxy-password" className="text-sm font-medium">
115+
Proxy password
116+
</label>
117+
<Input
118+
id="proxy-password"
119+
type="password"
120+
autoComplete="current-password"
121+
value={value.proxyPassword}
122+
onChange={(event) => update('proxyPassword', event.target.value)}
123+
disabled={disabled}
124+
/>
125+
</div>
126+
</div>
127+
)}
128+
129+
{value.mode === 'pangolin' && (
130+
<div className="space-y-3 rounded-lg border border-border bg-card p-3">
131+
<div className="space-y-2">
132+
<label htmlFor="pangolin-token" className="text-sm font-medium">
133+
Pangolin access token
134+
</label>
135+
<Input
136+
id="pangolin-token"
137+
type="password"
138+
autoCapitalize="none"
139+
autoCorrect="off"
140+
spellCheck={false}
141+
placeholder="tokenId.tokenSecret"
142+
value={value.pangolinToken}
143+
onChange={(event) => update('pangolinToken', event.target.value)}
144+
disabled={disabled}
145+
/>
146+
<p className="text-xs text-muted-foreground">
147+
Use the format <code>tokenId.tokenSecret</code>.
148+
</p>
149+
</div>
150+
151+
<div className="flex items-center justify-between gap-3">
152+
<div>
153+
<div className="text-sm font-medium">
154+
Advanced token parameter
155+
</div>
156+
<div className="text-xs text-muted-foreground">
157+
Default is {DEFAULT_PANGOLIN_TOKEN_PARAM}; WebSockets use this
158+
query parameter.
159+
</div>
160+
</div>
161+
<Button
162+
type="button"
163+
variant="ghost"
164+
size="sm"
165+
onClick={() => setShowAdvanced((open) => !open)}
166+
disabled={disabled}
167+
>
168+
{shouldShowAdvanced ? 'Hide' : 'Show'}
169+
</Button>
170+
</div>
171+
172+
{shouldShowAdvanced && (
173+
<div className="space-y-2">
174+
<label
175+
htmlFor="pangolin-token-param"
176+
className="text-sm font-medium"
177+
>
178+
Token query parameter
179+
</label>
180+
<Input
181+
id="pangolin-token-param"
182+
type="text"
183+
autoCapitalize="none"
184+
autoCorrect="off"
185+
spellCheck={false}
186+
value={value.pangolinTokenParam}
187+
onChange={(event) =>
188+
update('pangolinTokenParam', event.target.value)
189+
}
190+
disabled={disabled}
191+
/>
192+
</div>
193+
)}
194+
</div>
195+
)}
196+
197+
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3">
198+
<div>
199+
<div className="text-sm font-medium">Insecure/LAN Mode</div>
200+
<div className="text-xs text-muted-foreground">
201+
{value.allowInsecure
202+
? 'HTTP and LAN URLs allowed'
203+
: 'HTTPS required unless explicitly enabled'}
204+
</div>
205+
</div>
206+
<Switch
207+
checked={value.allowInsecure}
208+
onCheckedChange={(checked) => update('allowInsecure', checked)}
209+
disabled={disabled}
210+
/>
211+
</div>
212+
</div>
213+
);
214+
}

mobile/src/components/OfflineConnectionBanner.tsx

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,53 +4,64 @@ import { useApi } from '@/contexts/ApiContext';
44
import { useMountEffect } from '@/hooks/useMountEffect';
55

66
export function OfflineConnectionBanner() {
7-
const { baseUrl, isAuthenticated } = useApi();
7+
const { api, connectionProfile, isAuthenticated } = useApi();
88
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
99
const [apiReachable, setApiReachable] = useState(true);
1010

11-
// Refs to avoid stale closures in the interval callback
12-
const baseUrlRef = useRef(baseUrl);
11+
const apiRef = useRef(api);
12+
const baseUrlRef = useRef(connectionProfile?.baseUrl ?? '');
1313
const isAuthRef = useRef(isAuthenticated);
1414
const isOnlineRef = useRef(isOnline);
15-
baseUrlRef.current = baseUrl;
15+
16+
apiRef.current = api;
17+
baseUrlRef.current = connectionProfile?.baseUrl ?? '';
1618
isAuthRef.current = isAuthenticated;
1719
isOnlineRef.current = isOnline;
1820

1921
useMountEffect(() => {
20-
// Online/offline listeners
2122
const onOnline = () => setIsOnline(true);
2223
const onOffline = () => setIsOnline(false);
2324
window.addEventListener('online', onOnline);
2425
window.addEventListener('offline', onOffline);
2526

26-
// API reachability check
27-
const controller = new AbortController();
27+
let cancelled = false;
28+
2829
const checkReachability = async () => {
29-
if (!isAuthRef.current || !baseUrlRef.current || !isOnlineRef.current) {
30-
setApiReachable(true);
30+
if (!isAuthRef.current || !apiRef.current || !isOnlineRef.current) {
31+
if (!cancelled) {
32+
setApiReachable(true);
33+
}
3134
return;
3235
}
36+
3337
try {
34-
const response = await fetch(`${baseUrlRef.current}/api/health/stack`, { signal: controller.signal });
35-
setApiReachable(response.ok);
38+
await apiRef.current.client.verifyConnection();
39+
if (!cancelled) {
40+
setApiReachable(true);
41+
}
3642
} catch {
37-
setApiReachable(false);
43+
if (!cancelled) {
44+
setApiReachable(false);
45+
}
3846
}
3947
};
40-
checkReachability();
41-
const interval = window.setInterval(checkReachability, 15000);
48+
49+
void checkReachability();
50+
const interval = window.setInterval(() => {
51+
void checkReachability();
52+
}, 15000);
4253

4354
return () => {
55+
cancelled = true;
4456
window.removeEventListener('online', onOnline);
4557
window.removeEventListener('offline', onOffline);
46-
controller.abort();
4758
window.clearInterval(interval);
4859
};
4960
});
5061

5162
if (!isOnline) {
5263
return (
53-
<div className="sticky top-0 z-50 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning-foreground flex items-center gap-2">
64+
<div className="sticky top-0 z-50 flex items-center gap-2 border-b border-warning/30 bg-warning/10 px-4 py-2 text-xs text-warning-foreground">
5465
<WifiOff className="h-3.5 w-3.5" />
5566
You are offline. Some actions may fail until connection is restored.
5667
</div>
@@ -59,9 +70,10 @@ export function OfflineConnectionBanner() {
5970

6071
if (isAuthenticated && !apiReachable) {
6172
return (
62-
<div className="sticky top-0 z-50 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-xs text-destructive flex items-center gap-2">
73+
<div className="sticky top-0 z-50 flex items-center gap-2 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-xs text-destructive">
6374
<CloudOff className="h-3.5 w-3.5" />
64-
API unreachable at {baseUrl}. Check server status or network path.
75+
API unreachable at {baseUrlRef.current}. Check server status or network
76+
path.
6577
</div>
6678
);
6779
}

0 commit comments

Comments
 (0)