@atercates/claude-deck 0.2.2 → 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.
@@ -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,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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "crypto";
2
+ import { queries } from "@/lib/db";
3
+ import type { User } from "@/lib/db";
4
+
5
+ const SESSION_DURATION_DAYS = 30;
6
+ const SESSION_TOKEN_BYTES = 32;
7
+
8
+ export function createSession(userId: string): {
9
+ token: string;
10
+ expiresAt: string;
11
+ } {
12
+ const id = randomBytes(16).toString("hex");
13
+ const token = randomBytes(SESSION_TOKEN_BYTES).toString("hex");
14
+ const expiresAt = new Date(
15
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
16
+ ).toISOString();
17
+
18
+ queries.createAuthSession(id, token, userId, expiresAt);
19
+
20
+ return { token, expiresAt };
21
+ }
22
+
23
+ export function validateSession(token: string): User | null {
24
+ if (!token || token.length !== SESSION_TOKEN_BYTES * 2) return null;
25
+
26
+ const session = queries.getAuthSessionByToken(token);
27
+ if (!session) return null;
28
+
29
+ if (new Date(session.expires_at) < new Date()) {
30
+ queries.deleteAuthSession(token);
31
+ return null;
32
+ }
33
+
34
+ const user = queries.getUserById(session.user_id);
35
+ if (!user) {
36
+ queries.deleteAuthSession(token);
37
+ return null;
38
+ }
39
+
40
+ return user;
41
+ }
42
+
43
+ export function renewSession(token: string): void {
44
+ const expiresAt = new Date(
45
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
46
+ ).toISOString();
47
+ queries.renewAuthSession(token, expiresAt);
48
+ }
49
+
50
+ export function deleteSession(token: string): void {
51
+ queries.deleteAuthSession(token);
52
+ }
53
+
54
+ export function cleanupExpiredSessions(): void {
55
+ queries.deleteExpiredAuthSessions();
56
+ }
57
+
58
+ export const COOKIE_NAME = "claude_deck_session";
59
+
60
+ export function buildSessionCookie(token: string): string {
61
+ const maxAge = SESSION_DURATION_DAYS * 24 * 60 * 60;
62
+ return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
63
+ }
64
+
65
+ export function buildClearCookie(): string {
66
+ return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
67
+ }
68
+
69
+ export function parseCookies(
70
+ cookieHeader: string | undefined
71
+ ): Record<string, string> {
72
+ if (!cookieHeader) return {};
73
+ return Object.fromEntries(
74
+ cookieHeader.split(";").map((c) => {
75
+ const [key, ...rest] = c.trim().split("=");
76
+ return [key, rest.join("=")];
77
+ })
78
+ );
79
+ }
80
+
81
+ export function hasUsers(): boolean {
82
+ return queries.getUserCount() > 0;
83
+ }
@@ -0,0 +1,36 @@
1
+ import { TOTP, Secret } from "otpauth";
2
+
3
+ const ISSUER = "ClaudeDeck";
4
+
5
+ export function generateTotpSecret(username: string): {
6
+ secret: string;
7
+ uri: string;
8
+ } {
9
+ const secret = new Secret({ size: 20 });
10
+ const totp = new TOTP({
11
+ issuer: ISSUER,
12
+ label: username,
13
+ algorithm: "SHA1",
14
+ digits: 6,
15
+ period: 30,
16
+ secret,
17
+ });
18
+
19
+ return {
20
+ secret: secret.base32,
21
+ uri: totp.toString(),
22
+ };
23
+ }
24
+
25
+ export function verifyTotpCode(secret: string, code: string): boolean {
26
+ const totp = new TOTP({
27
+ issuer: ISSUER,
28
+ algorithm: "SHA1",
29
+ digits: 6,
30
+ period: 30,
31
+ secret: Secret.fromBase32(secret),
32
+ });
33
+
34
+ const delta = totp.validate({ token: code, window: 1 });
35
+ return delta !== null;
36
+ }
package/lib/db/queries.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  ProjectDevServer,
7
7
  ProjectRepository,
8
8
  DevServer,
9
+ User,
10
+ AuthSession,
9
11
  } from "./types";
10
12
 
11
13
  function query<T>(sql: string, params: unknown[] = []): T[] {
@@ -457,4 +459,66 @@ export const queries = {
457
459
  itemType,
458
460
  itemId,
459
461
  ]),
462
+
463
+ getUserCount(): number {
464
+ return (
465
+ queryOne<{ count: number }>("SELECT COUNT(*) as count FROM users") ?? {
466
+ count: 0,
467
+ }
468
+ ).count;
469
+ },
470
+
471
+ getUserByUsername(username: string): User | null {
472
+ return queryOne<User>("SELECT * FROM users WHERE username = ?", [username]);
473
+ },
474
+
475
+ getUserById(id: string): User | null {
476
+ return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
477
+ },
478
+
479
+ createUser(
480
+ id: string,
481
+ username: string,
482
+ passwordHash: string,
483
+ totpSecret: string | null
484
+ ): void {
485
+ execute(
486
+ "INSERT INTO users (id, username, password_hash, totp_secret) VALUES (?, ?, ?, ?)",
487
+ [id, username, passwordHash, totpSecret]
488
+ );
489
+ },
490
+
491
+ getAuthSessionByToken(token: string): AuthSession | null {
492
+ return queryOne<AuthSession>(
493
+ "SELECT * FROM auth_sessions WHERE token = ?",
494
+ [token]
495
+ );
496
+ },
497
+
498
+ createAuthSession(
499
+ id: string,
500
+ token: string,
501
+ userId: string,
502
+ expiresAt: string
503
+ ): void {
504
+ execute(
505
+ "INSERT INTO auth_sessions (id, token, user_id, expires_at) VALUES (?, ?, ?, ?)",
506
+ [id, token, userId, expiresAt]
507
+ );
508
+ },
509
+
510
+ renewAuthSession(token: string, expiresAt: string): void {
511
+ execute("UPDATE auth_sessions SET expires_at = ? WHERE token = ?", [
512
+ expiresAt,
513
+ token,
514
+ ]);
515
+ },
516
+
517
+ deleteAuthSession(token: string): void {
518
+ execute("DELETE FROM auth_sessions WHERE token = ?", [token]);
519
+ },
520
+
521
+ deleteExpiredAuthSessions(): void {
522
+ execute("DELETE FROM auth_sessions WHERE expires_at < datetime('now')");
523
+ },
460
524
  };
package/lib/db/schema.ts CHANGED
@@ -110,5 +110,24 @@ export function createSchema(db: Database.Database): void {
110
110
 
111
111
  INSERT OR IGNORE INTO projects (id, name, working_directory, is_uncategorized, sort_order)
112
112
  VALUES ('uncategorized', 'Uncategorized', '~', 1, 999999);
113
+
114
+ CREATE TABLE IF NOT EXISTS users (
115
+ id TEXT PRIMARY KEY,
116
+ username TEXT NOT NULL UNIQUE,
117
+ password_hash TEXT NOT NULL,
118
+ totp_secret TEXT,
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS auth_sessions (
123
+ id TEXT PRIMARY KEY,
124
+ token TEXT NOT NULL UNIQUE,
125
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
126
+ expires_at TEXT NOT NULL,
127
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token);
131
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
113
132
  `);
114
133
  }
package/lib/db/types.ts CHANGED
@@ -90,3 +90,19 @@ export interface DevServer {
90
90
  created_at: string;
91
91
  updated_at: string;
92
92
  }
93
+
94
+ export interface User {
95
+ id: string;
96
+ username: string;
97
+ password_hash: string;
98
+ totp_secret: string | null;
99
+ created_at: string;
100
+ }
101
+
102
+ export interface AuthSession {
103
+ id: string;
104
+ token: string;
105
+ user_id: string;
106
+ expires_at: string;
107
+ created_at: string;
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
@@ -78,6 +78,7 @@
78
78
  "@xterm/addon-search": "^0.16.0",
79
79
  "@xterm/addon-web-links": "^0.12.0",
80
80
  "@xterm/xterm": "^6.0.0",
81
+ "bcryptjs": "^3.0.3",
81
82
  "better-sqlite3": "^12.8.0",
82
83
  "chokidar": "^5.0.0",
83
84
  "class-variance-authority": "^0.7.1",
@@ -88,6 +89,8 @@
88
89
  "next": "^16.2.3",
89
90
  "next-themes": "^0.4.6",
90
91
  "node-pty": "1.2.0-beta.12",
92
+ "otpauth": "^9.5.0",
93
+ "qrcode": "^1.5.4",
91
94
  "react": "^19.2.5",
92
95
  "react-dom": "^19.2.5",
93
96
  "react-markdown": "^10.1.0",
@@ -107,6 +110,7 @@
107
110
  "@tauri-apps/cli": "^2.10.1",
108
111
  "@types/better-sqlite3": "^7.6.13",
109
112
  "@types/node": "^25.6.0",
113
+ "@types/qrcode": "^1.5.6",
110
114
  "@types/react": "^19.2.14",
111
115
  "@types/react-dom": "^19.2.3",
112
116
  "@types/ws": "^8.18.1",
package/server.ts CHANGED
@@ -5,6 +5,12 @@ import { WebSocketServer, WebSocket } from "ws";
5
5
  import * as pty from "node-pty";
6
6
  import { initDb } from "./lib/db";
7
7
  import { startWatcher, addUpdateClient } from "./lib/claude/watcher";
8
+ import {
9
+ validateSession,
10
+ parseCookies,
11
+ COOKIE_NAME,
12
+ hasUsers,
13
+ } from "./lib/auth";
8
14
 
9
15
  const dev = process.env.NODE_ENV !== "production";
10
16
  const hostname = "0.0.0.0";
@@ -35,6 +41,19 @@ app.prepare().then(async () => {
35
41
  server.on("upgrade", (request, socket, head) => {
36
42
  const { pathname } = parse(request.url || "");
37
43
 
44
+ // Validate auth for WebSocket connections
45
+ if (hasUsers()) {
46
+ const cookies = parseCookies(request.headers.cookie);
47
+ const token = cookies[COOKIE_NAME];
48
+ const user = token ? validateSession(token) : null;
49
+
50
+ if (!user) {
51
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
52
+ socket.destroy();
53
+ return;
54
+ }
55
+ }
56
+
38
57
  if (pathname === "/ws/terminal") {
39
58
  terminalWss.handleUpgrade(request, socket, head, (ws) => {
40
59
  terminalWss.emit("connection", ws, request);