Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,8 @@ DEFAULT_MODEL=
# Set to "true" to allow private/local network URLs (e.g. localhost, 192.168.x.x).
# Required for self-hosted models like Ollama. Do NOT enable on public deployments.
# ALLOW_LOCAL_NETWORKS=true

# --- Access Control -----------------------------------------------------------
# Set a password to restrict site access. When set, users must enter this code
# before using the app. Leave empty or remove to disable access control.
# ACCESS_CODE=your-secret-code
17 changes: 17 additions & 0 deletions app/api/access-code/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cookies } from 'next/headers';
import { apiSuccess } from '@/lib/server/api-response';
import { verifyAccessToken } from '@/app/api/access-code/verify/route';

export async function GET() {
const accessCode = process.env.ACCESS_CODE;
const enabled = !!accessCode;

let authenticated = false;
if (enabled) {
const cookieStore = await cookies();
const token = cookieStore.get('openmaic_access')?.value;
authenticated = !!token && verifyAccessToken(token, accessCode);
}

return apiSuccess({ enabled, authenticated });
}
64 changes: 64 additions & 0 deletions app/api/access-code/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { cookies } from 'next/headers';
import { createHmac, timingSafeEqual } from 'crypto';
import { apiError, apiSuccess } from '@/lib/server/api-response';

/** Create an HMAC-signed token: `timestamp.signature` */
function createAccessToken(accessCode: string): string {
const timestamp = Date.now().toString();
const signature = createHmac('sha256', accessCode).update(timestamp).digest('hex');
return `${timestamp}.${signature}`;
}

/** Verify an HMAC-signed token against the access code */
export function verifyAccessToken(token: string, accessCode: string): boolean {
const dotIndex = token.indexOf('.');
if (dotIndex === -1) return false;

const timestamp = token.substring(0, dotIndex);
const signature = token.substring(dotIndex + 1);

const expected = createHmac('sha256', accessCode).update(timestamp).digest('hex');

const sigBuf = Buffer.from(signature, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length !== expBuf.length) return false;

return timingSafeEqual(sigBuf, expBuf);
}

export async function POST(request: Request) {
const accessCode = process.env.ACCESS_CODE;
if (!accessCode) {
return apiSuccess({ valid: true });
}

let body: { code?: string };
try {
body = await request.json();
} catch {
return apiError('INVALID_REQUEST', 400, 'Invalid JSON body');
}

// Constant-time comparison
if (!body.code) {
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
}
const encoder = new TextEncoder();
const a = encoder.encode(body.code);
const b = encoder.encode(accessCode);
if (a.byteLength !== b.byteLength || !timingSafeEqual(a, b)) {
return apiError('INVALID_REQUEST', 401, 'Invalid access code');
}

const token = createAccessToken(accessCode);
const cookieStore = await cookies();
cookieStore.set('openmaic_access', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
secure: process.env.NODE_ENV === 'production',
});

return apiSuccess({ valid: true });
}
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ThemeProvider } from '@/lib/hooks/use-theme';
import { I18nProvider } from '@/lib/hooks/use-i18n';
import { Toaster } from '@/components/ui/sonner';
import { ServerProvidersInit } from '@/components/server-providers-init';
import { AccessCodeGuard } from '@/components/access-code-guard';

const inter = localFont({
src: '../node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2',
Expand Down Expand Up @@ -36,7 +37,7 @@ export default function RootLayout({
<ThemeProvider>
<I18nProvider>
<ServerProvidersInit />
{children}
<AccessCodeGuard>{children}</AccessCodeGuard>
<Toaster position="top-center" />
</I18nProvider>
</ThemeProvider>
Expand Down
50 changes: 50 additions & 0 deletions components/access-code-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import { useEffect, useState, ReactNode } from 'react';
import { AccessCodeModal } from '@/components/access-code-modal';

export function AccessCodeGuard({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<{
enabled: boolean;
authenticated: boolean;
loading: boolean;
}>({ enabled: false, authenticated: false, loading: true });

useEffect(() => {
let cancelled = false;
fetch('/api/access-code/status')
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setStatus({
enabled: data.enabled,
authenticated: data.authenticated,
loading: false,
});
}
})
.catch(() => {
if (!cancelled) {
// Default to requiring auth on error — safer than silently disabling
setStatus({ enabled: true, authenticated: false, loading: false });
}
});
return () => {
cancelled = true;
};
}, []);

const needsAuth = !status.loading && status.enabled && !status.authenticated;

return (
<>
{needsAuth && (
<AccessCodeModal
open={true}
onSuccess={() => setStatus((s) => ({ ...s, authenticated: true }))}
/>
)}
{children}
</>
);
}
198 changes: 198 additions & 0 deletions components/access-code-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
'use client';

import { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { ArrowRight, ShieldCheck, LoaderCircle } from 'lucide-react';

interface AccessCodeModalProps {
open: boolean;
onSuccess: () => void;
}

export function AccessCodeModal({ open, onSuccess }: AccessCodeModalProps) {
const { t } = useI18n();
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!code || loading) return;
setError('');
setLoading(true);

try {
const res = await fetch('/api/access-code/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});

if (res.ok) {
setSuccess(true);
setTimeout(onSuccess, 600);
} else {
setError(t('accessCode.error'));
setCode('');
inputRef.current?.focus();
}
} catch {
setError(t('accessCode.error'));
} finally {
setLoading(false);
}
}

return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-[200] flex items-center justify-center overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
{/* Background — subtle mesh gradient */}
<div className="absolute inset-0 bg-background">
<div
className="absolute inset-0 opacity-30 dark:opacity-20"
style={{
backgroundImage: `
radial-gradient(ellipse 80% 60% at 20% 40%, var(--primary) 0%, transparent 60%),
radial-gradient(ellipse 60% 80% at 80% 20%, oklch(0.6 0.15 280) 0%, transparent 50%),
radial-gradient(ellipse 50% 50% at 60% 80%, oklch(0.5 0.12 300) 0%, transparent 50%)
`,
}}
/>
{/* Subtle noise texture */}
<div
className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05]"
style={{
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")`,
backgroundSize: '128px 128px',
}}
/>
</div>

{/* Content card */}
<motion.div
className="relative z-10 w-full max-w-sm mx-4"
initial={{ opacity: 0, y: 20, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.96 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
>
<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">
{/* Icon */}
<motion.div
className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
>
<ShieldCheck className="h-7 w-7 text-primary" strokeWidth={1.5} />
</motion.div>

{/* Title */}
<motion.h1
className="mb-1 text-center text-lg font-semibold tracking-tight text-foreground"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
>
{t('accessCode.title')}
</motion.h1>

<motion.p
className="mb-6 text-center text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25, duration: 0.4 }}
>
OpenMAIC
</motion.p>

{/* Form */}
<motion.form
onSubmit={handleSubmit}
className="space-y-4"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
<div className="relative">
<input
ref={inputRef}
type="password"
placeholder={t('accessCode.placeholder')}
value={code}
onChange={(e) => {
setCode(e.target.value);
if (error) setError('');
}}
className={`
w-full rounded-xl border bg-background/60 px-4 py-3 pr-12 text-sm
outline-none transition-all duration-200
placeholder:text-muted-foreground/50
focus:border-primary/40 focus:ring-2 focus:ring-primary/10
${error ? 'border-destructive/50 focus:border-destructive/50 focus:ring-destructive/10' : 'border-border/60'}
`}
disabled={loading || success}
autoComplete="off"
/>
<button
type="submit"
disabled={!code || loading || success}
className={`
absolute right-2 top-1/2 -translate-y-1/2 flex h-8 w-8 items-center
justify-center rounded-lg transition-all duration-200
${code && !loading && !success ? 'bg-primary text-primary-foreground hover:opacity-90 cursor-pointer' : 'text-muted-foreground/30 cursor-default'}
`}
>
{loading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : success ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<ShieldCheck className="h-4 w-4 text-emerald-500" />
</motion.div>
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</div>

{/* Error message */}
<AnimatePresence mode="wait">
{error && (
<motion.p
className="text-center text-sm text-destructive"
initial={{ opacity: 0, y: -4, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -4, height: 0 }}
transition={{ duration: 0.2 }}
>
{error}
</motion.p>
)}
</AnimatePresence>
</motion.form>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
5 changes: 5 additions & 0 deletions lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -890,5 +890,10 @@
"voice": "Voice",
"speed": "Speed",
"language": "Language"
},
"accessCode": {
"title": "Enter Access Code",
"placeholder": "Access code",
"error": "Invalid access code. Please try again."
}
}
5 changes: 5 additions & 0 deletions lib/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -890,5 +890,10 @@
"voice": "ボイス",
"speed": "速度",
"language": "言語"
},
"accessCode": {
"title": "アクセスコードを入力",
"placeholder": "アクセスコード",
"error": "アクセスコードが正しくありません。もう一度お試しください。"
}
}
6 changes: 5 additions & 1 deletion lib/i18n/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,6 @@
"lang_fa": "فارسی",
"lang_pl": "Polski",
"lang_ro": "Română",
"lang_ru": "Русский",
"lang_sr": "Српски",
"lang_sk": "Slovenčina",
"lang_sl": "Slovenščina",
Expand Down Expand Up @@ -891,5 +890,10 @@
"voice": "Голос",
"speed": "Скорость",
"language": "Язык"
},
"accessCode": {
"title": "Введите код доступа",
"placeholder": "Код доступа",
"error": "Неверный код доступа. Попробуйте ещё раз."
}
}
Loading
Loading