Skip to content

Commit dd7919e

Browse files
authored
Merge pull request #98 from CS3219-AY2425S1/feat/add-oauth
add oauth
2 parents f71e7c0 + 5cd08e4 commit dd7919e

File tree

9 files changed

+232
-53
lines changed

9 files changed

+232
-53
lines changed

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ services:
4848
dockerfile: Dockerfile
4949
target: development
5050
volumes:
51-
- ./user-service:/app
52-
- ./user-service/node_modules:/app/node_modules
51+
- ./user-service:/usr/src/app
52+
- /usr/src/app/node_modules
5353
ports:
5454
- "3001:3001"
5555
networks:
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
import { Suspense, useEffect } from 'react';
3+
import { LoadingSpinner } from '@/components/ui/loading';
4+
import { useRouter, useSearchParams } from 'next/navigation';
5+
import { useAuthStore } from '@/state/useAuthStore';
6+
import { handleOAuthCallback } from '@/lib/oauth';
7+
8+
// Separate the component that uses useSearchParams
9+
function CallbackHandler() {
10+
const router = useRouter();
11+
const searchParams = useSearchParams();
12+
const setAuth = useAuthStore((state) => state.setAuth);
13+
14+
useEffect(() => {
15+
const code = searchParams.get('code');
16+
17+
if (!code) {
18+
router.push('/signin?error=no_token');
19+
return;
20+
}
21+
22+
handleOAuthCallback('github', code, setAuth)
23+
.then((isSuccess) => {
24+
if (!isSuccess) {
25+
router.push('/signin?error=auth_failed');
26+
return;
27+
}
28+
router.push('/');
29+
})
30+
.catch(() => {
31+
router.push('/signin?error=auth_failed');
32+
});
33+
}, [router, searchParams, setAuth]);
34+
35+
return (
36+
<div className="text-center text-white">
37+
<LoadingSpinner />
38+
<h2 className="mt-4 text-xl font-semibold">
39+
Processing authentication...
40+
</h2>
41+
<p className="text-gray-400">
42+
Please wait while we complete your sign-in.
43+
</p>
44+
</div>
45+
);
46+
}
47+
48+
// Main component wrapped with Suspense
49+
export default function OAuthCallback() {
50+
return (
51+
<div className="flex min-h-screen items-center justify-center bg-gray-900">
52+
<Suspense
53+
fallback={
54+
<div className="text-center text-white">
55+
<LoadingSpinner />
56+
<p>Loading...</p>
57+
</div>
58+
}
59+
>
60+
<CallbackHandler />
61+
</Suspense>
62+
</div>
63+
);
64+
}

peerprep-fe/src/app/signin/page.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
11
'use client';
2-
import { useState } from 'react';
2+
import { useEffect, useState } from 'react';
33
import { Button } from '@/components/ui/button';
44
import { Checkbox } from '@/components/ui/checkbox';
55
import { Input } from '@/components/ui/input';
66
import Link from 'next/link';
77
import { GithubIcon } from 'lucide-react';
8-
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
8+
import { Alert, AlertDescription } from '@/components/ui/alert';
99
import { axiosClient } from '@/network/axiosClient';
1010
import { login } from '@/lib/auth';
1111
import { useRouter } from 'next/navigation';
1212
import { useAuthStore } from '@/state/useAuthStore';
13+
import { initiateOAuth } from '@/lib/oauth';
1314

14-
export default function LoginForm() {
15+
// Add Props type for the page
16+
type Props = {
17+
searchParams: { [key: string]: string | string[] | undefined };
18+
};
19+
20+
export default function LoginForm({ searchParams }: Props) {
1521
const [email, setEmail] = useState('');
1622
const [password, setPassword] = useState('');
1723
const [error, setError] = useState('');
1824
const router = useRouter();
1925
const setAuth = useAuthStore((state) => state.setAuth);
2026

27+
useEffect(() => {
28+
const error = searchParams?.error;
29+
if (error) {
30+
setError('email or username already exists');
31+
}
32+
}, [searchParams]);
33+
2134
// handle login here
2235
const handleLogin = async (e: React.FormEvent) => {
2336
e.preventDefault();
@@ -40,12 +53,15 @@ export default function LoginForm() {
4053
setError(data.error || 'Please provide correct email and password');
4154
};
4255

56+
const handleGithubLogin = () => {
57+
initiateOAuth('github');
58+
};
59+
4360
return (
4461
<div className="flex min-h-screen items-center justify-center bg-gray-900">
4562
<div className="w-full max-w-md space-y-6 rounded-lg bg-gray-800 p-8 shadow-xl">
4663
{error && (
4764
<Alert variant="destructive" className="mb-4">
48-
<AlertTitle>Error</AlertTitle>
4965
<AlertDescription>{error}</AlertDescription>
5066
</Alert>
5167
)}
@@ -100,32 +116,21 @@ export default function LoginForm() {
100116
</span>
101117
</div>
102118
</div>
103-
<div className="grid grid-cols-2 gap-3">
119+
<div className="flex w-full justify-center gap-3">
104120
<Button
105121
variant="outline"
106122
className="rounded-md border border-gray-600 bg-gray-700 py-2 text-sm font-medium text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800"
123+
onClick={handleGithubLogin}
107124
>
108125
<GithubIcon className="mr-2 h-5 w-5" />
109126
GitHub
110127
</Button>
111-
<Button
112-
variant="outline"
113-
className="rounded-md border border-gray-600 bg-gray-700 py-2 text-sm font-medium text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800"
114-
>
115-
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
116-
<path
117-
fill="currentColor"
118-
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z"
119-
/>
120-
</svg>
121-
Google
122-
</Button>
123128
</div>
124129
<div className="flex justify-center text-center text-sm text-gray-400">
125130
Do not have an account?
126131
<span className="mx-1" />
127132
<Link href="/signup" className="text-blue-500 hover:underline">
128-
Sign up
133+
Sign up with email
129134
</Link>
130135
</div>
131136
</div>

peerprep-fe/src/app/signup/page.tsx

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useState } from 'react';
33
import { Button } from '@/components/ui/button';
44
import { Checkbox } from '@/components/ui/checkbox';
55
import { Input } from '@/components/ui/input';
6-
import { GithubIcon } from 'lucide-react';
76
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
87
import { axiosClient } from '@/network/axiosClient';
98
import { useRouter } from 'next/navigation';
@@ -131,37 +130,6 @@ export default function SignUpPage() {
131130
Sign up
132131
</Button>
133132
</form>
134-
<div className="relative">
135-
<div className="absolute inset-0 flex items-center">
136-
<div className="w-full border-t border-gray-600"></div>
137-
</div>
138-
<div className="relative flex justify-center text-sm">
139-
<span className="bg-gray-800 px-2 text-gray-400">
140-
Or continue with
141-
</span>
142-
</div>
143-
</div>
144-
<div className="grid grid-cols-2 gap-3">
145-
<Button
146-
variant="outline"
147-
className="rounded-md border border-gray-600 bg-gray-700 py-2 text-sm font-medium text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800"
148-
>
149-
<GithubIcon className="mr-2 h-5 w-5" />
150-
GitHub
151-
</Button>
152-
<Button
153-
variant="outline"
154-
className="rounded-md border border-gray-600 bg-gray-700 py-2 text-sm font-medium text-white hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-800"
155-
>
156-
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
157-
<path
158-
fill="currentColor"
159-
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z"
160-
/>
161-
</svg>
162-
Google
163-
</Button>
164-
</div>
165133
</div>
166134
</div>
167135
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function LoadingSpinner() {
2+
return (
3+
<div className="flex items-center justify-center">
4+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"></div>
5+
</div>
6+
);
7+
}

peerprep-fe/src/lib/oauth.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { axiosClient } from '@/network/axiosClient';
2+
import { login } from '@/lib/auth';
3+
import { User } from '@/types/types';
4+
5+
export type OAuthProvider = 'github';
6+
7+
// Extend for other providers
8+
export const oAuthConfig = {
9+
github: {
10+
authorizeUrl: 'https://github.com/login/oauth/authorize',
11+
clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID!,
12+
scope: 'user:email',
13+
},
14+
};
15+
16+
export const initiateOAuth = (provider: OAuthProvider) => {
17+
const config = oAuthConfig[provider];
18+
const authUrl =
19+
`${config.authorizeUrl}?` +
20+
new URLSearchParams({
21+
client_id: config.clientId,
22+
scope: config.scope,
23+
}).toString();
24+
window.location.href = authUrl;
25+
};
26+
27+
export const handleOAuthCallback = async (
28+
provider: OAuthProvider,
29+
code: string,
30+
setAuth: (isAuth: boolean, token: string | null, user: User | null) => void,
31+
): Promise<boolean> => {
32+
try {
33+
const res = await login(code);
34+
if (!res) return false;
35+
36+
const { data } = await axiosClient.get(
37+
`auth/${provider}/callback?code=${code}`,
38+
);
39+
setAuth(true, data.data.token, data.data.user);
40+
return true;
41+
} catch {
42+
return false;
43+
}
44+
};

peerprep-fe/src/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ export const config = {
5454
* - favicon.ico (favicon file)
5555
*/
5656

57-
'/((?!signin|_next/static|_next/image|$|signup|.*\\.png$).*)',
57+
'/((?!signin|_next/static|_next/image|$|signup|oauth*|.*\\.png$).*)',
5858
],
5959
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import jwt from 'jsonwebtoken';
2+
import { findUserByUsernameOrEmail, createUser } from '../model/repository.js';
3+
4+
export async function handleGithubCallback(req, res) {
5+
const { code } = req.query;
6+
7+
try {
8+
// Exchange code for access token for user data
9+
const tokenResponse = await fetch(
10+
'https://github.com/login/oauth/access_token',
11+
{
12+
method: 'POST',
13+
headers: {
14+
'Content-Type': 'application/json',
15+
Accept: 'application/json',
16+
},
17+
body: JSON.stringify({
18+
client_id: process.env.GITHUB_CLIENT_ID,
19+
client_secret: process.env.GITHUB_CLIENT_SECRET,
20+
code,
21+
}),
22+
},
23+
);
24+
25+
const tokenResponseData = await tokenResponse.json();
26+
27+
const { access_token } = tokenResponseData;
28+
29+
// Get user data and emails from GitHub
30+
const [githubUser, userEmailData] = await Promise.all([
31+
fetch('https://api.github.com/user', {
32+
headers: {
33+
Authorization: `Bearer ${access_token}`,
34+
},
35+
}).then((res) => res.json()),
36+
// necessary to get user's private email, public email is not always available
37+
fetch('https://api.github.com/user/emails', {
38+
headers: {
39+
Authorization: `Bearer ${access_token}`,
40+
},
41+
}).then((res) => res.json()),
42+
]);
43+
44+
if (!githubUser.email && !userEmailData.length) {
45+
res.status(400).json({ error: 'No email found' });
46+
return;
47+
}
48+
49+
const userEmail =
50+
githubUser.email || userEmailData.find((email) => email.primary).email;
51+
52+
if (!githubUser.login || !userEmail) {
53+
res.status(400).json({ error: 'Invalid user data' });
54+
return;
55+
}
56+
57+
// Find or create user
58+
let user = await findUserByUsernameOrEmail(githubUser.login, userEmail);
59+
if (!user) {
60+
try {
61+
user = await createUser(
62+
githubUser.login,
63+
userEmail,
64+
Math.random().toString(36), // temporary password
65+
);
66+
} catch (error) {
67+
console.error('Error creating user:', error);
68+
res.status(500).json({
69+
error: 'Failed to create user due to duplicate email or username',
70+
});
71+
return;
72+
}
73+
}
74+
75+
// Generate JWT token
76+
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
77+
expiresIn: '5d',
78+
});
79+
80+
// Redirect to frontend with token
81+
res.status(200).json({ data: { token: token, user: user } });
82+
} catch (error) {
83+
console.error('GitHub OAuth error:', error);
84+
res.status(500).json({ error: 'OAuth failed' });
85+
}
86+
}
87+
88+
// Similar implementation for Google OAuth

user-service/routes/auth-routes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
handleLogin,
44
handleVerifyToken,
55
} from '../controller/auth-controller.js';
6+
import { handleGithubCallback } from '../controller/oauth-controller.js';
67
import { verifyAccessToken } from '../middleware/basic-access-control.js';
78

89
const router = express.Router();
@@ -11,4 +12,6 @@ router.post('/login', handleLogin);
1112

1213
router.get('/verify-token', verifyAccessToken, handleVerifyToken);
1314

15+
router.get('/github/callback', handleGithubCallback);
16+
1417
export default router;

0 commit comments

Comments
 (0)