Skip to content

Commit c071e02

Browse files
wyucclaudecosarah
authored
feat: ACCESS_CODE site-level authentication (#407) (#411)
* feat(access-code): add status API endpoint Returns whether ACCESS_CODE is enabled and whether the current user is authenticated via cookie, to support the frontend access gate. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(access-code): add verify API endpoint Creates POST /api/access-code/verify that validates ACCESS_CODE env var and sets an HttpOnly 7-day cookie on success. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(access-code): add Next.js middleware to gate API routes Intercepts all requests when ACCESS_CODE env var is set. API routes return 401 without the openmaic_access cookie; page requests pass through so the frontend can render the access-code modal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(i18n): add accessCode translations for all 4 locales Add accessCode top-level key to en-US, zh-CN, ja-JP, and ru-RU locale files with title, placeholder, submit, and error strings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add ACCESS_CODE documentation to .env.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AccessCodeGuard component and integrate into root layout Wraps page children with an access-code gate that checks /api/access-code/status on mount and renders AccessCodeModal when authentication is required. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(access-code): add AccessCodeModal component Adds the modal dialog component that prompts users for an access code, using existing Dialog/Button/Input UI components and i18n translation keys. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(access-code): fix lint error and format files - Fix useEffect setState pattern in access-code-guard.tsx - Format middleware.ts and layout.tsx with Prettier Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(access-code): redesign modal as full-screen gate Replace generic Radix Dialog with a custom full-screen gate: - Mesh gradient background with noise texture - Frosted glass card with backdrop-blur - Staggered motion animations on mount - Inline submit button inside input field - Success state animation before dismissal - Proper dark mode support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(access-code): address code review findings Critical: - Middleware now validates HMAC-signed cookie tokens, not just existence - Verify endpoint uses timingSafeEqual for constant-time comparison - Cookie value is now `timestamp.HMAC(ACCESS_CODE, timestamp)` instead of UUID Important: - Middleware 401 response includes `errorCode` matching apiError shape - Status endpoint uses apiSuccess wrapper for consistency - Remove unused `accessCode.submit` i18n key from all 4 locales Suggestions: - Guard renders children during loading (overlay on top, not blocking) - Network error defaults to enabled=true (safer than silently disabling) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(access-code): use Web Crypto API in middleware for Edge Runtime Node.js crypto module is not available in Next.js Edge Runtime. Replace createHmac/timingSafeEqual with crypto.subtle HMAC and manual constant-length XOR comparison. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
1 parent 9a0060e commit c071e02

File tree

11 files changed

+433
-2
lines changed

11 files changed

+433
-2
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,8 @@ DEFAULT_MODEL=
162162
# Set to "true" to allow private/local network URLs (e.g. localhost, 192.168.x.x).
163163
# Required for self-hosted models like Ollama. Do NOT enable on public deployments.
164164
# ALLOW_LOCAL_NETWORKS=true
165+
166+
# --- Access Control -----------------------------------------------------------
167+
# Set a password to restrict site access. When set, users must enter this code
168+
# before using the app. Leave empty or remove to disable access control.
169+
# ACCESS_CODE=your-secret-code
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cookies } from 'next/headers';
2+
import { apiSuccess } from '@/lib/server/api-response';
3+
import { verifyAccessToken } from '@/app/api/access-code/verify/route';
4+
5+
export async function GET() {
6+
const accessCode = process.env.ACCESS_CODE;
7+
const enabled = !!accessCode;
8+
9+
let authenticated = false;
10+
if (enabled) {
11+
const cookieStore = await cookies();
12+
const token = cookieStore.get('openmaic_access')?.value;
13+
authenticated = !!token && verifyAccessToken(token, accessCode);
14+
}
15+
16+
return apiSuccess({ enabled, authenticated });
17+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { cookies } from 'next/headers';
2+
import { createHmac, timingSafeEqual } from 'crypto';
3+
import { apiError, apiSuccess } from '@/lib/server/api-response';
4+
5+
/** Create an HMAC-signed token: `timestamp.signature` */
6+
function createAccessToken(accessCode: string): string {
7+
const timestamp = Date.now().toString();
8+
const signature = createHmac('sha256', accessCode).update(timestamp).digest('hex');
9+
return `${timestamp}.${signature}`;
10+
}
11+
12+
/** Verify an HMAC-signed token against the access code */
13+
export function verifyAccessToken(token: string, accessCode: string): boolean {
14+
const dotIndex = token.indexOf('.');
15+
if (dotIndex === -1) return false;
16+
17+
const timestamp = token.substring(0, dotIndex);
18+
const signature = token.substring(dotIndex + 1);
19+
20+
const expected = createHmac('sha256', accessCode).update(timestamp).digest('hex');
21+
22+
const sigBuf = Buffer.from(signature, 'hex');
23+
const expBuf = Buffer.from(expected, 'hex');
24+
if (sigBuf.length !== expBuf.length) return false;
25+
26+
return timingSafeEqual(sigBuf, expBuf);
27+
}
28+
29+
export async function POST(request: Request) {
30+
const accessCode = process.env.ACCESS_CODE;
31+
if (!accessCode) {
32+
return apiSuccess({ valid: true });
33+
}
34+
35+
let body: { code?: string };
36+
try {
37+
body = await request.json();
38+
} catch {
39+
return apiError('INVALID_REQUEST', 400, 'Invalid JSON body');
40+
}
41+
42+
// Constant-time comparison
43+
if (!body.code) {
44+
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
45+
}
46+
const encoder = new TextEncoder();
47+
const a = encoder.encode(body.code);
48+
const b = encoder.encode(accessCode);
49+
if (a.byteLength !== b.byteLength || !timingSafeEqual(a, b)) {
50+
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
51+
}
52+
53+
const token = createAccessToken(accessCode);
54+
const cookieStore = await cookies();
55+
cookieStore.set('openmaic_access', token, {
56+
httpOnly: true,
57+
sameSite: 'lax',
58+
path: '/',
59+
maxAge: 60 * 60 * 24 * 7, // 7 days
60+
secure: process.env.NODE_ENV === 'production',
61+
});
62+
63+
return apiSuccess({ valid: true });
64+
}

app/layout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ThemeProvider } from '@/lib/hooks/use-theme';
99
import { I18nProvider } from '@/lib/hooks/use-i18n';
1010
import { Toaster } from '@/components/ui/sonner';
1111
import { ServerProvidersInit } from '@/components/server-providers-init';
12+
import { AccessCodeGuard } from '@/components/access-code-guard';
1213

1314
const inter = localFont({
1415
src: '../node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2',
@@ -36,7 +37,7 @@ export default function RootLayout({
3637
<ThemeProvider>
3738
<I18nProvider>
3839
<ServerProvidersInit />
39-
{children}
40+
<AccessCodeGuard>{children}</AccessCodeGuard>
4041
<Toaster position="top-center" />
4142
</I18nProvider>
4243
</ThemeProvider>

components/access-code-guard.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use client';
2+
3+
import { useEffect, useState, ReactNode } from 'react';
4+
import { AccessCodeModal } from '@/components/access-code-modal';
5+
6+
export function AccessCodeGuard({ children }: { children: ReactNode }) {
7+
const [status, setStatus] = useState<{
8+
enabled: boolean;
9+
authenticated: boolean;
10+
loading: boolean;
11+
}>({ enabled: false, authenticated: false, loading: true });
12+
13+
useEffect(() => {
14+
let cancelled = false;
15+
fetch('/api/access-code/status')
16+
.then((res) => res.json())
17+
.then((data) => {
18+
if (!cancelled) {
19+
setStatus({
20+
enabled: data.enabled,
21+
authenticated: data.authenticated,
22+
loading: false,
23+
});
24+
}
25+
})
26+
.catch(() => {
27+
if (!cancelled) {
28+
// Default to requiring auth on error — safer than silently disabling
29+
setStatus({ enabled: true, authenticated: false, loading: false });
30+
}
31+
});
32+
return () => {
33+
cancelled = true;
34+
};
35+
}, []);
36+
37+
const needsAuth = !status.loading && status.enabled && !status.authenticated;
38+
39+
return (
40+
<>
41+
{needsAuth && (
42+
<AccessCodeModal
43+
open={true}
44+
onSuccess={() => setStatus((s) => ({ ...s, authenticated: true }))}
45+
/>
46+
)}
47+
{children}
48+
</>
49+
);
50+
}

components/access-code-modal.tsx

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
'use client';
2+
3+
import { useState, useRef, useEffect } from 'react';
4+
import { motion, AnimatePresence } from 'motion/react';
5+
import { useI18n } from '@/lib/hooks/use-i18n';
6+
import { ArrowRight, ShieldCheck, LoaderCircle } from 'lucide-react';
7+
8+
interface AccessCodeModalProps {
9+
open: boolean;
10+
onSuccess: () => void;
11+
}
12+
13+
export function AccessCodeModal({ open, onSuccess }: AccessCodeModalProps) {
14+
const { t } = useI18n();
15+
const [code, setCode] = useState('');
16+
const [error, setError] = useState('');
17+
const [loading, setLoading] = useState(false);
18+
const [success, setSuccess] = useState(false);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
21+
useEffect(() => {
22+
if (open && inputRef.current) {
23+
inputRef.current.focus();
24+
}
25+
}, [open]);
26+
27+
async function handleSubmit(e: React.FormEvent) {
28+
e.preventDefault();
29+
if (!code || loading) return;
30+
setError('');
31+
setLoading(true);
32+
33+
try {
34+
const res = await fetch('/api/access-code/verify', {
35+
method: 'POST',
36+
headers: { 'Content-Type': 'application/json' },
37+
body: JSON.stringify({ code }),
38+
});
39+
40+
if (res.ok) {
41+
setSuccess(true);
42+
setTimeout(onSuccess, 600);
43+
} else {
44+
setError(t('accessCode.error'));
45+
setCode('');
46+
inputRef.current?.focus();
47+
}
48+
} catch {
49+
setError(t('accessCode.error'));
50+
} finally {
51+
setLoading(false);
52+
}
53+
}
54+
55+
return (
56+
<AnimatePresence>
57+
{open && (
58+
<motion.div
59+
className="fixed inset-0 z-[200] flex items-center justify-center overflow-hidden"
60+
initial={{ opacity: 0 }}
61+
animate={{ opacity: 1 }}
62+
exit={{ opacity: 0, transition: { duration: 0.3 } }}
63+
>
64+
{/* Background — subtle mesh gradient */}
65+
<div className="absolute inset-0 bg-background">
66+
<div
67+
className="absolute inset-0 opacity-30 dark:opacity-20"
68+
style={{
69+
backgroundImage: `
70+
radial-gradient(ellipse 80% 60% at 20% 40%, var(--primary) 0%, transparent 60%),
71+
radial-gradient(ellipse 60% 80% at 80% 20%, oklch(0.6 0.15 280) 0%, transparent 50%),
72+
radial-gradient(ellipse 50% 50% at 60% 80%, oklch(0.5 0.12 300) 0%, transparent 50%)
73+
`,
74+
}}
75+
/>
76+
{/* Subtle noise texture */}
77+
<div
78+
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05]"
79+
style={{
80+
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
81+
backgroundSize: '128px 128px',
82+
}}
83+
/>
84+
</div>
85+
86+
{/* Content card */}
87+
<motion.div
88+
className="relative z-10 w-full max-w-sm mx-4"
89+
initial={{ opacity: 0, y: 20, scale: 0.96 }}
90+
animate={{ opacity: 1, y: 0, scale: 1 }}
91+
exit={{ opacity: 0, y: -20, scale: 0.96 }}
92+
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
93+
>
94+
<div className="rounded-2xl border border-border/50 bg-card/80 p-8 shadow-xl shadow-black/5 backdrop-blur-xl dark:bg-card/60 dark:shadow-black/20">
95+
{/* Icon */}
96+
<motion.div
97+
className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10"
98+
initial={{ scale: 0.5, opacity: 0 }}
99+
animate={{ scale: 1, opacity: 1 }}
100+
transition={{ delay: 0.15, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
101+
>
102+
<ShieldCheck className="h-7 w-7 text-primary" strokeWidth={1.5} />
103+
</motion.div>
104+
105+
{/* Title */}
106+
<motion.h1
107+
className="mb-1 text-center text-lg font-semibold tracking-tight text-foreground"
108+
initial={{ opacity: 0, y: 8 }}
109+
animate={{ opacity: 1, y: 0 }}
110+
transition={{ delay: 0.2, duration: 0.4 }}
111+
>
112+
{t('accessCode.title')}
113+
</motion.h1>
114+
115+
<motion.p
116+
className="mb-6 text-center text-sm text-muted-foreground"
117+
initial={{ opacity: 0 }}
118+
animate={{ opacity: 1 }}
119+
transition={{ delay: 0.25, duration: 0.4 }}
120+
>
121+
OpenMAIC
122+
</motion.p>
123+
124+
{/* Form */}
125+
<motion.form
126+
onSubmit={handleSubmit}
127+
className="space-y-4"
128+
initial={{ opacity: 0, y: 8 }}
129+
animate={{ opacity: 1, y: 0 }}
130+
transition={{ delay: 0.3, duration: 0.4 }}
131+
>
132+
<div className="relative">
133+
<input
134+
ref={inputRef}
135+
type="password"
136+
placeholder={t('accessCode.placeholder')}
137+
value={code}
138+
onChange={(e) => {
139+
setCode(e.target.value);
140+
if (error) setError('');
141+
}}
142+
className={`
143+
w-full rounded-xl border bg-background/60 px-4 py-3 pr-12 text-sm
144+
outline-none transition-all duration-200
145+
placeholder:text-muted-foreground/50
146+
focus:border-primary/40 focus:ring-2 focus:ring-primary/10
147+
${error ? 'border-destructive/50 focus:border-destructive/50 focus:ring-destructive/10' : 'border-border/60'}
148+
`}
149+
disabled={loading || success}
150+
autoComplete="off"
151+
/>
152+
<button
153+
type="submit"
154+
disabled={!code || loading || success}
155+
className={`
156+
absolute right-2 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center
157+
justify-center rounded-lg transition-all duration-200
158+
${code && !loading && !success ? 'bg-primary text-primary-foreground hover:opacity-90 cursor-pointer' : 'text-muted-foreground/30 cursor-default'}
159+
`}
160+
>
161+
{loading ? (
162+
<LoaderCircle className="h-4 w-4 animate-spin" />
163+
) : success ? (
164+
<motion.div
165+
initial={{ scale: 0 }}
166+
animate={{ scale: 1 }}
167+
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
168+
>
169+
<ShieldCheck className="h-4 w-4 text-emerald-500" />
170+
</motion.div>
171+
) : (
172+
<ArrowRight className="h-4 w-4" />
173+
)}
174+
</button>
175+
</div>
176+
177+
{/* Error message */}
178+
<AnimatePresence mode="wait">
179+
{error && (
180+
<motion.p
181+
className="text-center text-sm text-destructive"
182+
initial={{ opacity: 0, y: -4, height: 0 }}
183+
animate={{ opacity: 1, y: 0, height: 'auto' }}
184+
exit={{ opacity: 0, y: -4, height: 0 }}
185+
transition={{ duration: 0.2 }}
186+
>
187+
{error}
188+
</motion.p>
189+
)}
190+
</AnimatePresence>
191+
</motion.form>
192+
</div>
193+
</motion.div>
194+
</motion.div>
195+
)}
196+
</AnimatePresence>
197+
);
198+
}

lib/i18n/locales/en-US.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,5 +890,10 @@
890890
"voice": "Voice",
891891
"speed": "Speed",
892892
"language": "Language"
893+
},
894+
"accessCode": {
895+
"title": "Enter Access Code",
896+
"placeholder": "Access code",
897+
"error": "Invalid access code. Please try again."
893898
}
894899
}

lib/i18n/locales/ja-JP.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,5 +890,10 @@
890890
"voice": "ボイス",
891891
"speed": "速度",
892892
"language": "言語"
893+
},
894+
"accessCode": {
895+
"title": "アクセスコードを入力",
896+
"placeholder": "アクセスコード",
897+
"error": "アクセスコードが正しくありません。もう一度お試しください。"
893898
}
894899
}

lib/i18n/locales/ru-RU.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,6 @@
705705
"lang_fa": "فارسی",
706706
"lang_pl": "Polski",
707707
"lang_ro": "Română",
708-
"lang_ru": "Русский",
709708
"lang_sr": "Српски",
710709
"lang_sk": "Slovenčina",
711710
"lang_sl": "Slovenščina",
@@ -891,5 +890,10 @@
891890
"voice": "Голос",
892891
"speed": "Скорость",
893892
"language": "Язык"
893+
},
894+
"accessCode": {
895+
"title": "Введите код доступа",
896+
"placeholder": "Код доступа",
897+
"error": "Неверный код доступа. Попробуйте ещё раз."
894898
}
895899
}

0 commit comments

Comments
 (0)