@atercates/claude-deck 0.2.2 → 0.2.4
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/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/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/login/page.tsx +192 -0
- package/app/page.tsx +34 -0
- package/app/setup/page.tsx +279 -0
- package/components/Pane/DesktopTabBar.tsx +62 -28
- package/components/Pane/index.tsx +5 -0
- package/components/QuickSwitcher.tsx +63 -11
- package/components/SessionList/ActiveSessionsSection.tsx +116 -0
- package/components/SessionList/index.tsx +9 -1
- package/components/SessionStatusBar.tsx +155 -0
- package/components/WaitingBanner.tsx +122 -0
- package/components/views/DesktopView.tsx +32 -8
- package/components/views/types.ts +2 -0
- package/data/statuses/queries.ts +68 -34
- 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/watcher.ts +28 -5
- package/lib/db/queries.ts +64 -0
- package/lib/db/schema.ts +19 -0
- package/lib/db/types.ts +16 -0
- package/lib/hooks/reporter.ts +116 -0
- package/lib/hooks/setup.ts +164 -0
- package/lib/orchestration.ts +6 -8
- package/lib/providers/registry.ts +1 -1
- package/lib/status-monitor.ts +278 -0
- package/package.json +5 -1
- package/server.ts +23 -0
- package/lib/status-detector.ts +0 -375
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { queries } from "@/lib/db";
|
|
3
|
+
import {
|
|
4
|
+
verifyPassword,
|
|
5
|
+
verifyTotpCode,
|
|
6
|
+
createSession,
|
|
7
|
+
buildSessionCookie,
|
|
8
|
+
checkRateLimit,
|
|
9
|
+
} from "@/lib/auth";
|
|
10
|
+
|
|
11
|
+
export async function POST(request: NextRequest) {
|
|
12
|
+
const ip =
|
|
13
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
14
|
+
request.headers.get("x-real-ip") ||
|
|
15
|
+
"unknown";
|
|
16
|
+
|
|
17
|
+
const rateCheck = checkRateLimit(ip);
|
|
18
|
+
if (!rateCheck.allowed) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: "Too many login attempts. Try again later." },
|
|
21
|
+
{
|
|
22
|
+
status: 429,
|
|
23
|
+
headers: { "Retry-After": String(rateCheck.retryAfterSeconds) },
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const body = await request.json();
|
|
29
|
+
const { username, password, totpCode } = body;
|
|
30
|
+
|
|
31
|
+
const INVALID = NextResponse.json(
|
|
32
|
+
{ error: "Invalid credentials" },
|
|
33
|
+
{ status: 401 }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (!username || !password) return INVALID;
|
|
37
|
+
|
|
38
|
+
const user = queries.getUserByUsername(username);
|
|
39
|
+
if (!user) return INVALID;
|
|
40
|
+
|
|
41
|
+
const validPassword = await verifyPassword(password, user.password_hash);
|
|
42
|
+
if (!validPassword) return INVALID;
|
|
43
|
+
|
|
44
|
+
if (user.totp_secret) {
|
|
45
|
+
if (!totpCode) {
|
|
46
|
+
return NextResponse.json({ requiresTotp: true });
|
|
47
|
+
}
|
|
48
|
+
if (!verifyTotpCode(user.totp_secret, totpCode)) {
|
|
49
|
+
return INVALID;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { token } = createSession(user.id);
|
|
54
|
+
const response = NextResponse.json({ ok: true });
|
|
55
|
+
response.headers.set("Set-Cookie", buildSessionCookie(token));
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { deleteSession, buildClearCookie, COOKIE_NAME } from "@/lib/auth";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: NextRequest) {
|
|
5
|
+
const token = request.cookies.get(COOKIE_NAME)?.value;
|
|
6
|
+
if (token) {
|
|
7
|
+
deleteSession(token);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const response = NextResponse.json({ ok: true });
|
|
11
|
+
response.headers.set("Set-Cookie", buildClearCookie());
|
|
12
|
+
return response;
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
validateSession,
|
|
4
|
+
renewSession,
|
|
5
|
+
COOKIE_NAME,
|
|
6
|
+
hasUsers,
|
|
7
|
+
} from "@/lib/auth";
|
|
8
|
+
|
|
9
|
+
export async function GET(request: NextRequest) {
|
|
10
|
+
if (!hasUsers()) {
|
|
11
|
+
return NextResponse.json({ authenticated: false, needsSetup: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const token = request.cookies.get(COOKIE_NAME)?.value;
|
|
15
|
+
if (!token) {
|
|
16
|
+
return NextResponse.json({ authenticated: false }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const user = validateSession(token);
|
|
20
|
+
if (!user) {
|
|
21
|
+
return NextResponse.json({ authenticated: false }, { status: 401 });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
renewSession(token);
|
|
25
|
+
return NextResponse.json({
|
|
26
|
+
authenticated: true,
|
|
27
|
+
username: user.username,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { queries } from "@/lib/db";
|
|
4
|
+
import {
|
|
5
|
+
hashPassword,
|
|
6
|
+
verifyTotpCode,
|
|
7
|
+
createSession,
|
|
8
|
+
buildSessionCookie,
|
|
9
|
+
hasUsers,
|
|
10
|
+
} from "@/lib/auth";
|
|
11
|
+
|
|
12
|
+
export async function POST(request: NextRequest) {
|
|
13
|
+
if (hasUsers()) {
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{ error: "Setup already completed" },
|
|
16
|
+
{ status: 403 }
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const body = await request.json();
|
|
21
|
+
const { username, password, totpSecret, totpCode } = body;
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
!username ||
|
|
25
|
+
typeof username !== "string" ||
|
|
26
|
+
username.length < 3 ||
|
|
27
|
+
username.length > 32 ||
|
|
28
|
+
!/^[a-zA-Z0-9_]+$/.test(username)
|
|
29
|
+
) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{
|
|
32
|
+
error:
|
|
33
|
+
"Username must be 3-32 characters, alphanumeric and underscore only",
|
|
34
|
+
},
|
|
35
|
+
{ status: 400 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!password || typeof password !== "string" || password.length < 8) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: "Password must be at least 8 characters" },
|
|
42
|
+
{ status: 400 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (totpSecret) {
|
|
47
|
+
if (!totpCode || !verifyTotpCode(totpSecret, totpCode)) {
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
error:
|
|
51
|
+
"Invalid TOTP code. Scan the QR code again and enter the current code.",
|
|
52
|
+
},
|
|
53
|
+
{ status: 400 }
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const id = randomBytes(16).toString("hex");
|
|
59
|
+
const passwordHash = await hashPassword(password);
|
|
60
|
+
|
|
61
|
+
queries.createUser(id, username, passwordHash, totpSecret || null);
|
|
62
|
+
|
|
63
|
+
const { token } = createSession(id);
|
|
64
|
+
const response = NextResponse.json({ ok: true });
|
|
65
|
+
response.headers.set("Set-Cookie", buildSessionCookie(token));
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
export async function POST() {
|
|
4
|
+
// With JSONL-based detection, acknowledge is a no-op.
|
|
5
|
+
// Status is determined by file content, not by a flag.
|
|
6
|
+
// The endpoint exists for API compatibility.
|
|
7
|
+
return NextResponse.json({ ok: true });
|
|
8
|
+
}
|
|
@@ -1,237 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
import { promisify } from "util";
|
|
4
|
-
import * as fs from "fs";
|
|
5
|
-
import * as path from "path";
|
|
6
|
-
import * as os from "os";
|
|
7
|
-
import { statusDetector, type SessionStatus } from "@/lib/status-detector";
|
|
8
|
-
import type { AgentType } from "@/lib/providers";
|
|
9
|
-
import {
|
|
10
|
-
getManagedSessionPattern,
|
|
11
|
-
getProviderIdFromSessionName,
|
|
12
|
-
getSessionIdFromName,
|
|
13
|
-
} from "@/lib/providers/registry";
|
|
14
|
-
import { getDb } from "@/lib/db";
|
|
15
|
-
|
|
16
|
-
const execAsync = promisify(exec);
|
|
17
|
-
|
|
18
|
-
interface SessionStatusResponse {
|
|
19
|
-
sessionName: string;
|
|
20
|
-
status: SessionStatus;
|
|
21
|
-
lastLine?: string;
|
|
22
|
-
claudeSessionId?: string | null;
|
|
23
|
-
agentType?: AgentType;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function getTmuxSessions(): Promise<string[]> {
|
|
27
|
-
try {
|
|
28
|
-
const { stdout } = await execAsync(
|
|
29
|
-
"tmux list-sessions -F '#{session_name}' 2>/dev/null || true"
|
|
30
|
-
);
|
|
31
|
-
return stdout.trim().split("\n").filter(Boolean);
|
|
32
|
-
} catch {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function getTmuxSessionCwd(sessionName: string): Promise<string | null> {
|
|
38
|
-
try {
|
|
39
|
-
const { stdout } = await execAsync(
|
|
40
|
-
`tmux display-message -t "${sessionName}" -p "#{pane_current_path}" 2>/dev/null || echo ""`
|
|
41
|
-
);
|
|
42
|
-
const cwd = stdout.trim();
|
|
43
|
-
return cwd || null;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Get Claude session ID from tmux environment variable
|
|
50
|
-
async function getClaudeSessionIdFromEnv(
|
|
51
|
-
sessionName: string
|
|
52
|
-
): Promise<string | null> {
|
|
53
|
-
try {
|
|
54
|
-
const { stdout } = await execAsync(
|
|
55
|
-
`tmux show-environment -t "${sessionName}" CLAUDE_SESSION_ID 2>/dev/null || echo ""`
|
|
56
|
-
);
|
|
57
|
-
const line = stdout.trim();
|
|
58
|
-
if (line.startsWith("CLAUDE_SESSION_ID=")) {
|
|
59
|
-
const sessionId = line.replace("CLAUDE_SESSION_ID=", "");
|
|
60
|
-
if (sessionId && sessionId !== "null") {
|
|
61
|
-
return sessionId;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Get Claude session ID by looking at session files on disk
|
|
71
|
-
function getClaudeSessionIdFromFiles(projectPath: string): string | null {
|
|
72
|
-
const home = os.homedir();
|
|
73
|
-
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
74
|
-
const projectDirName = projectPath.replace(/\//g, "-");
|
|
75
|
-
const projectDir = path.join(claudeDir, "projects", projectDirName);
|
|
76
|
-
|
|
77
|
-
if (!fs.existsSync(projectDir)) {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const uuidPattern =
|
|
82
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const files = fs.readdirSync(projectDir);
|
|
86
|
-
let mostRecent: string | null = null;
|
|
87
|
-
let mostRecentTime = 0;
|
|
88
|
-
|
|
89
|
-
for (const file of files) {
|
|
90
|
-
if (file.startsWith("agent-")) continue;
|
|
91
|
-
if (!uuidPattern.test(file)) continue;
|
|
92
|
-
|
|
93
|
-
const filePath = path.join(projectDir, file);
|
|
94
|
-
const stat = fs.statSync(filePath);
|
|
95
|
-
|
|
96
|
-
if (stat.mtimeMs > mostRecentTime) {
|
|
97
|
-
mostRecentTime = stat.mtimeMs;
|
|
98
|
-
mostRecent = file.replace(".jsonl", "");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (mostRecent && Date.now() - mostRecentTime < 5 * 60 * 1000) {
|
|
103
|
-
return mostRecent;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const configFile = path.join(claudeDir, ".claude.json");
|
|
107
|
-
if (fs.existsSync(configFile)) {
|
|
108
|
-
try {
|
|
109
|
-
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
110
|
-
if (config.projects?.[projectPath]?.lastSessionId) {
|
|
111
|
-
return config.projects[projectPath].lastSessionId;
|
|
112
|
-
}
|
|
113
|
-
} catch {
|
|
114
|
-
// Ignore config parse errors
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return null;
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function getClaudeSessionId(sessionName: string): Promise<string | null> {
|
|
125
|
-
const envId = await getClaudeSessionIdFromEnv(sessionName);
|
|
126
|
-
if (envId) {
|
|
127
|
-
return envId;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const cwd = await getTmuxSessionCwd(sessionName);
|
|
131
|
-
if (cwd) {
|
|
132
|
-
return getClaudeSessionIdFromFiles(cwd);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function getLastLine(sessionName: string): Promise<string> {
|
|
139
|
-
try {
|
|
140
|
-
const { stdout } = await execAsync(
|
|
141
|
-
`tmux capture-pane -t "${sessionName}" -p -S -5 2>/dev/null || echo ""`
|
|
142
|
-
);
|
|
143
|
-
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
144
|
-
return lines.pop() || "";
|
|
145
|
-
} catch {
|
|
146
|
-
return "";
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// UUID pattern for claude-deck managed sessions (derived from registry)
|
|
151
|
-
const UUID_PATTERN = getManagedSessionPattern();
|
|
152
|
-
|
|
153
|
-
// Track previous statuses to detect changes
|
|
154
|
-
const previousStatuses = new Map<string, SessionStatus>();
|
|
155
|
-
|
|
156
|
-
function getAgentTypeFromSessionName(sessionName: string): AgentType {
|
|
157
|
-
return getProviderIdFromSessionName(sessionName) || "claude";
|
|
158
|
-
}
|
|
2
|
+
import { getStatusSnapshot } from "@/lib/status-monitor";
|
|
159
3
|
|
|
160
4
|
export async function GET() {
|
|
161
|
-
|
|
162
|
-
const sessions = await getTmuxSessions();
|
|
163
|
-
|
|
164
|
-
// Get status for claude-deck managed sessions
|
|
165
|
-
const managedSessions = sessions.filter((s) => UUID_PATTERN.test(s));
|
|
166
|
-
|
|
167
|
-
// Use the new status detector
|
|
168
|
-
const statusMap: Record<string, SessionStatusResponse> = {};
|
|
169
|
-
|
|
170
|
-
const db = getDb();
|
|
171
|
-
const sessionsToUpdate: string[] = [];
|
|
172
|
-
|
|
173
|
-
// Process all sessions in parallel for speed
|
|
174
|
-
const sessionPromises = managedSessions.map(async (sessionName) => {
|
|
175
|
-
const [status, claudeSessionId, lastLine] = await Promise.all([
|
|
176
|
-
statusDetector.getStatus(sessionName),
|
|
177
|
-
getClaudeSessionId(sessionName),
|
|
178
|
-
getLastLine(sessionName),
|
|
179
|
-
]);
|
|
180
|
-
const id = getSessionIdFromName(sessionName);
|
|
181
|
-
const agentType = getAgentTypeFromSessionName(sessionName);
|
|
182
|
-
|
|
183
|
-
return { sessionName, id, status, claudeSessionId, lastLine, agentType };
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const results = await Promise.all(sessionPromises);
|
|
187
|
-
|
|
188
|
-
for (const {
|
|
189
|
-
sessionName,
|
|
190
|
-
id,
|
|
191
|
-
status,
|
|
192
|
-
claudeSessionId,
|
|
193
|
-
lastLine,
|
|
194
|
-
agentType,
|
|
195
|
-
} of results) {
|
|
196
|
-
// Track status changes - update DB when session becomes active
|
|
197
|
-
const prevStatus = previousStatuses.get(id);
|
|
198
|
-
if (status === "running" || status === "waiting") {
|
|
199
|
-
if (prevStatus !== status) {
|
|
200
|
-
sessionsToUpdate.push(id);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
previousStatuses.set(id, status);
|
|
204
|
-
|
|
205
|
-
statusMap[id] = {
|
|
206
|
-
sessionName,
|
|
207
|
-
status,
|
|
208
|
-
lastLine,
|
|
209
|
-
claudeSessionId,
|
|
210
|
-
agentType,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Batch update sessions and claude_session_id
|
|
215
|
-
for (const id of sessionsToUpdate) {
|
|
216
|
-
db.prepare(
|
|
217
|
-
"UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
|
|
218
|
-
).run(id);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const { id, claudeSessionId } of results) {
|
|
222
|
-
if (claudeSessionId) {
|
|
223
|
-
db.prepare(
|
|
224
|
-
"UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
|
|
225
|
-
).run(claudeSessionId, id, claudeSessionId);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Cleanup old trackers
|
|
230
|
-
statusDetector.cleanup();
|
|
231
|
-
|
|
232
|
-
return NextResponse.json({ statuses: statusMap });
|
|
233
|
-
} catch (error) {
|
|
234
|
-
console.error("Error getting session statuses:", error);
|
|
235
|
-
return NextResponse.json({ statuses: {} });
|
|
236
|
-
}
|
|
5
|
+
return NextResponse.json({ statuses: getStatusSnapshot() });
|
|
237
6
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, 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 { Loader2, Lock, Eye, EyeOff } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export default function LoginPage() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const [username, setUsername] = useState("");
|
|
12
|
+
const [password, setPassword] = useState("");
|
|
13
|
+
const [totpCode, setTotpCode] = useState("");
|
|
14
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
15
|
+
const [error, setError] = useState("");
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [step, setStep] = useState<"credentials" | "totp">("credentials");
|
|
18
|
+
const totpInputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (step === "totp" && totpInputRef.current) {
|
|
22
|
+
totpInputRef.current.focus();
|
|
23
|
+
}
|
|
24
|
+
}, [step]);
|
|
25
|
+
|
|
26
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
setError("");
|
|
29
|
+
setLoading(true);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch("/api/auth/login", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
username,
|
|
37
|
+
password,
|
|
38
|
+
...(step === "totp" ? { totpCode } : {}),
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
|
|
44
|
+
if (res.status === 429) {
|
|
45
|
+
setError(
|
|
46
|
+
`Too many attempts. Try again in ${data.retryAfterSeconds || 60}s.`
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (data.requiresTotp) {
|
|
52
|
+
setStep("totp");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
setError(data.error || "Invalid credentials");
|
|
58
|
+
if (step === "totp") setTotpCode("");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
router.push("/");
|
|
63
|
+
router.refresh();
|
|
64
|
+
} catch {
|
|
65
|
+
setError("Connection error");
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (step === "totp" && totpCode.length === 6) {
|
|
73
|
+
const form = document.getElementById("login-form") as HTMLFormElement;
|
|
74
|
+
form?.requestSubmit();
|
|
75
|
+
}
|
|
76
|
+
}, [totpCode, step]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="bg-background flex min-h-screen items-center justify-center p-4">
|
|
80
|
+
<div className="border-border bg-card w-full max-w-sm rounded-xl border p-8 shadow-lg">
|
|
81
|
+
<div className="mb-8 text-center">
|
|
82
|
+
<div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
83
|
+
<Lock className="h-6 w-6" />
|
|
84
|
+
</div>
|
|
85
|
+
<h1 className="text-foreground text-2xl font-semibold">ClaudeDeck</h1>
|
|
86
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
87
|
+
{step === "credentials"
|
|
88
|
+
? "Sign in to continue"
|
|
89
|
+
: "Enter your 2FA code"}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<form id="login-form" onSubmit={handleSubmit} className="space-y-4">
|
|
94
|
+
{step === "credentials" ? (
|
|
95
|
+
<>
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<label
|
|
98
|
+
htmlFor="username"
|
|
99
|
+
className="text-foreground text-sm font-medium"
|
|
100
|
+
>
|
|
101
|
+
Username
|
|
102
|
+
</label>
|
|
103
|
+
<Input
|
|
104
|
+
id="username"
|
|
105
|
+
type="text"
|
|
106
|
+
value={username}
|
|
107
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
108
|
+
autoComplete="username"
|
|
109
|
+
autoFocus
|
|
110
|
+
required
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
<label
|
|
115
|
+
htmlFor="password"
|
|
116
|
+
className="text-foreground text-sm font-medium"
|
|
117
|
+
>
|
|
118
|
+
Password
|
|
119
|
+
</label>
|
|
120
|
+
<div className="relative">
|
|
121
|
+
<Input
|
|
122
|
+
id="password"
|
|
123
|
+
type={showPassword ? "text" : "password"}
|
|
124
|
+
value={password}
|
|
125
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
126
|
+
autoComplete="current-password"
|
|
127
|
+
required
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
132
|
+
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
|
133
|
+
tabIndex={-1}
|
|
134
|
+
>
|
|
135
|
+
{showPassword ? (
|
|
136
|
+
<EyeOff className="h-4 w-4" />
|
|
137
|
+
) : (
|
|
138
|
+
<Eye className="h-4 w-4" />
|
|
139
|
+
)}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</>
|
|
144
|
+
) : (
|
|
145
|
+
<div className="space-y-2">
|
|
146
|
+
<label
|
|
147
|
+
htmlFor="totp"
|
|
148
|
+
className="text-foreground text-sm font-medium"
|
|
149
|
+
>
|
|
150
|
+
Authentication code
|
|
151
|
+
</label>
|
|
152
|
+
<Input
|
|
153
|
+
ref={totpInputRef}
|
|
154
|
+
id="totp"
|
|
155
|
+
type="text"
|
|
156
|
+
inputMode="numeric"
|
|
157
|
+
pattern="[0-9]*"
|
|
158
|
+
maxLength={6}
|
|
159
|
+
value={totpCode}
|
|
160
|
+
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
|
161
|
+
placeholder="000000"
|
|
162
|
+
className="text-center text-2xl tracking-[0.5em]"
|
|
163
|
+
autoComplete="one-time-code"
|
|
164
|
+
required
|
|
165
|
+
/>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => {
|
|
169
|
+
setStep("credentials");
|
|
170
|
+
setTotpCode("");
|
|
171
|
+
setError("");
|
|
172
|
+
}}
|
|
173
|
+
className="text-muted-foreground hover:text-foreground text-xs underline"
|
|
174
|
+
>
|
|
175
|
+
Back to login
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{error && (
|
|
181
|
+
<p className="text-destructive text-center text-sm">{error}</p>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
185
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
186
|
+
{step === "credentials" ? "Sign in" : "Verify"}
|
|
187
|
+
</Button>
|
|
188
|
+
</form>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -296,6 +296,37 @@ function HomeContent() {
|
|
|
296
296
|
[addTab, focusedPaneId, buildSessionCommand, runSessionInTerminal]
|
|
297
297
|
);
|
|
298
298
|
|
|
299
|
+
// Attach to an already-running tmux session (non-destructive)
|
|
300
|
+
const attachToActiveTmux = useCallback(
|
|
301
|
+
(sessionId: string, tmuxSessionName: string) => {
|
|
302
|
+
const terminalInfo = getTerminalWithFallback();
|
|
303
|
+
if (!terminalInfo) return;
|
|
304
|
+
|
|
305
|
+
const { terminal, paneId } = terminalInfo;
|
|
306
|
+
const activeTab = getActiveTab(paneId);
|
|
307
|
+
const isInTmux = !!activeTab?.attachedTmux;
|
|
308
|
+
|
|
309
|
+
if (isInTmux) {
|
|
310
|
+
terminal.sendInput("\x02d");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
setTimeout(
|
|
314
|
+
() => {
|
|
315
|
+
terminal.sendInput("\x03");
|
|
316
|
+
setTimeout(() => {
|
|
317
|
+
terminal.sendCommand(
|
|
318
|
+
`tmux attach -t ${tmuxSessionName} 2>/dev/null`
|
|
319
|
+
);
|
|
320
|
+
attachSession(paneId, sessionId, tmuxSessionName);
|
|
321
|
+
terminal.focus();
|
|
322
|
+
}, 50);
|
|
323
|
+
},
|
|
324
|
+
isInTmux ? 100 : 0
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
[getTerminalWithFallback, getActiveTab, attachSession]
|
|
328
|
+
);
|
|
329
|
+
|
|
299
330
|
const resumeClaudeSession = useCallback(
|
|
300
331
|
(
|
|
301
332
|
claudeSessionId: string,
|
|
@@ -464,6 +495,7 @@ function HomeContent() {
|
|
|
464
495
|
paneId={paneId}
|
|
465
496
|
sessions={sessions}
|
|
466
497
|
projects={projects}
|
|
498
|
+
sessionStatuses={sessionStatuses}
|
|
467
499
|
onRegisterTerminal={registerTerminalRef}
|
|
468
500
|
onMenuClick={isMobile ? () => setSidebarOpen(true) : undefined}
|
|
469
501
|
onSelectSession={handleSelectSession}
|
|
@@ -473,6 +505,7 @@ function HomeContent() {
|
|
|
473
505
|
[
|
|
474
506
|
sessions,
|
|
475
507
|
projects,
|
|
508
|
+
sessionStatuses,
|
|
476
509
|
registerTerminalRef,
|
|
477
510
|
isMobile,
|
|
478
511
|
handleSelectSession,
|
|
@@ -586,6 +619,7 @@ function HomeContent() {
|
|
|
586
619
|
updateSettings,
|
|
587
620
|
requestPermission,
|
|
588
621
|
attachToSession,
|
|
622
|
+
attachToActiveTmux,
|
|
589
623
|
openSessionInNewTab,
|
|
590
624
|
handleNewSessionInProject,
|
|
591
625
|
handleOpenTerminal,
|