diff --git a/.env.example b/.env.example index b86308b..dbdf6da 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ DISCORD_TOKEN='TOKEN' DISCORD_TOKEN_DEV='DEV_TOKEN' +DISCORD_CLIENT_ID='CLIENT_ID' +DISCORD_CLIENT_SECRET='CLIENT_SECRET' +WEBSITE_URL='http://localhost:3000' +NEXT_PUBLIC_API_URL='http://localhost:18103' MYSQL_ADDRESS='YOUR_MYSQL_SERVER_ADDRESS' MYSQL_PORT='YOUR_MYSQL_SERVER_PORT' @@ -7,4 +11,5 @@ MYSQL_USER='YOUR_MYSQL_USER' MYSQL_PASSWORD='YOUR_MYSQL_PASSWORD' MYSQL_DATABASE='YOUR_DATABASE_NAME' +JWT_SECRET='YOUR_JWT_SECRET' AUTH="AUTH_KEY_FOR_API" \ No newline at end of file diff --git a/api/package.json b/api/package.json index fb64225..6276045 100644 --- a/api/package.json +++ b/api/package.json @@ -1,21 +1,23 @@ { - "name": "@chatr/api", - "type": "module", - "version": "0.1.0", - "scripts": { - "dev": "bun with-env bun --watch src/index.ts --dev", - "with-env": "dotenv -e ../.env --" - }, - "dependencies": { - "cors": "^2.8.5", - "cron": "^3.1.7", - "express": "^4.19.2", - "mysql2": "^3.10.3" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "dotenv-cli": "^7.4.2" - } -} + "name": "@chatr/api", + "type": "module", + "version": "0.1.0", + "scripts": { + "dev": "bun with-env bun --watch src/index.ts --dev", + "with-env": "dotenv -e ../.env --" + }, + "dependencies": { + "cors": "^2.8.5", + "cron": "^3.1.7", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.10.3" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "dotenv-cli": "^7.4.2" + } +} \ No newline at end of file diff --git a/api/src/db/init.ts b/api/src/db/init.ts index dfaf7db..b75e132 100644 --- a/api/src/db/init.ts +++ b/api/src/db/init.ts @@ -44,6 +44,17 @@ export async function initTables() { xp INT NOT NULL ) `; + const createOauthUsersTable = ` + CREATE TABLE IF NOT EXISTS oauth_users ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + avatar VARCHAR(255) NOT NULL, + access_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL + ) + `; pool.query(createGuildsTable, (err) => { if (err) { @@ -76,4 +87,12 @@ export async function initTables() { console.log("Tracking table created"); } }); + + pool.query(createOauthUsersTable, (err) => { + if (err) { + console.error("Error creating OAuth users table:", err); + } else { + console.log("OAuth users table created"); + } + }); } diff --git a/api/src/db/queries/guilds.ts b/api/src/db/queries/guilds.ts index f9a1663..e26bf8f 100644 --- a/api/src/db/queries/guilds.ts +++ b/api/src/db/queries/guilds.ts @@ -5,7 +5,7 @@ import { pool } from ".."; export interface Guild { id: string; name: string; - icon: string; + icon?: string; members: number; cooldown: number; updates_enabled: 0 | 1; diff --git a/api/src/db/queries/oauth-users.ts b/api/src/db/queries/oauth-users.ts new file mode 100644 index 0000000..a0eba00 --- /dev/null +++ b/api/src/db/queries/oauth-users.ts @@ -0,0 +1,76 @@ +import type { QueryError } from "mysql2"; + +import { pool } from ".."; + +export interface OAuthUser { + id: string; + name: string; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + expires_at: Date; +} + +export type OAuthUserWithoutTokens = Without< + OAuthUser, + "access_token" | "refresh_token" | "expires_at" +>; + +type Without = { + [L in keyof T]: L extends K ? undefined : T[L]; +}; + +export function getOAuthUser( + id: string +): Promise<[QueryError, null] | [null, OAuthUser]> { + return new Promise((resolve, reject) => { + pool.query( + "SELECT * FROM oauth_users WHERE id = ?", + [id], + (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as OAuthUser[])[0]]); + } + } + ); + }); +} + +export function updateOAuthUser( + oauthUser: Partial +): Promise<[QueryError, false] | [null, true]> { + return new Promise((resolve, reject) => { + pool.query( + ` + INSERT INTO oauth_users (id, name, username, avatar, access_token, refresh_token, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + username = VALUES(username), + avatar = VALUES(avatar), + access_token = VALUES(access_token), + refresh_token = VALUES(refresh_token), + expires_at = VALUES(expires_at) + `, + [ + oauthUser.id, + oauthUser.name, + oauthUser.username, + oauthUser.avatar, + oauthUser.access_token, + oauthUser.refresh_token, + oauthUser.expires_at, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + } + ); + }); +} diff --git a/api/src/index.ts b/api/src/index.ts index c579d15..1923e22 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,9 +1,14 @@ +import type { RowDataPacket } from "mysql2"; + +import crypto from "node:crypto"; + import express, { type NextFunction, type Request, type Response, } from "express"; import cors from "cors"; +import jwt, { type JwtPayload } from "jsonwebtoken"; import { getBotInfo, @@ -26,12 +31,25 @@ import { getGuildTrackingData, getUsersTrackingData, } from "./db"; +import { + getOAuthUser, + updateOAuthUser, + type OAuthUser, +} from "./db/queries/oauth-users"; const app = express(); const PORT = 18103; -app.use(cors()); +// app.use(cors()); app.use(express.json()); +app.use((req, _res, next) => { + if (req.headers.cookie) { + const cookies = parseCookies(req.headers.cookie); + + req.cookies = cookies; + } + next(); +}); app.disable("x-powered-by"); @@ -210,7 +228,7 @@ app.get("/get/dbusage", (_req, res) => { .status(500) .json({ message: "Internal server error" }); } else { - const discordXpBot = results.find( + const discordXpBot = (results as RowDataPacket[]).find( (result) => result.name === process.env.MYSQL_DATABASE ); @@ -256,7 +274,7 @@ app.get("/get/tracking/:guild/:user", async (req, res) => { return res.status(200).json(data); }); -app.get("/get/:guild/:user", async (req, res) => { +app.get("/get/:guild/:user", cors(), async (req, res) => { const { guild, user } = req.params; const [err, result] = await getUser(user, guild); @@ -271,7 +289,7 @@ app.get("/get/:guild/:user", async (req, res) => { } }); -app.get("/get/:guild", async (req, res) => { +app.get("/get/:guild", cors(), async (req, res) => { const { guild } = req.params; const [guildErr, guildData] = await getGuild(guild); @@ -687,14 +705,324 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } }); -app.get("/invite", (_req, res) => - res - .status(308) - .redirect( - "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands" - ) +const API_URL = + process.env.NODE_ENV === "development" + ? `http://localhost:${PORT}` + : "https://api.chatr.fun"; +const WEBSITE_URL = + process.env.NODE_ENV === "development" + ? `http://localhost:56413` + : "https://chatr.fun"; +const REDIRECT_URI = `${API_URL}/auth/callback`; + +app.get("/auth/login", (_req, res) => { + const params = new URLSearchParams(); + const state = crypto.randomBytes(32).toString("hex"); + + params.append("client_id", process.env.DISCORD_CLIENT_ID!); + params.append("redirect_uri", REDIRECT_URI); + params.append("response_type", "code"); + params.append("scope", "identify guilds"); + params.append("state", state); + + res.appendHeader( + "Set-Cookie", + serializeCookie("state", state, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 10, + }) + ); + res.redirect(`https://discord.com/oauth2/authorize?${params.toString()}`); +}); + +app.get("/auth/callback", async (req, res) => { + const { code, state } = req.query; + const storedState = req.cookies.get("state"); + + if ( + !code || + typeof code !== "string" || + !state || + typeof state !== "string" || + !storedState + ) + return res.status(400).json({ message: "Illegal request" }); + + if (state !== storedState) + return res.status(400).json({ message: "Invalid state" }); + + const body = new URLSearchParams(); + + body.append("client_id", process.env.DISCORD_CLIENT_ID!); + body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); + body.append("grant_type", "authorization_code"); + body.append("code", code); + body.append("redirect_uri", REDIRECT_URI); + body.append("scope", "identify guilds"); + + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (tokenResponse.status !== 200) { + console.error("Error fetching token:", tokenResponse); + + return res.status(500).json({ message: "Internal server error" }); + } + + const tokenData = await tokenResponse.json(); + + const userResponse = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (userResponse.status !== 200) { + console.error("Error fetching user:", userResponse); + + return res.status(500).json({ message: "Internal server error" }); + } + + const userData = await userResponse.json(); + + const [err, success] = await updateOAuthUser({ + id: userData.id, + name: userData.display_name ?? userData.username, + username: userData.username, + avatar: `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.webp`, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: new Date( + new Date().getTime() + tokenData.expires_in * 1000 + ), + }); + + if (!success) { + console.error("Error updating OAuth user:", err); + + return res.status(500).json({ message: "Internal server error" }); + } + + const token = jwt.sign( + { + sub: userData.id, + }, + process.env.JWT_SECRET!, + { + expiresIn: "30d", + } + ); + + res.appendHeader( + "Set-Cookie", + serializeCookie("token", token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 400, + }) + ); + res.redirect(`${WEBSITE_URL}/dashboard`); +}); + +app.options( + "/user/me", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }) +); + +app.get( + "/user/me", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + res.json({ + ...user, + access_token: undefined, + refresh_token: undefined, + expires_at: undefined, + }); + } +); + +app.delete( + "/user/me", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + if (!(await getUserFromRequest(req))) { + return res.status(401).json({ message: "Unauthorized" }); + } + + res.clearCookie("token"); + + return res.sendStatus(200); + } +); + +app.options( + "/dashboard/update-guild", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }) ); +app.post( + "/dashboard/update-guild", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + if (!(await getUserFromRequest(req))) + return res.status(401).json({ message: "Unauthorized" }); + + const body = req.body; + const { guild } = req.body; + + if (!guild) return res.status(400).json({ message: "Illegal request" }); + + if (body.cooldown) { + await setCooldown(guild, body.cooldown); + } + + if (body.updates.enabled === true) { + await enableUpdates(guild); + } else if (body.updates.enabled === false) { + await disableUpdates(guild); + } + + if (body.updates.channel) { + await setUpdatesChannel(guild, body.updates.channel); + } + + return res.sendStatus(200); + } +); + +app.get("/user/me/guilds", async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + const botGuildsResponse = await fetch( + "https://discord.com/api/users/@me/guilds", + { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, + }, + } + ); + const botGuilds = await botGuildsResponse.json(); + + const [err, accessToken] = await getAccessToken(user); + + if (err) return res.status(500).json({ message: err }); + + const userGuildsResponse = await fetch( + "https://discord.com/api/users/@me/guilds", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const userGuilds = await userGuildsResponse.json(); + + const filteredGuilds = userGuilds.filter( + (guild: any) => guild.owner || (guild.permissions & 0x20) === 0x20 + ); + + res.json( + filteredGuilds + .map((guild: any) => ({ + ...guild, + icon: guild.icon + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp` + : null, + botIsInGuild: botGuilds.some( + (botGuild: any) => botGuild.id === guild.id + ), + })) + .sort((a: any, b: any) => { + if (a.botIsInGuild === b.botIsInGuild) { + return a.name.localeCompare(b.name); + } + + return Number(b.botIsInGuild) - Number(a.botIsInGuild); + }) + ); +}); + +// TODO: fetch from the bot itself using discord.js +// (would allow us to do permission filtering) +app.get("/dashboard/channels/:guild", authMiddleware, async (req, res) => { + const { guild } = req.params; + + const channelsResponse = await fetch( + `https://discord.com/api/v10/guilds/${guild}/channels`, + { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, + }, + } + ); + const channelsData = await channelsResponse.json(); + + if (channelsData.code === 50007) { + return res.status(404).json({ message: "Guild not found" }); + } + + const channels = channelsData + .filter((channel: any) => channel.type === 0) + .sort((a: any, b: any) => a.position - b.position); + + res.json(channels); +}); + +app.get("/invite", (req, res) => { + const guildId = req.query.guild_id; + + if (!guildId || typeof guildId !== "string") + res.status(308).redirect( + "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands" + ); + else { + const params = new URLSearchParams(); + + params.append("client_id", process.env.DISCORD_CLIENT_ID!); + params.append("permissions", "1099780115520"); + params.append("integration_type", "0"); + params.append("scope", "bot applications.commands identify guilds"); + params.append("guild_id", guildId); + params.append("response_type", "code"); + params.append("redirect_uri", REDIRECT_URI); + res.redirect( + `https://discord.com/oauth2/authorize?${params.toString()}` + ); + } +}); + app.get("/support", (_req, res) => res.status(308).redirect("https://discord.gg/fpJVTkVngm") ); @@ -707,6 +1035,138 @@ app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); +//#region Cookies +// Mostly taken from https://github.com/pilcrowonpaper/oslo/blob/main/src/cookie/index.ts +interface CookieAttributes { + secure?: boolean; + path?: string; + domain?: string; + sameSite?: "lax" | "strict" | "none"; + httpOnly?: boolean; + maxAge?: number; + expires?: Date; +} + +function parseCookies(header: string): Map { + const cookies = new Map(); + const items = header.split("; "); + + for (const item of items) { + const pair = item.split("="); + const rawKey = pair[0]; + const rawValue = pair[1] ?? ""; + + if (!rawKey) continue; + cookies.set(decodeURIComponent(rawKey), decodeURIComponent(rawValue)); + } + + return cookies; +} + +function serializeCookie( + name: string, + value: string, + attributes: CookieAttributes +): string { + const keyValueEntries: Array<[string, string] | [string]> = []; + + keyValueEntries.push([encodeURIComponent(name), encodeURIComponent(value)]); + if (attributes?.domain !== undefined) { + keyValueEntries.push(["Domain", attributes.domain]); + } + if (attributes?.expires !== undefined) { + keyValueEntries.push(["Expires", attributes.expires.toUTCString()]); + } + if (attributes?.httpOnly) { + keyValueEntries.push(["HttpOnly"]); + } + if (attributes?.maxAge !== undefined) { + keyValueEntries.push(["Max-Age", attributes.maxAge.toString()]); + } + if (attributes?.path !== undefined) { + keyValueEntries.push(["Path", attributes.path]); + } + if (attributes?.sameSite === "lax") { + keyValueEntries.push(["SameSite", "Lax"]); + } + if (attributes?.sameSite === "none") { + keyValueEntries.push(["SameSite", "None"]); + } + if (attributes?.sameSite === "strict") { + keyValueEntries.push(["SameSite", "Strict"]); + } + if (attributes?.secure) { + keyValueEntries.push(["Secure"]); + } + + return keyValueEntries.map((pair) => pair.join("=")).join("; "); +} + +async function getUserFromRequest(req: Request): Promise { + const token = req.cookies?.get("token"); + + if (!token) return null; + + let decoded: JwtPayload; + + try { + decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; + } catch (err) { + // most likely an invalid or expired token + return null; + } + + const userId = decoded.sub; + + if (!userId) return null; + + const [err, user] = await getOAuthUser(userId); + + if (err) return null; + + return user; +} + +async function getAccessToken( + user: OAuthUser +): Promise<[string, null] | [null, string]> { + let accessToken = user.access_token; + + if (new Date().getTime() > user.expires_at.getTime()) { + const body = new URLSearchParams(); + + body.append("client_id", process.env.DISCORD_CLIENT_ID!); + body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); + body.append("grant_type", "refresh_token"); + body.append("refresh_token", user.refresh_token); + body.append("scope", "identify guilds"); + + const tokenResponse = await fetch( + "https://discord.com/api/oauth2/token", + { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + if (tokenResponse.status !== 200) { + console.error("Error fetching token:", tokenResponse); + + return ["Internal server error", null]; + } + + const tokenData = await tokenResponse.json(); + + accessToken = tokenData.access_token; + } + + return [null, accessToken]; +} +//#endregion + // TODO: actually implement this in a real way //#region Admin: Roles async function adminRolesGet(guild: string) { diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 4542ee0..e595e3c Binary files a/bun.lockb and b/bun.lockb differ diff --git a/web/components/icons.tsx b/web/components/icons.tsx index d96b745..5ef9cb7 100644 --- a/web/components/icons.tsx +++ b/web/components/icons.tsx @@ -164,6 +164,23 @@ export const SearchIcon = (props: IconSvgProps) => ( ); +export const LoaderIcon = (props: IconSvgProps) => ( + + + +); + export const NextUILogo: React.FC = (props) => { const { width, height = 40 } = props; diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index f9ee30b..15de7bf 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -7,15 +7,50 @@ import { NavbarBrand, NavbarItem, NavbarMenuItem, + Button, + Image, + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, } from "@nextui-org/react"; import { link as linkStyles } from "@nextui-org/theme"; import NextLink from "next/link"; import clsx from "clsx"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/router"; +import NextImage from "next/image"; import { siteConfig } from "@/config/site"; -import { TwitterIcon, GithubIcon, DiscordIcon } from "@/components/icons"; +import { + TwitterIcon, + GithubIcon, + DiscordIcon, + LoaderIcon, +} from "@/components/icons"; +import { API_URL, useUser } from "@/lib/queries"; export const Navbar = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { user, isLoading } = useUser(); + + const logout = useMutation({ + mutationFn: () => + fetch(`${API_URL}/user/me`, { + method: "DELETE", + credentials: "include", + }), + onSuccess: () => { + if (router.pathname.includes("dashboard")) { + router.push("/"); + } + queryClient.invalidateQueries({ + queryKey: ["user"], + }); + }, + }); + return ( @@ -61,16 +96,53 @@ export const Navbar = () => { - {/* */} + {isLoading ? ( + + ) : user ? ( + + + {user.name + + + +

Signed in as

+

+ {user.name} +

+
+ + Dashboard + + logout.mutate()} + > + Log out + +
+
+ ) : ( + + )}
@@ -83,24 +155,55 @@ export const Navbar = () => {
- {siteConfig.navItems.map((item, index) => ( - - + {siteConfig.navItems.map((item) => ( + + {item.label} - + ))} + {isLoading ? ( + + + + ) : user ? ( + <> + + + Dashboard + + + + logout.mutate()} + > + Log out + + +
+ {user.name +

{user.name}

+
+ + ) : null}
diff --git a/web/components/server-icon.tsx b/web/components/server-icon.tsx new file mode 100644 index 0000000..8c9dac3 --- /dev/null +++ b/web/components/server-icon.tsx @@ -0,0 +1,44 @@ +import { Image, ImageProps } from "@nextui-org/react"; +import clsx from "clsx"; +import NextImage from "next/image"; + +export function ServerIcon({ + guild, + className, + width, + height, + ...props +}: ImageProps & { + guild: { name: string; icon?: string }; +}) { + if (!guild.icon) { + return ( +
+ {guild.name.match(/[A-Z]/g)?.join("")} +
+ ); + } + + return ( + {guild.name + ); +} diff --git a/web/config/site.ts b/web/config/site.ts index 6abfa7f..c3ba1e2 100644 --- a/web/config/site.ts +++ b/web/config/site.ts @@ -8,10 +8,6 @@ export const siteConfig = { label: "Home", href: "/", }, - { - label: "Dashboard", - href: "https://dashboard.chatr.fun", - }, { label: "Docs", href: "https://docs.chatr.fun", diff --git a/web/lib/queries.tsx b/web/lib/queries.tsx new file mode 100644 index 0000000..34ce84a --- /dev/null +++ b/web/lib/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; + +import { User } from "@/types/api"; + +export const API_URL = + process.env.NODE_ENV === "development" + ? "http://localhost:18103" + : "https://api.chatr.fun"; + +const UserContext = createContext(null); + +export const UserProvider = ({ + children, + user, +}: { + children: React.ReactNode; + user: User; +}) => { + return {children}; +}; + +export const useUser = () => { + const user = useContext(UserContext); + const query = useQuery({ + queryKey: ["user"], + queryFn: async () => { + const res = await fetch(`${API_URL}/user/me`, { + credentials: "include", + }); + + if (res.status === 401) return null; + + return await res.json(); + }, + initialData: user, + }); + + return { user: query.data, isLoading: query.isLoading }; +}; diff --git a/web/package.json b/web/package.json index 7565764..501f577 100644 --- a/web/package.json +++ b/web/package.json @@ -1,33 +1,38 @@ { - "name": "@chatr/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev --port 56413", - "build": "next build", - "start": "next start --port 56414", - "lint": "next lint" - }, - "dependencies": { - "@nextui-org/react": "^2.3.0", - "@types/node": "20.5.7", - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", - "autoprefixer": "10.4.19", - "clsx": "^2.0.0", - "framer-motion": "^11.1.1", - "highcharts": "^11.4.6", - "highcharts-react-official": "^3.2.1", - "intl-messageformat": "^10.5.0", - "next": "14.2.1", - "next-themes": "^0.3.0", - "postcss": "8.4.38", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-odometer": "^0.0.1", - "react-odometerjs": "^3.1.3", - "tailwind-variants": "^0.2.1", - "tailwindcss": "3.4.3", - "typescript": "5.5.4" - } -} + "name": "@chatr/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "bun with-env next dev --port 56413", + "build": "next build", + "start": "bun with-env next start --port 56414", + "lint": "next lint", + "with-env": "dotenv -e ../.env --" + }, + "dependencies": { + "@nextui-org/react": "^2.3.0", + "@tanstack/react-query": "^5.62.9", + "@types/node": "20.5.7", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "autoprefixer": "10.4.19", + "clsx": "^2.0.0", + "framer-motion": "^11.1.1", + "highcharts": "^11.4.6", + "highcharts-react-official": "^3.2.1", + "intl-messageformat": "^10.5.0", + "next": "14.2.1", + "next-themes": "^0.3.0", + "postcss": "8.4.38", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-odometer": "^0.0.1", + "react-odometerjs": "^3.1.3", + "tailwind-variants": "^0.2.1", + "tailwindcss": "3.4.3", + "typescript": "5.5.4" + }, + "devDependencies": { + "dotenv-cli": "^8.0.0" + } +} \ No newline at end of file diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 3c5a301..3139e3e 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -3,17 +3,22 @@ import type { AppProps } from "next/app"; import { NextUIProvider } from "@nextui-org/react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { useRouter } from "next/router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fontSans, fontMono } from "@/config/fonts"; import "@/styles/globals.css"; +const queryClient = new QueryClient(); + export default function App({ Component, pageProps }: AppProps) { const router = useRouter(); return ( - + + + ); diff --git a/web/pages/dashboard/[server].tsx b/web/pages/dashboard/[server].tsx new file mode 100644 index 0000000..5513626 --- /dev/null +++ b/web/pages/dashboard/[server].tsx @@ -0,0 +1,175 @@ +import { GetServerSidePropsContext } from "next"; +import { + Autocomplete, + AutocompleteItem, + Button, + Checkbox, + Input, +} from "@nextui-org/react"; +import { FormEvent, useCallback, useState } from "react"; + +import { API_URL, UserProvider } from "@/lib/queries"; +import { User } from "@/types/api"; +import DefaultLayout from "@/layouts/default"; +import { ServerIcon } from "@/components/server-icon"; + +export default function Dashboard({ + user, + guild, + channels, +}: { + user: User; + guild: any; + channels: any; +}) { + const [cooldown, setCooldown] = useState( + (guild.cooldown / 1000).toString() + ); + const [updatesEnabled, setUpdatesEnabled] = useState( + guild.updates_enabled === 1 + ); + const [updatesChannel, setUpdatesChannel] = useState( + guild.updates_channel_id + ); + + const onSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault(); + await fetch(`${API_URL}/dashboard/update-guild`, { + body: JSON.stringify({ + guild: guild.id, + cooldown: parseInt(cooldown) * 1000, + updates: { + enabled: updatesEnabled, + channel: updatesChannel, + }, + }), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + }, []); + + return ( + + +
+
+
+ +

+ {guild.name} +

+
+
+
+
+
+ + setCooldown(e.target.value) + } + /> +
+
+

+ Level up messages +

+
+ + setUpdatesEnabled(e.target.checked) + } + > + Enable level up messages + + + setUpdatesChannel( + id as string | null + ) + } + > + {(channel: any) => ( + + {"#" + channel.name} + + )} + +
+

+ Whether or not and where to send level up + messages to. +

+
+
+ +
+
+
+
+ ); +} + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const userResponse = await fetch(`${API_URL}/user/me`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + if (userResponse.status === 401) + return { + props: { user: null, guild: null, channels: null }, + redirect: { + destination: `${API_URL}/auth/login`, + permanent: false, + }, + }; + + const guildResponse = await fetch(`${API_URL}/get/${ctx.params!.server}`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + const channelsResponse = await fetch( + `${API_URL}/dashboard/channels/${ctx.params!.server}`, + { + headers: { + Authorization: process.env.AUTH!, + }, + } + ); + + const user = await userResponse.json(); + const { guild } = await guildResponse.json(); + const channels = await channelsResponse.json(); + + console.log(channels); + + return { props: { user, guild, channels } }; +}; diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx new file mode 100644 index 0000000..ef64a97 --- /dev/null +++ b/web/pages/dashboard/index.tsx @@ -0,0 +1,101 @@ +import { Button } from "@nextui-org/react"; +import Link from "next/link"; +import { GetServerSidePropsContext } from "next"; + +import DefaultLayout from "@/layouts/default"; +import { API_URL, UserProvider } from "@/lib/queries"; +import { subtitle, title } from "@/components/primitives"; +import { ServerIcon } from "@/components/server-icon"; +import { Guild, User } from "@/types/api"; + +export default function Dashboard({ + user, + guilds, +}: { + user: User; + guilds: Guild[]; +}) { + return ( + + +
+
+

Dashboard

+

+ Manage and update your server's settings. +

+
+ {guilds.length === 0 ? ( +

You are not admin in any servers.

+ ) : ( +
+ {guilds.map((guild) => ( +
+
+ + + {guild.name} + +
+ +
+ ))} +
+ )} +
+
+
+ ); +} + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const userResponse = await fetch(`${API_URL}/user/me`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + if (userResponse.status === 401) + return { + props: { user: null, guilds: null }, + redirect: { + destination: `${API_URL}/auth/login`, + permanent: false, + }, + }; + + const guildsResponse = await fetch(`${API_URL}/user/me/guilds`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + const user = await userResponse.json(); + const guilds = await guildsResponse.json(); + + return { props: { user, guilds } }; +}; diff --git a/web/pages/leaderboard/[server].tsx b/web/pages/leaderboard/[server].tsx index 3c53ccd..88e7265 100644 --- a/web/pages/leaderboard/[server].tsx +++ b/web/pages/leaderboard/[server].tsx @@ -10,13 +10,13 @@ import DefaultLayout from "@/layouts/default"; import { Leaderboard } from "@/types/leaderboard"; import { PropsGuilds } from "@/types/props"; import { ChartOptions, ChartPointsFormatted } from "@/types/chart"; +import { API_URL } from "@/lib/queries"; const Odometer = dynamic(import("react-odometerjs"), { ssr: false, }); interface PageState { - urlToFetch: string; isLoading: boolean; discordGuildExists: boolean; discordGuildId: string; @@ -36,10 +36,6 @@ class IndexPage extends Component { super(props); this.state = { - urlToFetch: - process.env.NODE_ENV === "development" - ? "http://localhost:18103" - : "https://api.chatr.fun", isLoading: true, discordGuildExists: props.discordGuildExists, discordGuildId: props.discordGuildId, @@ -162,7 +158,7 @@ class IndexPage extends Component { if (this.state.discordGuildExists == null) { return; } else { - fetch(`${this.state.urlToFetch}/get/${this.state.discordGuildId}`) + fetch(`${API_URL}/get/${this.state.discordGuildId}`) .then((response) => response.json()) .then((data) => { const points = data.totalXp; @@ -227,7 +223,6 @@ class IndexPage extends Component { render() { const { - discordGuildExists, odometerPoints, odometerMembersBeingTracked, odometerMembers, @@ -235,15 +230,6 @@ class IndexPage extends Component { leaderboard, } = this.state; - if (!discordGuildExists) { - // Redirect to 404 - if (typeof window != "undefined") { - window.location.href = "/404"; - } - - return null; - } - return (
@@ -485,6 +471,7 @@ export async function getServerSideProps(context: { odometerMembersBeingTracked: null, leaderboard: null, }, + notFound: true, }; } } catch (error) { diff --git a/web/pages/leaderboard/[server]/[user].tsx b/web/pages/leaderboard/[server]/[user].tsx index f390b90..f07f9c6 100644 --- a/web/pages/leaderboard/[server]/[user].tsx +++ b/web/pages/leaderboard/[server]/[user].tsx @@ -8,13 +8,13 @@ import DefaultLayout from "@/layouts/default"; import "odometer/themes/odometer-theme-default.css"; import { ChartOptions, ChartPointsFormatted } from "@/types/chart"; import { PropsUsers } from "@/types/props"; +import { API_URL } from "@/lib/queries"; const Odometer = dynamic(import("react-odometerjs"), { ssr: false, }); interface PageState { - urlToFetch: string; isLoading: boolean; discordAccountExists: boolean; discordUserId: string; @@ -38,10 +38,6 @@ class IndexPage extends Component { super(props); this.state = { - urlToFetch: - process.env.NODE_ENV === "development" - ? "http://localhost:18103" - : "https://api.chatr.fun", isLoading: true, // Flag to indicate whether a request is in progress discordAccountExists: props.discordAccountExists, discordUserId: props.discordUserId, @@ -171,7 +167,7 @@ class IndexPage extends Component { return; } else { fetch( - `${this.state.urlToFetch}/get/${this.state.discordGuildId}/${this.state.discordUserId}` + `${API_URL}/get/${this.state.discordGuildId}/${this.state.discordUserId}` ) .then((response) => response.json()) .then((data) => { @@ -239,7 +235,6 @@ class IndexPage extends Component { render() { const { - discordAccountExists, odometerPoints, odometerPointsNeededToNextLevel, odometerPointsNeededForNextLevel, @@ -248,15 +243,6 @@ class IndexPage extends Component { chartOptions, } = this.state; - if (!discordAccountExists) { - // Redirect to 404 - if (typeof window != "undefined") { - window.location.href = "/404"; - } - - return null; - } - return (
@@ -403,6 +389,7 @@ export async function getServerSideProps(context: { odometerPointsNeededForNextLevel: null, odometerProgressToNextLevelPercentage: null, }, + notFound: true, }; } } catch (error) { diff --git a/web/types/api.d.ts b/web/types/api.d.ts new file mode 100644 index 0000000..7facb9e --- /dev/null +++ b/web/types/api.d.ts @@ -0,0 +1,16 @@ +export interface User { + id: string; + name: string; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + expires_at: Date; +} + +export interface Guild { + id: string; + name: string; + icon?: string; + botIsInGuild: boolean; +}