From 70818763a7fa3be24d613265b0fe0fc76568a6f8 Mon Sep 17 00:00:00 2001 From: Radek <104318242+radekm2000@users.noreply.github.com> Date: Sat, 4 May 2024 12:45:02 +0200 Subject: [PATCH] add signing in with discord --- .../src/components/DiscordAvatar.tsx | 17 ++++ .../ecommerce/src/components/DiscordIcon.tsx | 9 ++ .../src/components/DiscordSignInButton.tsx | 30 +++++++ .../ClearNotificationsButton.tsx | 28 +++++++ .../NavbarNotifications/NotificationCard.tsx | 39 +++++++++ .../NavbarNotifications/NotificationsList.tsx | 41 +++++++++ .../src/components/PopoverPopupState.tsx | 79 ++--------------- .../ecommerce/src/components/ProfileInfo.tsx | 14 +++- .../ecommerce/src/components/pages/Inbox.tsx | 6 -- .../ecommerce/src/components/pages/Login.tsx | 4 +- client/ecommerce/src/types/types.ts | 4 +- server/ecommerce/package-lock.json | 58 +++++++------ server/ecommerce/package.json | 5 +- server/ecommerce/src/auth/auth.controller.ts | 23 +++++ server/ecommerce/src/auth/auth.module.ts | 3 +- server/ecommerce/src/auth/auth.service.ts | 15 +++- .../ecommerce/src/auth/utils/DiscordGuard.ts | 5 ++ .../src/auth/utils/DiscordStrategy.ts | 84 +++++++++++++++++++ server/ecommerce/src/users/users.service.ts | 19 +++++ .../ecommerce/src/utils/dtos/discord.dto.ts | 14 ++++ .../src/utils/entities/user.entity.ts | 8 +- 21 files changed, 391 insertions(+), 114 deletions(-) create mode 100644 client/ecommerce/src/components/DiscordAvatar.tsx create mode 100644 client/ecommerce/src/components/DiscordIcon.tsx create mode 100644 client/ecommerce/src/components/DiscordSignInButton.tsx create mode 100644 client/ecommerce/src/components/NavbarNotifications/ClearNotificationsButton.tsx create mode 100644 client/ecommerce/src/components/NavbarNotifications/NotificationCard.tsx create mode 100644 client/ecommerce/src/components/NavbarNotifications/NotificationsList.tsx create mode 100644 server/ecommerce/src/auth/utils/DiscordGuard.ts create mode 100644 server/ecommerce/src/auth/utils/DiscordStrategy.ts create mode 100644 server/ecommerce/src/utils/dtos/discord.dto.ts diff --git a/client/ecommerce/src/components/DiscordAvatar.tsx b/client/ecommerce/src/components/DiscordAvatar.tsx new file mode 100644 index 0000000..e260e93 --- /dev/null +++ b/client/ecommerce/src/components/DiscordAvatar.tsx @@ -0,0 +1,17 @@ +import { Avatar } from "@mui/material"; + +type Props = { + userId: string | undefined; + avatar: string; +}; + +export const DiscordAvatar = ({ userId, avatar }: Props) => { + const avatarUrl = `https://cdn.discordapp.com/avatars/${userId}/${avatar}.png`; + console.log(avatarUrl); + return ( + + ); +}; diff --git a/client/ecommerce/src/components/DiscordIcon.tsx b/client/ecommerce/src/components/DiscordIcon.tsx new file mode 100644 index 0000000..f9c8f9a --- /dev/null +++ b/client/ecommerce/src/components/DiscordIcon.tsx @@ -0,0 +1,9 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; + +export const DiscordIcon = (props: SvgIconProps) => { + return ( + + + + ); +}; diff --git a/client/ecommerce/src/components/DiscordSignInButton.tsx b/client/ecommerce/src/components/DiscordSignInButton.tsx new file mode 100644 index 0000000..b7c66fa --- /dev/null +++ b/client/ecommerce/src/components/DiscordSignInButton.tsx @@ -0,0 +1,30 @@ +import { Box, BoxProps, Link, Typography } from "@mui/material"; +import { DiscordIcon } from "./DiscordIcon"; + +export const DiscordSignInButton = (props: BoxProps) => { + return ( + + + + + SIGN IN + + + + ); +}; diff --git a/client/ecommerce/src/components/NavbarNotifications/ClearNotificationsButton.tsx b/client/ecommerce/src/components/NavbarNotifications/ClearNotificationsButton.tsx new file mode 100644 index 0000000..e06dd87 --- /dev/null +++ b/client/ecommerce/src/components/NavbarNotifications/ClearNotificationsButton.tsx @@ -0,0 +1,28 @@ +import { Button } from '@mui/material'; + +type Props = { + onClick(): void +} + +export const ClearNotificationsButton = ({onClick}: Props) => { + return ( + + ) +} diff --git a/client/ecommerce/src/components/NavbarNotifications/NotificationCard.tsx b/client/ecommerce/src/components/NavbarNotifications/NotificationCard.tsx new file mode 100644 index 0000000..3445b45 --- /dev/null +++ b/client/ecommerce/src/components/NavbarNotifications/NotificationCard.tsx @@ -0,0 +1,39 @@ +import { Box, CardContent, CardMedia, Typography } from "@mui/material"; +import { ProductNotification } from "../../types/types"; +import { Link } from "wouter"; + +type Props = { + notification: ProductNotification; +}; + +export const NotificationCard = ({ notification }: Props) => { + return ( + + + + + + {notification.message} + + + ); +}; diff --git a/client/ecommerce/src/components/NavbarNotifications/NotificationsList.tsx b/client/ecommerce/src/components/NavbarNotifications/NotificationsList.tsx new file mode 100644 index 0000000..8070024 --- /dev/null +++ b/client/ecommerce/src/components/NavbarNotifications/NotificationsList.tsx @@ -0,0 +1,41 @@ +import { Box, CardContent, Typography } from "@mui/material"; +import { ProductNotification } from "../../types/types"; +import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined"; +import { NotificationCard } from "./NotificationCard"; + +type Props = { + productNotifications: ProductNotification[]; +}; + +export const NotificationsList = ({ productNotifications }: Props) => { + if (productNotifications.length === 0) { + return ( + + + + No notifications yet + + This is where you'll find your notifications + + + + ); + } + + return ( + <> + {productNotifications.map((notification, index) => ( + + ))} + + ); +}; diff --git a/client/ecommerce/src/components/PopoverPopupState.tsx b/client/ecommerce/src/components/PopoverPopupState.tsx index 6e754a8..97b7c00 100644 --- a/client/ecommerce/src/components/PopoverPopupState.tsx +++ b/client/ecommerce/src/components/PopoverPopupState.tsx @@ -2,19 +2,14 @@ import PopupState, { bindPopover, bindTrigger } from "material-ui-popup-state"; import { ProductNotification } from "../types/types"; import { Badge, - Box, - Button, Card, - CardContent, - CardMedia, IconButton, Popover, - Typography, } from "@mui/material"; -import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined"; import NotificationsIcon from "@mui/icons-material/Notifications"; -import { Link } from "wouter"; import { useDeleteProductNotificationsMutation } from "../hooks/useDeleteProductNotificationsMutation"; +import { NotificationsList } from "./NavbarNotifications/NotificationsList"; +import { ClearNotificationsButton } from "./NavbarNotifications/ClearNotificationsButton"; export default function PopoverPopupState({ productNotifications, @@ -64,77 +59,15 @@ export default function PopoverPopupState({ - {productNotifications.length >= 1 ? ( - productNotifications?.map((notification, index) => ( - - - - - - {notification.message} - - - )) - ) : ( - - - - No notifications yet - - This is where you'll find your notifications - - - - )} + + {productNotifications.length >= 1 && ( - + /> )} diff --git a/client/ecommerce/src/components/ProfileInfo.tsx b/client/ecommerce/src/components/ProfileInfo.tsx index a55b1c5..b51482c 100644 --- a/client/ecommerce/src/components/ProfileInfo.tsx +++ b/client/ecommerce/src/components/ProfileInfo.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { useFollowUser } from "../utils/followUser"; import { Link, useLocation } from "wouter"; import { calculateMedian } from "../utils/calculateMedian"; +import { DiscordAvatar } from "./DiscordAvatar"; export const ProfileInfo = ({ user, }: { @@ -25,6 +26,9 @@ export const ProfileInfo = ({ const ratings = user.reviews.map((review) => review.rating); const calculatedRatingValue = calculateMedian(ratings); + const avatarUrl = `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png`; + console.log(avatarUrl); + const isFollowed = (member: UserWithFollows) => { return (member.followings ?? []).some((following) => { return following.follower.id === meUser.id; @@ -106,7 +110,12 @@ export const ProfileInfo = ({ ) : ( <> - {user.avatar ? ( + {user.role === "discordUser" && user.avatar && ( + + )} + + + {user.role !== "discordUser" && user.avatar && ( Avatar - ) : ( + )} + {!(user.role === "discordUser" && user.avatar) && ( { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -165,10 +166,11 @@ export const Login = () => { or - + + diff --git a/client/ecommerce/src/types/types.ts b/client/ecommerce/src/types/types.ts index 8bf80e9..e179060 100644 --- a/client/ecommerce/src/types/types.ts +++ b/client/ecommerce/src/types/types.ts @@ -29,7 +29,8 @@ export type User = { username: string; googleId?: string; email: string; - role: "admin" | "user"; + discordId?: string; + role: "admin" | "user" | "discordUser"; avatar?: string; products?: Product[]; }; @@ -122,7 +123,6 @@ export type Brand = | "Calvin Klein" | ""; - export type Message = { id: number; content: string; diff --git a/server/ecommerce/package-lock.json b/server/ecommerce/package-lock.json index 52b95b8..c7a1d40 100644 --- a/server/ecommerce/package-lock.json +++ b/server/ecommerce/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.1", "@types/jsonwebtoken": "^9.0.5", + "@types/passport-oauth2": "^1.4.15", "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "dotenv": "^16.3.1", @@ -25,7 +26,9 @@ "madge": "^6.1.0", "nodemailer": "^6.9.9", "passport": "^0.7.0", + "passport-discord": "^0.1.4", "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -45,6 +48,7 @@ "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.14", + "@types/passport-discord": "^0.1.13", "@types/passport-google-oauth20": "^2.0.14", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -3492,7 +3496,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -3502,7 +3505,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -3552,7 +3554,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -3564,7 +3565,6 @@ "version": "4.17.41", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -3584,8 +3584,7 @@ "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3649,8 +3648,7 @@ "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/multer": { "version": "1.4.11", @@ -3682,7 +3680,6 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.4.tgz", "integrity": "sha512-qk9orhti499fq5XxKCCEbd0OzdPZuancneyse3KtR+vgMiHRbh+mn8M4G6t64ob/Fg+GZGpa565MF/2dKWY32A==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -3691,11 +3688,21 @@ "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", - "dev": true, "dependencies": { "@types/express": "*" } }, + "node_modules/@types/passport-discord": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/passport-discord/-/passport-discord-0.1.13.tgz", + "integrity": "sha512-A9fGTf69C19xQr4BQUHvK+/DpGuSLF/v/MEMdw4Z46sQd0dqeP+4ysBZqF5rXxaCWVcJKBxXUpV8MpW5jwWVIQ==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-google-oauth20": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.14.tgz", @@ -3711,7 +3718,6 @@ "version": "1.4.15", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.15.tgz", "integrity": "sha512-9cUTP/HStNSZmhxXGuRrBJfEWzIEJRub2eyJu3CvkA+8HAMc9W3aKdFhVq+Qz1hi42qn+GvSAnz3zwacDSYWpw==", - "dev": true, "dependencies": { "@types/express": "*", "@types/oauth": "*", @@ -3721,14 +3727,12 @@ "node_modules/@types/qs": { "version": "6.9.11", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", - "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==", - "dev": true + "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/semver": { "version": "7.5.6", @@ -3740,7 +3744,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -3750,7 +3753,6 @@ "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/mime": "*", @@ -9403,9 +9405,9 @@ } }, "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz", + "integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==" }, "node_modules/object-assign": { "version": "4.1.1", @@ -9663,6 +9665,14 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-discord": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", + "integrity": "sha512-VJWPYqSOmh7SaCLw/C+k1ZqCzJnn2frrmQRx1YrcPJ3MQ+Oa31XclbbmqFICSvl8xv3Fqd6YWQ4H4p1MpIN9rA==", + "dependencies": { + "passport-oauth2": "^1.5.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", @@ -9675,12 +9685,12 @@ } }, "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", "dependencies": { "base64url": "3.x.x", - "oauth": "0.9.x", + "oauth": "0.10.x", "passport-strategy": "1.x.x", "uid2": "0.0.x", "utils-merge": "1.x.x" diff --git a/server/ecommerce/package.json b/server/ecommerce/package.json index 56b2372..184369e 100644 --- a/server/ecommerce/package.json +++ b/server/ecommerce/package.json @@ -23,7 +23,6 @@ "migration:run": "npm run typeorm -- migration:run", "migration:revert": "npm run typeorm -- migration:revert", "typeorm:drop": "npm run typeorm schema:drop", - "typeorm:reset": "npm run typeorm:drop && npm run typeorm:sync" }, "dependencies": { @@ -36,6 +35,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.1", "@types/jsonwebtoken": "^9.0.5", + "@types/passport-oauth2": "^1.4.15", "bcrypt": "^5.1.1", "cookie-parser": "^1.4.6", "dotenv": "^16.3.1", @@ -43,7 +43,9 @@ "madge": "^6.1.0", "nodemailer": "^6.9.9", "passport": "^0.7.0", + "passport-discord": "^0.1.4", "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.8.0", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", @@ -63,6 +65,7 @@ "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/nodemailer": "^6.4.14", + "@types/passport-discord": "^0.1.13", "@types/passport-google-oauth20": "^2.0.14", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", diff --git a/server/ecommerce/src/auth/auth.controller.ts b/server/ecommerce/src/auth/auth.controller.ts index 3aa2a5e..adb6193 100644 --- a/server/ecommerce/src/auth/auth.controller.ts +++ b/server/ecommerce/src/auth/auth.controller.ts @@ -21,6 +21,8 @@ import { GoogleAuthGuard } from './utils/GoogleGuard'; import { User } from 'src/utils/entities/user.entity'; import { AuthGuard } from './auth.guard'; import { AuthUser } from 'src/decorators/user.decorator'; +import { DiscordAuthGuard } from './utils/DiscordGuard'; +import { DiscordProfile } from 'src/utils/dtos/discord.dto'; @Controller('auth') export class AuthController { @@ -46,6 +48,12 @@ export class AuthController { return await this.authService.handleRefreshToken(request); } + @UseGuards(DiscordAuthGuard) + @Get('discord/login') + handleDiscordLogin() { + //empty method to initialize oauth flow + } + @UseGuards(GoogleAuthGuard) @Get('google/login') handleGoogleLogin() { @@ -70,4 +78,19 @@ export class AuthController { }); response.redirect('https://exquisite-pasca-338883.netlify.app'); } + + @UseGuards(DiscordAuthGuard) + @Get('discord/redirect') + async disordAuthRedirect(@Req() request: Request, @Res() response: Response) { + const user = request.user as DiscordProfile; + console.log(user); + const refreshToken = await this.authService.generateRefreshTokenFor( + Number(user.id), + ); + response.cookie('refreshToken', refreshToken, { + httpOnly: true, + maxAge: 60 * 60 * 1000, + }); + response.redirect('http://localhost:5173'); + } } diff --git a/server/ecommerce/src/auth/auth.module.ts b/server/ecommerce/src/auth/auth.module.ts index 4bcdc31..557d1df 100644 --- a/server/ecommerce/src/auth/auth.module.ts +++ b/server/ecommerce/src/auth/auth.module.ts @@ -11,6 +11,7 @@ import { GoogleStrategy } from './utils/GoogleStrategy'; import { Follow } from 'src/utils/entities/followers.entity'; import { Profile } from 'src/utils/entities/profile.entity'; import { Avatar } from 'src/utils/entities/avatar.entity'; +import { DiscordStrategy } from './utils/DiscordStrategy'; @Module({ imports: [ @@ -21,7 +22,7 @@ import { Avatar } from 'src/utils/entities/avatar.entity'; secret: jwtConstants.secret, }), ], - providers: [AuthService, UsersService, GoogleStrategy], + providers: [AuthService, UsersService, GoogleStrategy, DiscordStrategy], controllers: [AuthController], }) export class AuthModule {} diff --git a/server/ecommerce/src/auth/auth.service.ts b/server/ecommerce/src/auth/auth.service.ts index 552d9f4..663e2e3 100644 --- a/server/ecommerce/src/auth/auth.service.ts +++ b/server/ecommerce/src/auth/auth.service.ts @@ -9,6 +9,7 @@ import 'dotenv/config'; import { JwtService } from '@nestjs/jwt'; import { jwtConstants } from './constants'; import { Profile } from 'passport-google-oauth20'; +import { DiscordProfile } from 'src/utils/dtos/discord.dto'; @Injectable() export class AuthService { constructor( @@ -45,7 +46,6 @@ export class AuthService { } async handleRefreshToken(req: Request) { - console.log(req.cookies); const { refreshToken } = req.cookies; if (!refreshToken) { @@ -74,6 +74,19 @@ export class AuthService { return user; } + async createOrGetDiscordUser( + profile: DiscordProfile, + accessToken: string, + refreshToken: string, + ) { + const user = await this.usersService.findDiscordUserOrCreate( + profile.username, + profile.avatar, + profile.id, + ); + return user; + } + async generateRefreshTokenFor(userId: number) { const refreshToken = await this.jwtService.signAsync( { sub: userId }, diff --git a/server/ecommerce/src/auth/utils/DiscordGuard.ts b/server/ecommerce/src/auth/utils/DiscordGuard.ts new file mode 100644 index 0000000..3e6b0f1 --- /dev/null +++ b/server/ecommerce/src/auth/utils/DiscordGuard.ts @@ -0,0 +1,5 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class DiscordAuthGuard extends AuthGuard('discord') {} diff --git a/server/ecommerce/src/auth/utils/DiscordStrategy.ts b/server/ecommerce/src/auth/utils/DiscordStrategy.ts new file mode 100644 index 0000000..c04c0e1 --- /dev/null +++ b/server/ecommerce/src/auth/utils/DiscordStrategy.ts @@ -0,0 +1,84 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Profile, Strategy } from 'passport-discord'; + +import OAuth2Strategy, { + InternalOAuthError, + StrategyOptions, +} from 'passport-oauth2'; +import 'dotenv/config'; +import { AuthService } from '../auth.service'; +import { + DiscordProfile, + DiscordProfileSchema, +} from 'src/utils/dtos/discord.dto'; + +type VerifyCallback = (err?: Error | null, user?: Express.User) => void; +type UserProfileCallback = (err?: Error | null, profile?: any) => void; + +const AUTHORIZATION_URL = 'https://discord.com/oauth2/authorize'; +const TOKEN_URL = 'https://discord.com/api/oauth2/token'; +const PROFILE_URL = 'https://discord.com/api/users/@me'; +@Injectable() +export class DiscordStrategy extends PassportStrategy(Strategy, 'discord') { + private readonly logger: Logger; + + constructor(private readonly authService: AuthService) { + super({ + authorizationURL: AUTHORIZATION_URL, + tokenURL: TOKEN_URL, + clientID: process.env.DISCORD_CLIENT_ID ?? '', + clientSecret: process.env.DISCORD_CLIENT_SECRET ?? '', + callbackURL: process.env.DISCORD_REDIRECT_URL ?? '', + scope: ['identify', 'guilds'], + } as StrategyOptions); + this.logger = new Logger(DiscordStrategy.name); + } + + public validate = async ( + accessToken: string, + refreshToken: string, + profile: DiscordProfile, + done: VerifyCallback, + ) => { + try { + console.log(profile); + const user = await this.authService.createOrGetDiscordUser( + profile, + accessToken, + refreshToken, + ); + return done(null, user as unknown as Express.User); + } catch (error) { + return done(error); + } + }; + + public userProfile = async ( + accessToken: string, + done: UserProfileCallback, + ) => { + this._oauth2.get(PROFILE_URL, accessToken, (err, body) => { + if (err || !body) { + this.logger.error({ err }, this.profileErrorMessage); + return done(new InternalOAuthError(this.profileErrorMessage, err)); + } + + try { + const parsedBody = JSON.parse((body ?? '').toString()); + return done(null, DiscordProfileSchema.parse(parsedBody)); + } catch (e) { + this.logger.error( + { err: e, rawProfile: body }, + this.profileErrorMessage, + ); + return done(new Error(this.profileErrorMessage)); + } + }); + }; + + private get profileErrorMessage() { + return 'Failed to parse user discord profile'; + } + F; +} diff --git a/server/ecommerce/src/users/users.service.ts b/server/ecommerce/src/users/users.service.ts index ba87fe5..c0cb049 100644 --- a/server/ecommerce/src/users/users.service.ts +++ b/server/ecommerce/src/users/users.service.ts @@ -375,4 +375,23 @@ export class UsersService { } return filteredUsersWithoutMe; } + + async findDiscordUserOrCreate(username: string, avatar: string, id: string) { + const user = await this.usersRepository.findOne({ + where: { username: username }, + }); + + if (!user) { + const newUser = this.usersRepository.create({ + avatar: avatar, + username: username, + discordId: id, + role: 'discordUser', + }); + await this.usersRepository.save(newUser); + return newUser; + } + + return user; + } } diff --git a/server/ecommerce/src/utils/dtos/discord.dto.ts b/server/ecommerce/src/utils/dtos/discord.dto.ts new file mode 100644 index 0000000..d78969b --- /dev/null +++ b/server/ecommerce/src/utils/dtos/discord.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const DiscordProfileSchema = z.object({ + id: z.string(), + username: z.string(), + avatar: z.string().nullish(), +}); + +export type DiscordProfile = z.infer; + +export type DiscordTokens = { + accessToken: string; + refreshToken: string; +}; diff --git a/server/ecommerce/src/utils/entities/user.entity.ts b/server/ecommerce/src/utils/entities/user.entity.ts index f9c1369..2ab0131 100644 --- a/server/ecommerce/src/utils/entities/user.entity.ts +++ b/server/ecommerce/src/utils/entities/user.entity.ts @@ -25,18 +25,21 @@ export class User { @Column({ nullable: true }) googleId: string; - @Column() + @Column({ nullable: true }) email: string; @Column({ nullable: true }) password: string; + @Column({ nullable: true }) + discordId: string; + @OneToOne(() => Profile, (profile) => profile.user, { cascade: true }) @JoinColumn() profile: Profile; @Column({ default: 'user' }) - role: 'admin' | 'user'; + role: 'admin' | 'user' | 'discordUser'; @Column({ nullable: true }) avatar: string; @@ -60,7 +63,6 @@ export class User { @OneToMany(() => Review, (review) => review.reviewRecipient) reviews: Review[]; - @BeforeInsert() async hashPassword() { if (this.password) {