@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
package/README.md
CHANGED
|
@@ -30,13 +30,13 @@ claude-deck start
|
|
|
30
30
|
For fresh installs without Node.js:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
curl -fsSL https://raw.githubusercontent.com/
|
|
33
|
+
curl -fsSL https://raw.githubusercontent.com/ATERCATES/claude-deck/main/scripts/install.sh | bash
|
|
34
34
|
claude-deck start
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
### Desktop App
|
|
38
38
|
|
|
39
|
-
Download native desktop apps from [Releases](https://github.com/
|
|
39
|
+
Download native desktop apps from [Releases](https://github.com/ATERCATES/claude-deck/releases):
|
|
40
40
|
|
|
41
41
|
- macOS (Apple Silicon): `.dmg`
|
|
42
42
|
- Linux: `.deb` or `.AppImage`
|
|
@@ -48,7 +48,7 @@ Download native desktop apps from [Releases](https://github.com/atercates/claude
|
|
|
48
48
|
### Manual Install
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
git clone https://github.com/
|
|
51
|
+
git clone https://github.com/ATERCATES/claude-deck
|
|
52
52
|
cd claude-deck
|
|
53
53
|
npm install
|
|
54
54
|
npm run dev # http://localhost:3011
|
|
@@ -61,20 +61,6 @@ npm run dev # http://localhost:3011
|
|
|
61
61
|
- [ripgrep](https://github.com/BurntSushi/ripgrep) (for code search - auto-installed by installer script, or run `claude-deck update`)
|
|
62
62
|
- At least one AI CLI: [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://github.com/anomalyco/opencode), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Aider](https://aider.chat/), or [Cursor CLI](https://cursor.com/cli)
|
|
63
63
|
|
|
64
|
-
## Supported Agents
|
|
65
|
-
|
|
66
|
-
| Agent | Resume | Fork | Auto-Approve |
|
|
67
|
-
| ----------- | ------ | ---- | -------------------------------- |
|
|
68
|
-
| Claude Code | ✅ | ✅ | `--dangerously-skip-permissions` |
|
|
69
|
-
| Codex | ❌ | ❌ | `--approval-mode full-auto` |
|
|
70
|
-
| OpenCode | ❌ | ❌ | Config file |
|
|
71
|
-
| Gemini CLI | ❌ | ❌ | `--yolomode` |
|
|
72
|
-
| Aider | ❌ | ❌ | `--yes` |
|
|
73
|
-
| Cursor CLI | ❌ | ❌ | N/A |
|
|
74
|
-
| Amp | ❌ | ❌ | `--dangerously-allow-all` |
|
|
75
|
-
| Pi | ❌ | ❌ | N/A |
|
|
76
|
-
| Oh My Pi | ❌ | ❌ | N/A |
|
|
77
|
-
|
|
78
64
|
## Features
|
|
79
65
|
|
|
80
66
|
- **Mobile-first** - Full functionality from your phone, not a dumbed-down responsive view
|
|
@@ -113,7 +99,7 @@ For configuration and advanced usage, see the [docs](https://www.runagentos.com/
|
|
|
113
99
|
|
|
114
100
|
## Related Projects
|
|
115
101
|
|
|
116
|
-
- **[aTerm](https://github.com/
|
|
102
|
+
- **[aTerm](https://github.com/ATERCATES/aTerm)** - A Tauri-based desktop terminal workspace for AI-assisted coding. While ClaudeDeck is a mobile-first web UI, aTerm is a native desktop app with multi-pane layouts optimized for running AI coding agents (Claude Code, Aider, OpenCode) alongside shells, dev servers, and a built-in git panel. Choose ClaudeDeck for mobile access and browser-based workflows, or aTerm for a native desktop terminal experience.
|
|
117
103
|
- **[LumifyHub](https://lumifyhub.io)** - Team collaboration platform with real-time chat and structured documentation. Useful alongside ClaudeDeck for coordinating multi-agent work across a team — share session context, document architectural decisions from coding sessions, and track progress across parallel agent workflows.
|
|
118
104
|
|
|
119
105
|
## License
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { updateProjectDevServer, deleteProjectDevServer } from "@/lib/projects";
|
|
3
|
-
import { queries
|
|
3
|
+
import { queries } from "@/lib/db";
|
|
4
4
|
|
|
5
5
|
interface RouteParams {
|
|
6
6
|
params: Promise<{ id: string; dsId: string }>;
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
updateProjectRepository,
|
|
4
4
|
deleteProjectRepository,
|
|
5
5
|
} from "@/lib/projects";
|
|
6
|
-
import { queries
|
|
6
|
+
import { queries } from "@/lib/db";
|
|
7
7
|
|
|
8
8
|
interface RouteParams {
|
|
9
9
|
params: Promise<{ id: string; repoId: string }>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { exec } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
|
-
import { queries
|
|
4
|
+
import { queries } from "@/lib/db";
|
|
5
5
|
import { deleteWorktree, isClaudeDeckWorktree } from "@/lib/worktrees";
|
|
6
6
|
import { releasePort } from "@/lib/ports";
|
|
7
7
|
import { killWorker } from "@/lib/orchestration";
|
|
@@ -81,7 +81,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// If this is a worktree session, also rename the git branch
|
|
84
|
-
if (
|
|
84
|
+
if (
|
|
85
|
+
existing.worktree_path &&
|
|
86
|
+
isClaudeDeckWorktree(existing.worktree_path)
|
|
87
|
+
) {
|
|
85
88
|
try {
|
|
86
89
|
const currentBranch = await getCurrentBranch(existing.worktree_path);
|
|
87
90
|
const newBranchName = generateBranchName(body.name);
|
|
@@ -174,7 +177,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
174
177
|
await queries.deleteSession(id);
|
|
175
178
|
|
|
176
179
|
// Clean up worktree in background (non-blocking)
|
|
177
|
-
if (
|
|
180
|
+
if (
|
|
181
|
+
existing.worktree_path &&
|
|
182
|
+
isClaudeDeckWorktree(existing.worktree_path)
|
|
183
|
+
) {
|
|
178
184
|
const worktreePath = existing.worktree_path; // Capture for closure
|
|
179
185
|
runInBackground(async () => {
|
|
180
186
|
const { exec } = await import("child_process");
|
|
@@ -196,7 +202,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
|
196
202
|
// Also cleanup worker worktrees in background
|
|
197
203
|
if (workers.length > 0) {
|
|
198
204
|
for (const worker of workers) {
|
|
199
|
-
if (
|
|
205
|
+
if (
|
|
206
|
+
worker.worktree_path &&
|
|
207
|
+
isClaudeDeckWorktree(worker.worktree_path)
|
|
208
|
+
) {
|
|
200
209
|
const worktreePath = worker.worktree_path; // Capture for closure
|
|
201
210
|
const workerId = worker.id; // Capture ID for task name
|
|
202
211
|
runInBackground(async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { exec } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
|
-
import { queries
|
|
4
|
+
import { queries } from "@/lib/db";
|
|
5
5
|
import { appendFileSync } from "fs";
|
|
6
6
|
|
|
7
7
|
const execAsync = promisify(exec);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { queries, type Session
|
|
3
|
+
import { queries, type Session } from "@/lib/db";
|
|
4
4
|
import { isValidAgentType, type AgentType } from "@/lib/providers";
|
|
5
5
|
import { createWorktree } from "@/lib/worktrees";
|
|
6
6
|
import { setupWorktree, type SetupResult } from "@/lib/env-setup";
|
|
@@ -88,7 +88,7 @@ export async function POST(request: NextRequest) {
|
|
|
88
88
|
let branchName: string | null = null;
|
|
89
89
|
let actualWorkingDirectory = workingDirectory;
|
|
90
90
|
let port: number | null = null;
|
|
91
|
-
|
|
91
|
+
const setupResult: SetupResult | null = null;
|
|
92
92
|
|
|
93
93
|
if (useWorktree && featureName) {
|
|
94
94
|
try {
|
|
@@ -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
|
+
}
|