diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..0a98352
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from '@/auth';
+
+export const { GET, POST } = handlers;
diff --git a/app/globals.css b/app/globals.css
index 1ebf9b5..4074452 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -302,3 +302,32 @@ header select option {
text-align: left;
}
.content th { color: var(--green); background: #111; }
+
+/* ── login page ── */
+.login-page {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+}
+
+.login-box {
+ border: 1px solid var(--border);
+ padding: 40px 48px;
+ text-align: center;
+}
+
+.login-box button {
+ background: transparent;
+ color: var(--green);
+ border: 1px solid var(--green);
+ font-family: inherit;
+ font-size: 14px;
+ padding: 8px 16px;
+ cursor: pointer;
+}
+
+.login-box button:hover {
+ background: var(--green);
+ color: var(--bg);
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..1105711
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,17 @@
+import { signIn } from '@/auth';
+
+export default function LoginPage() {
+ return (
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 0b85a51..f0ee49e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
+import { signOut } from 'next-auth/react';
import { renderMarkdown } from '@/lib/markdown';
type Role = 'user' | 'assistant';
@@ -148,6 +149,7 @@ export default function Home() {
{messages.length > 0 && (
)}
+
{showSettings && (
diff --git a/auth.ts b/auth.ts
new file mode 100644
index 0000000..f1ce05c
--- /dev/null
+++ b/auth.ts
@@ -0,0 +1,16 @@
+import NextAuth from 'next-auth';
+import Google from 'next-auth/providers/google';
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers: [
+ Google({
+ clientId: process.env.GOOGLE_ID!,
+ clientSecret: process.env.GOOGLE_SECRET!,
+ }),
+ ],
+ callbacks: {
+ signIn({ user }) {
+ return user.email === process.env.ALLOWED_EMAIL;
+ },
+ },
+});
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..fbf51da
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,15 @@
+import { auth } from '@/auth';
+import { NextResponse } from 'next/server';
+
+export default auth((req) => {
+ if (!req.auth) {
+ if (req.nextUrl.pathname.startsWith('/api/')) {
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ return NextResponse.redirect(new URL('/login', req.url));
+ }
+});
+
+export const config = {
+ matcher: ['/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)'],
+};
diff --git a/package.json b/package.json
index 6f4f680..6bba850 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"marked": "^17.0.4",
"marked-highlight": "^2.2.3",
"next": "^15.2.3",
+ "next-auth": "5.0.0-beta.30",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2ceef1e..2923034 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
next:
specifier: ^15.2.3
version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-auth:
+ specifier: 5.0.0-beta.30
+ version: 5.0.0-beta.30(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.0.0
version: 19.2.4
@@ -42,6 +45,20 @@ importers:
packages:
+ '@auth/core@0.41.0':
+ resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ nodemailer: ^6.8.0
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
'@emnapi/runtime@1.9.0':
resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==}
@@ -233,6 +250,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@panva/hkdf@1.2.1':
+ resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -264,6 +284,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
+ jose@6.2.1:
+ resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
+
marked-highlight@2.2.3:
resolution: {integrity: sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==}
peerDependencies:
@@ -279,6 +302,22 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ next-auth@5.0.0-beta.30:
+ resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
+ nodemailer: ^7.0.7
+ react: ^18.2.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
next@15.5.12:
resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -300,6 +339,9 @@ packages:
sass:
optional: true
+ oauth4webapi@3.8.5:
+ resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -307,6 +349,14 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
+ preact-render-to-string@6.5.11:
+ resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
+ peerDependencies:
+ preact: '>=10'
+
+ preact@10.24.3:
+ resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
+
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -358,6 +408,14 @@ packages:
snapshots:
+ '@auth/core@0.41.0':
+ dependencies:
+ '@panva/hkdf': 1.2.1
+ jose: 6.2.1
+ oauth4webapi: 3.8.5
+ preact: 10.24.3
+ preact-render-to-string: 6.5.11(preact@10.24.3)
+
'@emnapi/runtime@1.9.0':
dependencies:
tslib: 2.8.1
@@ -486,6 +544,8 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.12':
optional: true
+ '@panva/hkdf@1.2.1': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -513,6 +573,8 @@ snapshots:
highlight.js@11.11.1: {}
+ jose@6.2.1: {}
+
marked-highlight@2.2.3(marked@17.0.4):
dependencies:
marked: 17.0.4
@@ -521,6 +583,12 @@ snapshots:
nanoid@3.3.11: {}
+ next-auth@5.0.0-beta.30(next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@auth/core': 0.41.0
+ next: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+
next@15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 15.5.12
@@ -544,6 +612,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ oauth4webapi@3.8.5: {}
+
picocolors@1.1.1: {}
postcss@8.4.31:
@@ -552,6 +622,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ preact-render-to-string@6.5.11(preact@10.24.3):
+ dependencies:
+ preact: 10.24.3
+
+ preact@10.24.3: {}
+
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4