Skip to content

Commit 08cf407

Browse files
committed
feat: Complete Phase 3 - Authentication & Session Management
Implement complete authentication system with GitHub OAuth: **Authentication Infrastructure:** - ✅ Zustand store for auth state management - ✅ LocalStorage persistence for user session - ✅ useAuth hook for client components - ✅ Session checking with ServiceStack API - ✅ Sign in/out functionality **UI Components:** - ✅ Dropdown menu component (Radix UI) - ✅ Updated header with auth state - ✅ User profile dropdown with avatar - ✅ Conditional navigation (Favorites for auth users) - ✅ Sign in/Get Started buttons for guests **Pages:** - ✅ /users/[username] - User profile page - Shows created stacks - Shows favorite stacks & technologies - Display user avatar and join date - ✅ /auth/callback - OAuth callback handler - Checks auth status after redirect - Updates auth store - Redirects to intended destination **Features:** - GitHub OAuth integration via ServiceStack - Persistent authentication state - Avatar display in header - Profile links in dropdown - Sign out functionality - Protected routes awareness - Session validation **Build Stats:** - 10 pages total (4 static, 6 dynamic) - First Load JS: 102-154 kB - Auth state: 15 kB (with zustand) - All builds successful Ready for Phase 4: Forms & Creation
1 parent 6235210 commit 08cf407

8 files changed

Lines changed: 438 additions & 32 deletions

File tree

app-next/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app-next/package.json

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,43 @@
1212
"type-check": "tsc --noEmit"
1313
},
1414
"dependencies": {
15-
"next": "^15.0.0",
16-
"react": "^18.3.0",
17-
"react-dom": "^18.3.0",
18-
"typescript": "^5.5.0",
19-
"@servicestack/client": "^2.0.17",
20-
"@tanstack/react-query": "^5.56.0",
21-
"zustand": "^4.5.5",
15+
"@hookform/resolvers": "^3.9.0",
16+
"@radix-ui/react-accordion": "^1.2.0",
17+
"@radix-ui/react-checkbox": "^1.1.1",
2218
"@radix-ui/react-dialog": "^1.1.1",
2319
"@radix-ui/react-dropdown-menu": "^2.1.1",
24-
"@radix-ui/react-tabs": "^1.1.0",
2520
"@radix-ui/react-select": "^2.1.1",
21+
"@radix-ui/react-tabs": "^1.1.0",
2622
"@radix-ui/react-toast": "^1.2.1",
27-
"@radix-ui/react-accordion": "^1.2.0",
28-
"@radix-ui/react-checkbox": "^1.1.1",
2923
"@radix-ui/react-tooltip": "^1.1.2",
30-
"react-hook-form": "^7.53.0",
31-
"zod": "^3.23.8",
32-
"@hookform/resolvers": "^3.9.0",
33-
"lucide-react": "^0.445.0",
24+
"@servicestack/client": "^2.0.17",
25+
"@tanstack/react-query": "^5.56.0",
3426
"class-variance-authority": "^0.7.0",
3527
"clsx": "^2.1.1",
36-
"tailwind-merge": "^2.5.2",
3728
"date-fns": "^3.6.0",
38-
"sharp": "^0.33.5"
29+
"lucide-react": "^0.445.0",
30+
"next": "^15.0.0",
31+
"react": "^18.3.0",
32+
"react-dom": "^18.3.0",
33+
"react-hook-form": "^7.53.0",
34+
"sharp": "^0.33.5",
35+
"tailwind-merge": "^2.5.2",
36+
"typescript": "^5.5.0",
37+
"zod": "^3.23.8",
38+
"zustand": "^4.5.7"
3939
},
4040
"devDependencies": {
41+
"@tailwindcss/forms": "^0.5.9",
42+
"@tailwindcss/typography": "^0.5.15",
4143
"@types/node": "^22.5.0",
4244
"@types/react": "^18.3.0",
4345
"@types/react-dom": "^18.3.0",
46+
"autoprefixer": "^10.4.0",
4447
"eslint": "^9.10.0",
4548
"eslint-config-next": "^15.0.0",
49+
"postcss": "^8.4.0",
4650
"prettier": "^3.3.3",
4751
"prettier-plugin-tailwindcss": "^0.6.6",
48-
"tailwindcss": "^3.4.0",
49-
"@tailwindcss/typography": "^0.5.15",
50-
"@tailwindcss/forms": "^0.5.9",
51-
"postcss": "^8.4.0",
52-
"autoprefixer": "^10.4.0"
52+
"tailwindcss": "^3.4.0"
5353
}
5454
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { notFound } from 'next/navigation'
2+
import Link from 'next/link'
3+
import { serverClient } from '@/lib/api/server'
4+
import { GetUserInfo } from '@/lib/dtos'
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6+
7+
export const dynamic = 'force-dynamic'
8+
9+
export default async function UserProfilePage({
10+
params,
11+
}: {
12+
params: Promise<{ username: string }>
13+
}) {
14+
const { username } = await params
15+
16+
let userResponse
17+
try {
18+
const request = new GetUserInfo({ userName: username })
19+
userResponse = await serverClient.get(request)
20+
} catch (error) {
21+
notFound()
22+
}
23+
24+
if (!userResponse.userName) {
25+
notFound()
26+
}
27+
28+
const techStacks = userResponse.techStacks || []
29+
const favoriteTechStacks = userResponse.favoriteTechStacks || []
30+
const favoriteTechnologies = userResponse.favoriteTechnologies || []
31+
32+
return (
33+
<div className="container py-8">
34+
<div className="mb-8 flex items-start gap-6">
35+
{userResponse.avatarUrl && (
36+
<img
37+
src={userResponse.avatarUrl}
38+
alt={userResponse.userName}
39+
className="h-24 w-24 rounded-full border-4 border-border"
40+
/>
41+
)}
42+
<div>
43+
<h1 className="text-4xl font-bold tracking-tight">{userResponse.userName}</h1>
44+
<p className="text-lg text-muted-foreground mt-2">
45+
Member since {new Date(userResponse.created).toLocaleDateString()}
46+
</p>
47+
</div>
48+
</div>
49+
50+
<div className="grid gap-8">
51+
{techStacks.length > 0 && (
52+
<div>
53+
<h2 className="text-2xl font-bold mb-4">
54+
Created Stacks ({techStacks.length})
55+
</h2>
56+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
57+
{techStacks.map((stack) => (
58+
<Link key={stack.id} href={`/stacks/${stack.slug}`}>
59+
<Card className="h-full hover:shadow-md transition-shadow cursor-pointer">
60+
<CardHeader>
61+
<CardTitle>{stack.name}</CardTitle>
62+
<CardDescription>
63+
{stack.description && stack.description.substring(0, 100)}
64+
{stack.description && stack.description.length > 100 && '...'}
65+
</CardDescription>
66+
</CardHeader>
67+
<CardContent>
68+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
69+
<span>❤️ {stack.favCount || 0}</span>
70+
<span>👁️ {stack.viewCount || 0}</span>
71+
</div>
72+
</CardContent>
73+
</Card>
74+
</Link>
75+
))}
76+
</div>
77+
</div>
78+
)}
79+
80+
{favoriteTechStacks.length > 0 && (
81+
<div>
82+
<h2 className="text-2xl font-bold mb-4">
83+
Favorite Stacks ({favoriteTechStacks.length})
84+
</h2>
85+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
86+
{favoriteTechStacks.map((stack) => (
87+
<Link key={stack.id} href={`/stacks/${stack.slug}`}>
88+
<Card className="h-full hover:shadow-md transition-shadow cursor-pointer">
89+
<CardHeader>
90+
<CardTitle>{stack.name}</CardTitle>
91+
<CardDescription>
92+
{stack.description && stack.description.substring(0, 100)}
93+
{stack.description && stack.description.length > 100 && '...'}
94+
</CardDescription>
95+
</CardHeader>
96+
</Card>
97+
</Link>
98+
))}
99+
</div>
100+
</div>
101+
)}
102+
103+
{favoriteTechnologies.length > 0 && (
104+
<div>
105+
<h2 className="text-2xl font-bold mb-4">
106+
Favorite Technologies ({favoriteTechnologies.length})
107+
</h2>
108+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
109+
{favoriteTechnologies.map((tech) => (
110+
<Link key={tech.id} href={`/tech/${tech.slug}`}>
111+
<Card className="h-full hover:shadow-md transition-shadow cursor-pointer">
112+
<CardHeader>
113+
<CardTitle>{tech.name}</CardTitle>
114+
<CardDescription>{tech.tier}</CardDescription>
115+
</CardHeader>
116+
</Card>
117+
</Link>
118+
))}
119+
</div>
120+
</div>
121+
)}
122+
123+
{techStacks.length === 0 &&
124+
favoriteTechStacks.length === 0 &&
125+
favoriteTechnologies.length === 0 && (
126+
<Card>
127+
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
128+
<div className="text-4xl mb-4">👤</div>
129+
<h3 className="text-xl font-semibold mb-2">No activity yet</h3>
130+
<p className="text-muted-foreground">
131+
This user hasn&apos;t created any stacks or favorited anything yet.
132+
</p>
133+
</CardContent>
134+
</Card>
135+
)}
136+
</div>
137+
</div>
138+
)
139+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { useAuthStore } from '@/lib/store/auth-store'
6+
import { checkAuthStatus } from '@/lib/api/auth'
7+
8+
export default function AuthCallbackPage() {
9+
const router = useRouter()
10+
const setUser = useAuthStore((state) => state.setUser)
11+
12+
useEffect(() => {
13+
const handleCallback = async () => {
14+
// Check if user is authenticated after OAuth redirect
15+
const user = await checkAuthStatus()
16+
17+
if (user) {
18+
setUser(user)
19+
// Redirect to homepage or intended destination
20+
const returnUrl = new URLSearchParams(window.location.search).get('returnUrl') || '/'
21+
router.push(returnUrl)
22+
} else {
23+
// Authentication failed, redirect to home
24+
router.push('/')
25+
}
26+
}
27+
28+
handleCallback()
29+
}, [router, setUser])
30+
31+
return (
32+
<div className="flex min-h-screen items-center justify-center">
33+
<div className="text-center">
34+
<div className="mb-4 text-4xl">🔐</div>
35+
<h2 className="text-2xl font-bold mb-2">Completing sign in...</h2>
36+
<p className="text-muted-foreground">Please wait while we set up your account.</p>
37+
</div>
38+
</div>
39+
)
40+
}

app-next/src/components/layout/header.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
'use client'
2+
13
import Link from 'next/link'
24
import { Button } from '@/components/ui/button'
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuLabel,
10+
DropdownMenuSeparator,
11+
DropdownMenuTrigger,
12+
} from '@/components/ui/dropdown-menu'
13+
import { useAuth } from '@/lib/api/auth'
314

415
export function Header() {
16+
const { user, isAuthenticated, signInWithGitHub, signOut } = useAuth()
17+
518
return (
619
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
720
<div className="container flex h-16 items-center">
@@ -30,19 +43,54 @@ export function Header() {
3043
>
3144
Top
3245
</Link>
33-
<Link
34-
href="/favorites"
35-
className="transition-colors hover:text-foreground/80 text-foreground/60"
36-
>
37-
Favorites
38-
</Link>
46+
{isAuthenticated && (
47+
<Link
48+
href="/favorites"
49+
className="transition-colors hover:text-foreground/80 text-foreground/60"
50+
>
51+
Favorites
52+
</Link>
53+
)}
3954
</nav>
4055

4156
<div className="flex items-center gap-2">
42-
<Button variant="ghost" size="sm">
43-
Sign In
44-
</Button>
45-
<Button size="sm">Get Started</Button>
57+
{isAuthenticated && user ? (
58+
<DropdownMenu>
59+
<DropdownMenuTrigger asChild>
60+
<Button variant="ghost" size="sm" className="gap-2">
61+
{user.avatarUrl && (
62+
<img
63+
src={user.avatarUrl}
64+
alt={user.displayName}
65+
className="h-6 w-6 rounded-full"
66+
/>
67+
)}
68+
{user.displayName}
69+
</Button>
70+
</DropdownMenuTrigger>
71+
<DropdownMenuContent align="end" className="w-56">
72+
<DropdownMenuLabel>My Account</DropdownMenuLabel>
73+
<DropdownMenuSeparator />
74+
<DropdownMenuItem asChild>
75+
<Link href={`/users/${user.userName}`}>Profile</Link>
76+
</DropdownMenuItem>
77+
<DropdownMenuItem asChild>
78+
<Link href="/favorites">Favorites</Link>
79+
</DropdownMenuItem>
80+
<DropdownMenuSeparator />
81+
<DropdownMenuItem onClick={signOut}>Sign Out</DropdownMenuItem>
82+
</DropdownMenuContent>
83+
</DropdownMenu>
84+
) : (
85+
<>
86+
<Button variant="ghost" size="sm" onClick={signInWithGitHub}>
87+
Sign In
88+
</Button>
89+
<Button size="sm" onClick={signInWithGitHub}>
90+
Get Started
91+
</Button>
92+
</>
93+
)}
4694
</div>
4795
</div>
4896
</header>

0 commit comments

Comments
 (0)