Skip to content

Commit 5aea472

Browse files
committed
feat: add optional Google OAuth authentication
Gate dashboard access behind Google sign-in when AUTH_SECRET is set. Uses Auth.js v5 with JWT sessions (no DB changes), Next.js 16 proxy, domain/email restriction, and sign-out menu in nav bar. Made-with: Cursor
1 parent 1462897 commit 5aea472

10 files changed

Lines changed: 359 additions & 4 deletions

File tree

.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ ALERT_EMAIL_TO=team-lead@yourcompany.com
1111

1212
CRON_SECRET=your-secret-for-cron-endpoint
1313

14-
DASHBOARD_PASSWORD=
14+
# Authentication (optional — dashboard is open when these are not set)
15+
# Setting AUTH_SECRET enables Google OAuth sign-in
16+
AUTH_SECRET= # openssl rand -base64 32
17+
AUTH_GOOGLE_ID= # Google OAuth client ID
18+
AUTH_GOOGLE_SECRET= # Google OAuth client secret
19+
AUTH_ALLOWED_DOMAIN= # e.g. yourcompany.com (only this domain can sign in)
20+
AUTH_ALLOWED_EMAILS= # comma-separated allowlist, e.g. admin@example.com,viewer@example.com
21+
AUTH_TRUST_HOST=true # required when behind a reverse proxy (Fly.io, Vercel, etc.)
22+
AUTH_URL= # public URL, e.g. https://your-app.fly.dev (auto-detected locally)
1523

1624
ELEVENLABS_API_KEY=

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,6 @@ DASHBOARD_URL=http://localhost:3000
193193

194194
# Optional
195195
CRON_SECRET=your_secret_here # protects the cron endpoint
196-
DASHBOARD_PASSWORD=your_password # optional basic auth for the dashboard
197196

198197
# Email alerts via Resend (optional)
199198
RESEND_API_KEY=re_xxxxxxxxxxxx
@@ -376,6 +375,44 @@ The import uses HiBob's `Group` and `Team` columns (falling back to `Department`
376375

377376
---
378377

378+
## Authentication
379+
380+
Authentication is **fully optional**. When no auth environment variables are set, the dashboard is open (the default behavior). Setting `AUTH_SECRET` enables Google OAuth sign-in.
381+
382+
### Setup
383+
384+
1. Create a [Google OAuth app](https://console.cloud.google.com/apis/credentials) with redirect URI:
385+
- Local: `http://localhost:3000/api/auth/callback/google`
386+
- Production: `https://your-domain.com/api/auth/callback/google`
387+
388+
2. Add to your `.env`:
389+
390+
```bash
391+
AUTH_SECRET=$(openssl rand -base64 32) # encryption key for sessions
392+
AUTH_GOOGLE_ID=your-client-id.apps.google... # Google OAuth client ID
393+
AUTH_GOOGLE_SECRET=GOCSPX-... # Google OAuth client secret
394+
AUTH_TRUST_HOST=true # required behind a reverse proxy
395+
AUTH_URL=https://your-domain.com # public URL (auto-detected locally)
396+
```
397+
398+
3. Optionally restrict access by domain or specific emails:
399+
400+
```bash
401+
AUTH_ALLOWED_DOMAIN=yourcompany.com # only @yourcompany.com emails
402+
AUTH_ALLOWED_EMAILS=admin@example.com,cto@example.com # or specific emails
403+
```
404+
405+
When both are set, either match grants access. When neither is set, any Google account can sign in.
406+
407+
### How It Works
408+
409+
- Sessions use encrypted JWT cookies — no database tables needed
410+
- The `/api/cron` endpoint is excluded from auth (it uses its own `CRON_SECRET`)
411+
- Sign-in page appears automatically when auth is enabled
412+
- User avatar and sign-out menu appear in the nav bar
413+
414+
---
415+
379416
## API Endpoints
380417

381418
| Endpoint | Method | Description |

package-lock.json

Lines changed: 103 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@tailwindcss/postcss": "^4.1.18",
7474
"better-sqlite3": "^12.6.2",
7575
"next": "^16.1.6",
76+
"next-auth": "^5.0.0-beta.30",
7677
"react": "^19.2.4",
7778
"react-dom": "^19.2.4",
7879
"recharts": "^3.7.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { handlers } from "@/auth";
2+
3+
export const { GET, POST } = handlers;

src/app/layout.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import Image from "next/image";
33
import Link from "next/link";
44
import { NavLinks } from "@/components/nav-links";
55
import { UpgradeBanner } from "@/components/upgrade-banner";
6+
import { UserMenu } from "@/components/user-menu";
7+
import { auth, signOut } from "@/auth";
68
import "./globals.css";
79

810
export const metadata: Metadata = {
@@ -11,7 +13,9 @@ export const metadata: Metadata = {
1113
icons: { icon: "/favicon.png" },
1214
};
1315

14-
export default function RootLayout({ children }: { children: React.ReactNode }) {
16+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
17+
const session = process.env.AUTH_SECRET ? await auth() : null;
18+
1519
return (
1620
<html lang="en" className="dark">
1721
<body className="min-h-screen">
@@ -25,7 +29,20 @@ export default function RootLayout({ children }: { children: React.ReactNode })
2529
</Link>
2630
<NavLinks />
2731
</div>
28-
<UpgradeBanner />
32+
<div className="flex items-center gap-2">
33+
<UpgradeBanner />
34+
{session?.user && (
35+
<UserMenu
36+
name={session.user.name ?? session.user.email ?? "User"}
37+
email={session.user.email ?? ""}
38+
image={session.user.image ?? undefined}
39+
signOutAction={async () => {
40+
"use server";
41+
await signOut({ redirectTo: "/login" });
42+
}}
43+
/>
44+
)}
45+
</div>
2946
</div>
3047
</div>
3148
</nav>

src/app/login/page.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { redirect } from "next/navigation";
2+
import { auth, signIn } from "@/auth";
3+
import Image from "next/image";
4+
5+
export default async function LoginPage({
6+
searchParams,
7+
}: {
8+
searchParams: Promise<{ callbackUrl?: string }>;
9+
}) {
10+
const session = await auth();
11+
if (session?.user) redirect("/");
12+
13+
const { callbackUrl } = await searchParams;
14+
15+
return (
16+
<div className="min-h-[80vh] flex items-center justify-center">
17+
<div className="w-full max-w-sm mx-4 rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl overflow-hidden">
18+
<div className="h-1 w-full bg-gradient-to-r from-blue-500 via-blue-400 to-cyan-500" />
19+
20+
<div className="px-8 pt-8 pb-6 text-center">
21+
<Image
22+
src="/logo.png"
23+
alt=""
24+
width={40}
25+
height={40}
26+
className="mx-auto mb-4"
27+
aria-hidden
28+
/>
29+
<h1 className="text-xl font-bold text-white mb-1">Cursor Usage Tracker</h1>
30+
<p className="text-sm text-zinc-400">Sign in to access the dashboard</p>
31+
</div>
32+
33+
<div className="px-8 pb-8">
34+
<form
35+
action={async () => {
36+
"use server";
37+
await signIn("google", { redirectTo: callbackUrl ?? "/" });
38+
}}
39+
>
40+
<button
41+
type="submit"
42+
className="w-full flex items-center justify-center gap-3 rounded-xl py-3 px-4 text-sm font-medium text-white bg-zinc-800 border border-zinc-700 hover:bg-zinc-700 hover:border-zinc-600 transition-all cursor-pointer"
43+
>
44+
<svg width="18" height="18" viewBox="0 0 24 24">
45+
<path
46+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
47+
fill="#4285F4"
48+
/>
49+
<path
50+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
51+
fill="#34A853"
52+
/>
53+
<path
54+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
55+
fill="#FBBC05"
56+
/>
57+
<path
58+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
59+
fill="#EA4335"
60+
/>
61+
</svg>
62+
Sign in with Google
63+
</button>
64+
</form>
65+
</div>
66+
</div>
67+
</div>
68+
);
69+
}

src/auth.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import NextAuth from "next-auth";
2+
import Google from "next-auth/providers/google";
3+
4+
const allowedDomain = process.env.AUTH_ALLOWED_DOMAIN?.toLowerCase().trim();
5+
const allowedEmails = process.env.AUTH_ALLOWED_EMAILS?.split(",")
6+
.map((e) => e.toLowerCase().trim())
7+
.filter(Boolean);
8+
9+
export const { handlers, auth, signIn, signOut } = NextAuth({
10+
providers: [Google],
11+
pages: { signIn: "/login" },
12+
session: { strategy: "jwt" },
13+
callbacks: {
14+
signIn({ profile }) {
15+
const email = profile?.email?.toLowerCase();
16+
if (!email) return false;
17+
if (allowedEmails?.length && allowedEmails.includes(email)) return true;
18+
if (allowedDomain && email.endsWith(`@${allowedDomain}`)) return true;
19+
if (!allowedEmails?.length && !allowedDomain) return true;
20+
return false;
21+
},
22+
},
23+
});

0 commit comments

Comments
 (0)