Skip to content

Commit 0067280

Browse files
Merge pull request #66 from HrushiBorhade/step-5/better-auth
feat: Better Auth + GitHub OAuth + DAL + admin plugin
2 parents 2552a96 + 7e8ef87 commit 0067280

21 files changed

Lines changed: 946 additions & 54 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ dist/
99
.claude/
1010
.code-indexer/
1111
.superpowers/
12+
.vercel
13+
.env*.local
14+
.playwright-mcp/

apps/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
"@codeindexer/db": "workspace:*",
1616
"@phosphor-icons/react": "^2.1.10",
1717
"@tanstack/react-query": "^5.80.6",
18+
"better-auth": "^1.5.5",
1819
"class-variance-authority": "^0.7.1",
1920
"clsx": "^2.1.1",
2021
"next": "16.2.0",
22+
"next-themes": "^0.4.6",
2123
"radix-ui": "^1.4.3",
2224
"react": "19.2.4",
2325
"react-dom": "19.2.4",
26+
"server-only": "^0.0.1",
2427
"sonner": "^2.0.7",
2528
"tailwind-merge": "^3.5.0",
2629
"zustand": "^5.0.5"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { auth } from '@/lib/auth';
2+
import { toNextJsHandler } from 'better-auth/next-js';
3+
4+
export const { POST, GET } = toNextJsHandler(auth);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getSession } from '@/lib/dal';
2+
import { SignOutButton } from '@/components/sign-out-button';
3+
4+
export default async function DashboardPage() {
5+
const session = await getSession();
6+
7+
return (
8+
<div className="p-8">
9+
<div className="flex items-center justify-between">
10+
<div>
11+
<h1 className="text-2xl font-bold">Dashboard</h1>
12+
<p className="mt-1 text-muted-foreground">Welcome, {session.user.name}</p>
13+
</div>
14+
<SignOutButton />
15+
</div>
16+
</div>
17+
);
18+
}

apps/web/src/app/layout.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Metadata } from 'next';
22
import { Geist, JetBrains_Mono } from 'next/font/google';
33
import { cn } from '@/lib/utils';
4+
import { Providers } from '@/components/providers';
5+
import { ThemeToggle } from '@/components/theme-toggle';
46
import './globals.css';
57

68
const geistSans = Geist({
@@ -26,9 +28,15 @@ export default function RootLayout({
2628
return (
2729
<html
2830
lang="en"
31+
suppressHydrationWarning
2932
className={cn('h-full antialiased', geistSans.variable, jetbrainsMono.variable)}
3033
>
31-
<body className="flex min-h-full flex-col">{children}</body>
34+
<body className="flex min-h-full flex-col">
35+
<Providers>
36+
<ThemeToggle />
37+
{children}
38+
</Providers>
39+
</body>
3240
</html>
3341
);
3442
}

apps/web/src/app/login/page.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { GithubLogo } from '@phosphor-icons/react';
5+
import { Button } from '@/components/ui/button';
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7+
import { authClient } from '@/lib/auth-client';
8+
9+
export default function LoginPage() {
10+
const [loading, setLoading] = useState(false);
11+
12+
async function handleGitHubSignIn() {
13+
setLoading(true);
14+
try {
15+
await authClient.signIn.social({
16+
provider: 'github',
17+
callbackURL: '/dashboard',
18+
});
19+
} catch {
20+
setLoading(false);
21+
}
22+
}
23+
24+
return (
25+
<div className="flex min-h-screen items-center justify-center p-4">
26+
<Card className="w-full max-w-sm">
27+
<CardHeader className="text-center">
28+
<CardTitle className="text-2xl">Sign in to CodeIndexer</CardTitle>
29+
<CardDescription>Connect your GitHub account to get started</CardDescription>
30+
</CardHeader>
31+
<CardContent>
32+
<Button className="w-full" size="lg" onClick={handleGitHubSignIn} disabled={loading}>
33+
<GithubLogo weight="bold" />
34+
{loading ? 'Redirecting...' : 'Continue with GitHub'}
35+
</Button>
36+
</CardContent>
37+
</Card>
38+
</div>
39+
);
40+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client';
2+
3+
import { ThemeProvider } from 'next-themes';
4+
import { Toaster } from 'sonner';
5+
import type { ReactNode } from 'react';
6+
import { useSessionSync } from '@/hooks/use-session-sync';
7+
8+
function SessionSync() {
9+
useSessionSync();
10+
return null;
11+
}
12+
13+
export function Providers({ children }: { children: ReactNode }) {
14+
return (
15+
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
16+
<SessionSync />
17+
{children}
18+
<Toaster />
19+
</ThemeProvider>
20+
);
21+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { SignOut } from '@phosphor-icons/react';
5+
import { useRouter } from 'next/navigation';
6+
import { Button } from '@/components/ui/button';
7+
import { authClient } from '@/lib/auth-client';
8+
9+
export function SignOutButton() {
10+
const router = useRouter();
11+
const [loading, setLoading] = useState(false);
12+
13+
async function handleSignOut() {
14+
setLoading(true);
15+
try {
16+
await authClient.signOut();
17+
const bc = new BroadcastChannel('codeindexer-session');
18+
bc.postMessage({ type: 'signed-out' });
19+
bc.close();
20+
router.push('/login');
21+
} catch {
22+
setLoading(false);
23+
}
24+
}
25+
26+
return (
27+
<Button variant="outline" size="sm" onClick={handleSignOut} disabled={loading}>
28+
<SignOut weight="bold" />
29+
{loading ? 'Signing out...' : 'Sign out'}
30+
</Button>
31+
);
32+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client';
2+
3+
import { Moon, Sun } from '@phosphor-icons/react';
4+
import { useTheme } from 'next-themes';
5+
import { Button } from '@/components/ui/button';
6+
7+
export function ThemeToggle() {
8+
const { theme, setTheme } = useTheme();
9+
10+
return (
11+
<Button
12+
variant="ghost"
13+
size="icon"
14+
className="fixed right-4 top-4 z-50"
15+
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
16+
>
17+
<Sun className="size-5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
18+
<Moon className="absolute size-5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
19+
<span className="sr-only">Toggle theme</span>
20+
</Button>
21+
);
22+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
3+
import { useEffect, useCallback } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { authClient } from '@/lib/auth-client';
6+
import { useTabLeader } from './use-tab-leader';
7+
8+
type SessionMessage =
9+
| { type: 'session-update'; user: { name: string; email: string; image?: string } }
10+
| { type: 'signed-out' };
11+
12+
/**
13+
* Cross-tab session synchronization using the Leader Election pattern.
14+
*
15+
* Leader tab: polls session periodically, broadcasts state to followers.
16+
* Follower tabs: listen for broadcasts, react to sign-out/session changes.
17+
* All tabs: redirect to /login on sign-out broadcast.
18+
*/
19+
export function useSessionSync() {
20+
const router = useRouter();
21+
22+
const onMessage = useCallback(
23+
(data: unknown) => {
24+
const msg = data as SessionMessage;
25+
if (msg.type === 'signed-out') {
26+
router.push('/login');
27+
} else if (msg.type === 'session-update') {
28+
// Could update local state/cache here if needed
29+
router.refresh();
30+
}
31+
},
32+
[router],
33+
);
34+
35+
const { isLeader, broadcast } = useTabLeader({
36+
channel: 'codeindexer-session',
37+
onMessage,
38+
});
39+
40+
// Leader polls session every 4 minutes (cookie cache is 5 min)
41+
useEffect(() => {
42+
if (!isLeader) return;
43+
44+
const interval = setInterval(
45+
async () => {
46+
const { data: session } = await authClient.getSession();
47+
if (!session) {
48+
broadcast({ type: 'signed-out' });
49+
router.push('/login');
50+
} else {
51+
broadcast({
52+
type: 'session-update',
53+
user: { name: session.user.name, email: session.user.email, image: session.user.image },
54+
});
55+
}
56+
},
57+
4 * 60 * 1000,
58+
); // 4 minutes
59+
60+
return () => clearInterval(interval);
61+
}, [isLeader, broadcast, router]);
62+
63+
return { isLeader, broadcast };
64+
}

0 commit comments

Comments
 (0)