@atercates/claude-deck 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -18
- package/app/api/auth/login/route.ts +57 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/api/auth/session/route.ts +29 -0
- package/app/api/auth/setup/route.ts +67 -0
- package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +1 -1
- package/app/api/projects/[id]/repositories/[repoId]/route.ts +1 -1
- package/app/api/sessions/[id]/fork/route.ts +1 -1
- package/app/api/sessions/[id]/pr/route.ts +1 -1
- package/app/api/sessions/[id]/preview/route.ts +1 -1
- package/app/api/sessions/[id]/route.ts +13 -4
- package/app/api/sessions/[id]/send-keys/route.ts +1 -1
- package/app/api/sessions/route.ts +2 -2
- package/app/login/page.tsx +192 -0
- package/app/setup/page.tsx +279 -0
- package/components/ConductorPanel.tsx +1 -1
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/DiffViewer/DiffModal.tsx +0 -1
- package/components/FileExplorer/index.tsx +1 -1
- package/components/GitDrawer/FileEditDialog.tsx +1 -1
- package/components/GitPanel/FileChanges.tsx +6 -2
- package/components/GitPanel/index.tsx +1 -1
- package/components/Pane/index.tsx +16 -15
- package/components/Projects/ProjectCard.tsx +1 -1
- package/components/QuickSwitcher.tsx +1 -0
- package/components/SessionList/SessionList.types.ts +1 -1
- package/components/SessionList/index.tsx +8 -8
- package/components/Terminal/hooks/useTerminalConnection.ts +3 -2
- package/components/Terminal/hooks/websocket-connection.ts +1 -0
- package/data/git/queries.ts +0 -1
- package/lib/auth/index.ts +15 -0
- package/lib/auth/password.ts +14 -0
- package/lib/auth/rate-limit.ts +40 -0
- package/lib/auth/session.ts +83 -0
- package/lib/auth/totp.ts +36 -0
- package/lib/claude/process-manager.ts +1 -1
- package/lib/code-search.ts +5 -5
- package/lib/db/index.ts +1 -1
- package/lib/db/queries.ts +64 -0
- package/lib/db/schema.ts +19 -0
- package/lib/db/types.ts +16 -0
- package/lib/git-history.ts +1 -1
- package/lib/git.ts +0 -1
- package/lib/multi-repo-git.ts +0 -1
- package/lib/projects.ts +29 -8
- package/package.json +8 -4
- package/scripts/agent-os +1 -1
- package/scripts/install.sh +2 -2
- package/server.ts +20 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Switch } from "@/components/ui/switch";
|
|
8
|
+
import { Loader2, Shield, Eye, EyeOff } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export default function SetupPage() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const [username, setUsername] = useState("");
|
|
13
|
+
const [password, setPassword] = useState("");
|
|
14
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
15
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
16
|
+
const [enableTotp, setEnableTotp] = useState(false);
|
|
17
|
+
const [totpSecret, setTotpSecret] = useState("");
|
|
18
|
+
const [totpCode, setTotpCode] = useState("");
|
|
19
|
+
const [qrDataUrl, setQrDataUrl] = useState("");
|
|
20
|
+
const [error, setError] = useState("");
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
fetch("/api/auth/session").then(async (res) => {
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
if (!data.needsSetup) {
|
|
27
|
+
router.push(data.authenticated ? "/" : "/login");
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}, [router]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (enableTotp && !totpSecret && username.length >= 3) {
|
|
34
|
+
generateTotp();
|
|
35
|
+
}
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit generateTotp and totpSecret to avoid regenerating secret on every render
|
|
37
|
+
}, [enableTotp, username]);
|
|
38
|
+
|
|
39
|
+
const generateTotp = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const { TOTP, Secret } = await import("otpauth");
|
|
42
|
+
const secret = new Secret({ size: 20 });
|
|
43
|
+
const totp = new TOTP({
|
|
44
|
+
issuer: "ClaudeDeck",
|
|
45
|
+
label: username,
|
|
46
|
+
algorithm: "SHA1",
|
|
47
|
+
digits: 6,
|
|
48
|
+
period: 30,
|
|
49
|
+
secret,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const uri = totp.toString();
|
|
53
|
+
setTotpSecret(secret.base32);
|
|
54
|
+
|
|
55
|
+
const QRCode = await import("qrcode");
|
|
56
|
+
const dataUrl = await QRCode.toDataURL(uri, {
|
|
57
|
+
width: 200,
|
|
58
|
+
margin: 2,
|
|
59
|
+
color: { dark: "#ffffff", light: "#00000000" },
|
|
60
|
+
});
|
|
61
|
+
setQrDataUrl(dataUrl);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error("Failed to generate TOTP:", err);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
setError("");
|
|
70
|
+
|
|
71
|
+
if (password !== confirmPassword) {
|
|
72
|
+
setError("Passwords do not match");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (password.length < 8) {
|
|
77
|
+
setError("Password must be at least 8 characters");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (enableTotp && totpCode.length !== 6) {
|
|
82
|
+
setError("Enter the 6-digit code from your authenticator app");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setLoading(true);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch("/api/auth/setup", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
username,
|
|
94
|
+
password,
|
|
95
|
+
...(enableTotp ? { totpSecret, totpCode } : {}),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const data = await res.json();
|
|
100
|
+
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
setError(data.error || "Setup failed");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
router.push("/");
|
|
107
|
+
router.refresh();
|
|
108
|
+
} catch {
|
|
109
|
+
setError("Connection error");
|
|
110
|
+
} finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="bg-background flex min-h-screen items-center justify-center p-4">
|
|
117
|
+
<div className="border-border bg-card w-full max-w-md rounded-xl border p-8 shadow-lg">
|
|
118
|
+
<div className="mb-8 text-center">
|
|
119
|
+
<div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
120
|
+
<Shield className="h-6 w-6" />
|
|
121
|
+
</div>
|
|
122
|
+
<h1 className="text-foreground text-2xl font-semibold">
|
|
123
|
+
Welcome to ClaudeDeck
|
|
124
|
+
</h1>
|
|
125
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
126
|
+
Create your account to get started
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
131
|
+
<div className="space-y-2">
|
|
132
|
+
<label
|
|
133
|
+
htmlFor="username"
|
|
134
|
+
className="text-foreground text-sm font-medium"
|
|
135
|
+
>
|
|
136
|
+
Username
|
|
137
|
+
</label>
|
|
138
|
+
<Input
|
|
139
|
+
id="username"
|
|
140
|
+
type="text"
|
|
141
|
+
value={username}
|
|
142
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
143
|
+
placeholder="admin"
|
|
144
|
+
autoComplete="username"
|
|
145
|
+
autoFocus
|
|
146
|
+
required
|
|
147
|
+
minLength={3}
|
|
148
|
+
maxLength={32}
|
|
149
|
+
pattern="[a-zA-Z0-9_]+"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="space-y-2">
|
|
154
|
+
<label
|
|
155
|
+
htmlFor="password"
|
|
156
|
+
className="text-foreground text-sm font-medium"
|
|
157
|
+
>
|
|
158
|
+
Password
|
|
159
|
+
</label>
|
|
160
|
+
<div className="relative">
|
|
161
|
+
<Input
|
|
162
|
+
id="password"
|
|
163
|
+
type={showPassword ? "text" : "password"}
|
|
164
|
+
value={password}
|
|
165
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
166
|
+
autoComplete="new-password"
|
|
167
|
+
required
|
|
168
|
+
minLength={8}
|
|
169
|
+
/>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
173
|
+
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
|
174
|
+
tabIndex={-1}
|
|
175
|
+
>
|
|
176
|
+
{showPassword ? (
|
|
177
|
+
<EyeOff className="h-4 w-4" />
|
|
178
|
+
) : (
|
|
179
|
+
<Eye className="h-4 w-4" />
|
|
180
|
+
)}
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
<label
|
|
187
|
+
htmlFor="confirmPassword"
|
|
188
|
+
className="text-foreground text-sm font-medium"
|
|
189
|
+
>
|
|
190
|
+
Confirm password
|
|
191
|
+
</label>
|
|
192
|
+
<Input
|
|
193
|
+
id="confirmPassword"
|
|
194
|
+
type={showPassword ? "text" : "password"}
|
|
195
|
+
value={confirmPassword}
|
|
196
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
197
|
+
autoComplete="new-password"
|
|
198
|
+
required
|
|
199
|
+
minLength={8}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
|
204
|
+
<div>
|
|
205
|
+
<p className="text-foreground text-sm font-medium">
|
|
206
|
+
Two-factor authentication
|
|
207
|
+
</p>
|
|
208
|
+
<p className="text-muted-foreground text-xs">
|
|
209
|
+
Secure your account with TOTP
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
<Switch checked={enableTotp} onCheckedChange={setEnableTotp} />
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{enableTotp && qrDataUrl && (
|
|
216
|
+
<div className="border-border space-y-3 rounded-lg border p-4">
|
|
217
|
+
<p className="text-foreground text-center text-sm font-medium">
|
|
218
|
+
Scan with your authenticator app
|
|
219
|
+
</p>
|
|
220
|
+
<div className="flex justify-center">
|
|
221
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- base64 data URL, next/image not applicable */}
|
|
222
|
+
<img
|
|
223
|
+
src={qrDataUrl}
|
|
224
|
+
alt="TOTP QR Code"
|
|
225
|
+
className="h-[200px] w-[200px]"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="space-y-1">
|
|
229
|
+
<p className="text-muted-foreground text-center text-xs">
|
|
230
|
+
Or enter manually:
|
|
231
|
+
</p>
|
|
232
|
+
<code className="bg-muted text-foreground block rounded p-2 text-center font-mono text-xs break-all">
|
|
233
|
+
{totpSecret}
|
|
234
|
+
</code>
|
|
235
|
+
</div>
|
|
236
|
+
<div className="space-y-2">
|
|
237
|
+
<label
|
|
238
|
+
htmlFor="totpVerify"
|
|
239
|
+
className="text-foreground text-sm font-medium"
|
|
240
|
+
>
|
|
241
|
+
Verification code
|
|
242
|
+
</label>
|
|
243
|
+
<Input
|
|
244
|
+
id="totpVerify"
|
|
245
|
+
type="text"
|
|
246
|
+
inputMode="numeric"
|
|
247
|
+
pattern="[0-9]*"
|
|
248
|
+
maxLength={6}
|
|
249
|
+
value={totpCode}
|
|
250
|
+
onChange={(e) =>
|
|
251
|
+
setTotpCode(e.target.value.replace(/\D/g, ""))
|
|
252
|
+
}
|
|
253
|
+
placeholder="000000"
|
|
254
|
+
className="text-center text-lg tracking-[0.3em]"
|
|
255
|
+
autoComplete="one-time-code"
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{enableTotp && !qrDataUrl && username.length < 3 && (
|
|
262
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
263
|
+
Enter a username (3+ characters) to generate the QR code
|
|
264
|
+
</p>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{error && (
|
|
268
|
+
<p className="text-destructive text-center text-sm">{error}</p>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
272
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
273
|
+
Create account
|
|
274
|
+
</Button>
|
|
275
|
+
</form>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from "react";
|
|
4
|
-
import { WorkerCard, type WorkerInfo
|
|
4
|
+
import { WorkerCard, type WorkerInfo } from "./WorkerCard";
|
|
5
5
|
import { Button } from "./ui/button";
|
|
6
6
|
import {
|
|
7
7
|
RefreshCw,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
4
|
import { X, RefreshCw, Loader2 } from "lucide-react";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
|
|
@@ -20,31 +20,34 @@ export function ServerLogsModal({
|
|
|
20
20
|
const [refreshing, setRefreshing] = useState(false);
|
|
21
21
|
const logsRef = useRef<HTMLDivElement>(null);
|
|
22
22
|
|
|
23
|
-
const fetchLogs =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const fetchLogs = useCallback(
|
|
24
|
+
async (isRefresh = false) => {
|
|
25
|
+
if (isRefresh) {
|
|
26
|
+
setRefreshing(true);
|
|
27
|
+
} else {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`/api/dev-servers/${serverId}/logs?lines=200`);
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
setLogs(data.logs || []);
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error("Failed to fetch logs:", err);
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
setRefreshing(false);
|
|
35
42
|
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
setLoading(false);
|
|
40
|
-
setRefreshing(false);
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
+
},
|
|
44
|
+
[serverId]
|
|
45
|
+
);
|
|
43
46
|
|
|
44
47
|
// Initial fetch
|
|
45
48
|
useEffect(() => {
|
|
46
49
|
fetchLogs();
|
|
47
|
-
}, [
|
|
50
|
+
}, [fetchLogs]);
|
|
48
51
|
|
|
49
52
|
// Auto-scroll to bottom when logs update
|
|
50
53
|
useEffect(() => {
|
|
@@ -59,7 +62,7 @@ export function ServerLogsModal({
|
|
|
59
62
|
fetchLogs(true);
|
|
60
63
|
}, 3000);
|
|
61
64
|
return () => clearInterval(interval);
|
|
62
|
-
}, [
|
|
65
|
+
}, [fetchLogs]);
|
|
63
66
|
|
|
64
67
|
return (
|
|
65
68
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
@@ -4,7 +4,6 @@ import { X, Plus, Minus, ChevronLeft } from "lucide-react";
|
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import { UnifiedDiff } from "./UnifiedDiff";
|
|
6
6
|
import { parseDiff, getDiffFileName, getDiffSummary } from "@/lib/diff-parser";
|
|
7
|
-
import { cn } from "@/lib/utils";
|
|
8
7
|
|
|
9
8
|
interface DiffModalProps {
|
|
10
9
|
diff: string;
|
|
@@ -108,7 +108,7 @@ export function FileEditDialog({
|
|
|
108
108
|
: baseDir;
|
|
109
109
|
const filePath = `${expandedBaseDir}/${file.path}`;
|
|
110
110
|
const fileName = file.path.split("/").pop() || file.path;
|
|
111
|
-
const
|
|
111
|
+
const _repoName = "repoName" in file ? file.repoName : null;
|
|
112
112
|
const hasChanges = modifiedContent !== initialModified;
|
|
113
113
|
|
|
114
114
|
useEffect(() => {
|
|
@@ -44,7 +44,7 @@ const SWIPE_THRESHOLD = 80;
|
|
|
44
44
|
export function FileChanges({
|
|
45
45
|
files,
|
|
46
46
|
title,
|
|
47
|
-
emptyMessage,
|
|
47
|
+
emptyMessage: _emptyMessage,
|
|
48
48
|
selectedPath,
|
|
49
49
|
onFileClick,
|
|
50
50
|
onStage,
|
|
@@ -99,7 +99,11 @@ export function FileChanges({
|
|
|
99
99
|
<button
|
|
100
100
|
onClick={(e) => {
|
|
101
101
|
e.stopPropagation();
|
|
102
|
-
|
|
102
|
+
if (isStaged) {
|
|
103
|
+
onUnstageAll?.();
|
|
104
|
+
} else {
|
|
105
|
+
onStageAll?.();
|
|
106
|
+
}
|
|
103
107
|
}}
|
|
104
108
|
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors"
|
|
105
109
|
>
|
|
@@ -4,10 +4,7 @@ import { useRef, useCallback, useEffect, memo, useState, useMemo } from "react";
|
|
|
4
4
|
import dynamic from "next/dynamic";
|
|
5
5
|
import { usePanes } from "@/contexts/PaneContext";
|
|
6
6
|
import { useViewport } from "@/hooks/useViewport";
|
|
7
|
-
import type {
|
|
8
|
-
TerminalHandle,
|
|
9
|
-
TerminalScrollState,
|
|
10
|
-
} from "@/components/Terminal";
|
|
7
|
+
import type { TerminalHandle } from "@/components/Terminal";
|
|
11
8
|
import type { Session, Project } from "@/lib/db";
|
|
12
9
|
import { sessionRegistry } from "@/lib/client/session-registry";
|
|
13
10
|
import { cn } from "@/lib/utils";
|
|
@@ -114,16 +111,20 @@ export const Pane = memo(function Pane({
|
|
|
114
111
|
? (terminalRefs.current.get(activeTab.id) ?? null)
|
|
115
112
|
: null;
|
|
116
113
|
const isFocused = focusedPaneId === paneId;
|
|
117
|
-
const session =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
? (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
114
|
+
const session = useMemo(
|
|
115
|
+
() =>
|
|
116
|
+
activeTab
|
|
117
|
+
? (sessions.find((s) => s.id === activeTab.sessionId) ??
|
|
118
|
+
(activeTab.sessionId
|
|
119
|
+
? ({
|
|
120
|
+
id: activeTab.sessionId,
|
|
121
|
+
name: activeTab.sessionName || activeTab.sessionId.slice(0, 8),
|
|
122
|
+
working_directory: activeTab.workingDirectory || "~",
|
|
123
|
+
} as Session)
|
|
124
|
+
: null))
|
|
125
|
+
: null,
|
|
126
|
+
[activeTab, sessions]
|
|
127
|
+
);
|
|
127
128
|
|
|
128
129
|
// File editor state - lifted here so it persists across view switches
|
|
129
130
|
const fileEditor = useFileEditor();
|
|
@@ -236,7 +237,7 @@ export const Pane = memo(function Pane({
|
|
|
236
237
|
setTimeout(() => handle.sendCommand(`tmux attach -t ${tmuxName}`), 100);
|
|
237
238
|
}
|
|
238
239
|
},
|
|
239
|
-
[paneId, sessions, onRegisterTerminal]
|
|
240
|
+
[paneId, paneData, sessions, onRegisterTerminal]
|
|
240
241
|
);
|
|
241
242
|
|
|
242
243
|
// Track current tab ID for cleanup
|
|
@@ -108,7 +108,7 @@ export function ProjectCard({
|
|
|
108
108
|
setIsEditing(false);
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
const handleClick = (
|
|
111
|
+
const handleClick = (_e: React.MouseEvent) => {
|
|
112
112
|
if (isEditing) return;
|
|
113
113
|
onClick?.();
|
|
114
114
|
onToggleExpanded?.(!project.expanded);
|
|
@@ -100,6 +100,7 @@ export function QuickSwitcher({
|
|
|
100
100
|
(a, b) =>
|
|
101
101
|
new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
|
102
102
|
);
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when .data changes, not entire query objects
|
|
103
104
|
}, [s0.data, s1.data, s2.data, s3.data, topProjects]);
|
|
104
105
|
|
|
105
106
|
const filteredSessions = useMemo(() => {
|
|
@@ -23,14 +23,14 @@ import type { SessionListProps } from "./SessionList.types";
|
|
|
23
23
|
export type { SessionListProps } from "./SessionList.types";
|
|
24
24
|
|
|
25
25
|
export function SessionList({
|
|
26
|
-
activeSessionId,
|
|
27
|
-
sessionStatuses,
|
|
26
|
+
activeSessionId: _activeSessionId,
|
|
27
|
+
sessionStatuses: _sessionStatuses,
|
|
28
28
|
onSelect,
|
|
29
|
-
onOpenInTab,
|
|
30
|
-
onNewSessionInProject,
|
|
31
|
-
onOpenTerminal,
|
|
32
|
-
onStartDevServer,
|
|
33
|
-
onCreateDevServer,
|
|
29
|
+
onOpenInTab: _onOpenInTab,
|
|
30
|
+
onNewSessionInProject: _onNewSessionInProject,
|
|
31
|
+
onOpenTerminal: _onOpenTerminal,
|
|
32
|
+
onStartDevServer: _onStartDevServer,
|
|
33
|
+
onCreateDevServer: _onCreateDevServer,
|
|
34
34
|
onResumeClaudeSession,
|
|
35
35
|
onNewSession,
|
|
36
36
|
}: SessionListProps) {
|
|
@@ -66,7 +66,7 @@ export function SessionList({
|
|
|
66
66
|
rect: DOMRect;
|
|
67
67
|
} | null>(null);
|
|
68
68
|
|
|
69
|
-
const
|
|
69
|
+
const _hoverHandlers = {
|
|
70
70
|
onHoverStart: useCallback(
|
|
71
71
|
(_session: Session, _rect: DOMRect) => {
|
|
72
72
|
if (isMobile) return;
|
|
@@ -131,6 +131,7 @@ export function useTerminalConnection({
|
|
|
131
131
|
useEffect(() => {
|
|
132
132
|
if (!terminalRef.current) return;
|
|
133
133
|
|
|
134
|
+
const terminalElement = terminalRef.current;
|
|
134
135
|
let cancelled = false;
|
|
135
136
|
// Reset intentional close flag (may be true from previous cleanup)
|
|
136
137
|
intentionalCloseRef.current = false;
|
|
@@ -208,9 +209,9 @@ export function useTerminalConnection({
|
|
|
208
209
|
|
|
209
210
|
// Save scroll state before unmount
|
|
210
211
|
const term = xtermRef.current;
|
|
211
|
-
if (term && callbacksRef.current.onBeforeUnmount &&
|
|
212
|
+
if (term && callbacksRef.current.onBeforeUnmount && terminalElement) {
|
|
212
213
|
const buffer = term.buffer.active;
|
|
213
|
-
const viewport =
|
|
214
|
+
const viewport = terminalElement.querySelector(
|
|
214
215
|
".xterm-viewport"
|
|
215
216
|
) as HTMLElement;
|
|
216
217
|
callbacksRef.current.onBeforeUnmount({
|
|
@@ -57,6 +57,7 @@ export function createWebSocketConnection(
|
|
|
57
57
|
|
|
58
58
|
// Force reconnect - kills any existing connection and creates fresh one
|
|
59
59
|
// Note: savedHandlers is populated after handlers are defined below
|
|
60
|
+
// eslint-disable-next-line prefer-const -- assigned after handler definitions below
|
|
60
61
|
let savedHandlers: {
|
|
61
62
|
onopen: typeof ws.onopen;
|
|
62
63
|
onmessage: typeof ws.onmessage;
|
package/data/git/queries.ts
CHANGED
|
@@ -6,7 +6,6 @@ export { gitKeys };
|
|
|
6
6
|
import type { CommitSummary, CommitDetail } from "@/lib/git-history";
|
|
7
7
|
import type { GitStatus } from "@/lib/git-status";
|
|
8
8
|
import type { MultiRepoGitStatus } from "@/lib/multi-repo-git";
|
|
9
|
-
import type { ProjectRepository } from "@/lib/db";
|
|
10
9
|
|
|
11
10
|
export interface PRInfo {
|
|
12
11
|
number: number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { hashPassword, verifyPassword } from "./password";
|
|
2
|
+
export { generateTotpSecret, verifyTotpCode } from "./totp";
|
|
3
|
+
export {
|
|
4
|
+
createSession,
|
|
5
|
+
validateSession,
|
|
6
|
+
renewSession,
|
|
7
|
+
deleteSession,
|
|
8
|
+
cleanupExpiredSessions,
|
|
9
|
+
COOKIE_NAME,
|
|
10
|
+
buildSessionCookie,
|
|
11
|
+
buildClearCookie,
|
|
12
|
+
parseCookies,
|
|
13
|
+
hasUsers,
|
|
14
|
+
} from "./session";
|
|
15
|
+
export { checkRateLimit } from "./rate-limit";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs";
|
|
2
|
+
|
|
3
|
+
const BCRYPT_COST = 12;
|
|
4
|
+
|
|
5
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
6
|
+
return bcrypt.hash(password, BCRYPT_COST);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function verifyPassword(
|
|
10
|
+
password: string,
|
|
11
|
+
hash: string
|
|
12
|
+
): Promise<boolean> {
|
|
13
|
+
return bcrypt.compare(password, hash);
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const MAX_ATTEMPTS = 5;
|
|
2
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
interface RateLimitEntry {
|
|
5
|
+
count: number;
|
|
6
|
+
resetAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const attempts = new Map<string, RateLimitEntry>();
|
|
10
|
+
|
|
11
|
+
setInterval(
|
|
12
|
+
() => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [ip, entry] of attempts) {
|
|
15
|
+
if (entry.resetAt < now) attempts.delete(ip);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
5 * 60 * 1000
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export function checkRateLimit(ip: string): {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
retryAfterSeconds?: number;
|
|
24
|
+
} {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const entry = attempts.get(ip);
|
|
27
|
+
|
|
28
|
+
if (!entry || entry.resetAt < now) {
|
|
29
|
+
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
30
|
+
return { allowed: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (entry.count >= MAX_ATTEMPTS) {
|
|
34
|
+
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
|
35
|
+
return { allowed: false, retryAfterSeconds };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
entry.count++;
|
|
39
|
+
return { allowed: true };
|
|
40
|
+
}
|