From 34f8f95f01cfa67415d74cb2b1b73e44499cdda9 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 19 Jun 2025 00:28:33 -0400 Subject: [PATCH 1/6] Set up ServerOverview feature structure --- app/components/login.tsx | 27 --------------------------- app/components/logout.tsx | 18 ------------------ app/routes/index.tsx | 2 +- 3 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 app/components/login.tsx delete mode 100644 app/components/logout.tsx diff --git a/app/components/login.tsx b/app/components/login.tsx deleted file mode 100644 index b9acffd..0000000 --- a/app/components/login.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Form } from "react-router"; -import type { ButtonHTMLAttributes } from "react"; - -interface LoginProps extends ButtonHTMLAttributes { - errors?: { [k: string]: string }; - redirectTo?: string; -} - -export function Login({ - children = "Log in with Discord", - // errors, - redirectTo, - ...props -}: LoginProps) { - return ( -
- - -
- ); -} diff --git a/app/components/logout.tsx b/app/components/logout.tsx deleted file mode 100644 index a29e919..0000000 --- a/app/components/logout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Form } from "react-router"; -import type { ButtonHTMLAttributes } from "react"; - -type LoginProps = ButtonHTMLAttributes; - -export function Logout({ children = "Log out", ...props }: LoginProps) { - return ( -
- -
- ); -} diff --git a/app/routes/index.tsx b/app/routes/index.tsx index f5a14a0..b973783 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,4 +1,4 @@ -import { Login } from "#~/components/login"; +import { Login } from "#~/basics/login"; import { ServerOverview } from "#~/features/ServerOverview"; import { useOptionalUser } from "#~/utils"; From 4890a21bbd14fba15ba09908c3a6890aec110fb6 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 18 Jun 2025 14:58:21 -0400 Subject: [PATCH 2/6] discord model --- app/models/discord.server.ts | 110 ++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/app/models/discord.server.ts b/app/models/discord.server.ts index b1b3479..a3a811f 100644 --- a/app/models/discord.server.ts +++ b/app/models/discord.server.ts @@ -1,4 +1,14 @@ -import type { GuildMember } from "discord.js"; +import { + Routes, + type APIGuild, + PermissionFlagsBits, +} from "discord-api-types/v10"; + +import type { REST } from "@discordjs/rest"; +import { type GuildMember } from "discord.js"; + +import { complement, intersection } from "#~/helpers/sets.js"; + import type { AccessToken } from "simple-oauth2"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; @@ -88,3 +98,101 @@ export const timeout = async (member: GuildMember | null) => { } return member.timeout(OVERNIGHT); }; + +const authzRoles = { + mod: "MOD", + admin: "ADMIN", + manager: "MANAGER", + manageChannels: "MANAGE_CHANNELS", + manageGuild: "MANAGE_GUILD", + manageRoles: "MANAGE_ROLES", +} as const; + +const isUndefined = (x: unknown): x is undefined => typeof x === "undefined"; + +const processGuild = (g: APIGuild) => { + const perms = BigInt(g.permissions || 0); + const authz = new Set<(typeof authzRoles)[keyof typeof authzRoles]>(); + + if (perms & PermissionFlagsBits.Administrator) { + authz.add(authzRoles.admin); + } + if (perms & PermissionFlagsBits.ModerateMembers) { + authz.add(authzRoles.mod); + } + if (perms & PermissionFlagsBits.ManageChannels) { + authz.add(authzRoles.manageChannels); + authz.add(authzRoles.manager); + } + if (perms & PermissionFlagsBits.ManageGuild) { + authz.add(authzRoles.manageGuild); + authz.add(authzRoles.manager); + } + if (perms & PermissionFlagsBits.ManageRoles) { + authz.add(authzRoles.manageRoles); + authz.add(authzRoles.manager); + } + + return { + id: g.id as string, + icon: g.icon, + name: g.name as string, + authz: [...authz.values()], + }; +}; + +export interface Guild extends ReturnType { + hasBot: boolean; +} + +export const fetchGuilds = async ( + userRest: REST, + botRest: REST, +): Promise => { + const [rawUserGuilds, rawBotGuilds] = (await Promise.all([ + userRest.get(Routes.userGuilds()), + botRest.get(Routes.userGuilds()), + ])) as [APIGuild[], APIGuild[]]; + + const botGuilds = new Map( + rawBotGuilds.reduce( + (accum, val) => { + const guild = processGuild(val); + if (guild.authz.length > 0) { + accum.push([val.id, guild]); + } + return accum; + }, + [] as [string, Omit][], + ), + ); + const userGuilds = new Map( + rawUserGuilds.reduce( + (accum, val) => { + const guild = processGuild(val); + if (guild.authz.includes("MANAGER")) { + accum.push([val.id, guild]); + } + return accum; + }, + [] as [string, Omit][], + ), + ); + + const botGuildIds = new Set(botGuilds.keys()); + const userGuildIds = new Set(userGuilds.keys()); + + const manageableGuilds = intersection(botGuildIds, userGuildIds); + const invitableGuilds = complement(userGuildIds, botGuildIds); + + return [ + ...[...manageableGuilds].map((gId) => { + const guild = botGuilds.get(gId); + return guild ? { ...guild, hasBot: true } : undefined; + }), + ...[...invitableGuilds].map((gId) => { + const guild = botGuilds.get(gId); + return guild ? { ...guild, hasBot: false } : undefined; + }), + ].filter((g) => !isUndefined(g)); +}; From 4524194e56d3698e0de78889e4a5a8272bc43c33 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 18 Jun 2025 14:57:37 -0400 Subject: [PATCH 3/6] helpers --- app/helpers/array.ts | 5 +++++ app/helpers/sets.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/app/helpers/array.ts b/app/helpers/array.ts index f217775..dd2a1c7 100644 --- a/app/helpers/array.ts +++ b/app/helpers/array.ts @@ -14,3 +14,8 @@ export const partition = (predicate: (d: Data) => boolean, xs: Data[]) => }, [[], []] as [Data[], Data[]], ); + +/** + * ElementType is a helper type to turn `Array` into `T` + */ +export type ElementType = T extends (infer U)[] ? U : T; diff --git a/app/helpers/sets.ts b/app/helpers/sets.ts index a58d409..f256f4c 100644 --- a/app/helpers/sets.ts +++ b/app/helpers/sets.ts @@ -1,2 +1,38 @@ export const difference = (a: Set, b: Set) => new Set(Array.from(a).filter((x) => !b.has(x))); + +/** + * Returns the intersection of two sets - elements that exist in both sets + * @param setA First set + * @param setB Second set + * @returns A new Set containing elements present in both input sets + */ +export function intersection(setA: Set, setB: Set): Set { + const result = new Set(); + + for (const elem of setA) { + if (setB.has(elem)) { + result.add(elem); + } + } + + return result; +} + +/** + * Returns the complement (difference) of two sets - elements in setA that are not in setB + * @param setA First set + * @param setB Second set + * @returns A new Set containing elements present in setA but not in setB + */ +export function complement(setA: Set, setB: Set): Set { + const result = new Set(); + + for (const elem of setA) { + if (!setB.has(elem)) { + result.add(elem); + } + } + + return result; +} From a29fefe9f5ff5a4c28fd5f7f54808150bfd91075 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Wed, 18 Jun 2025 14:58:13 -0400 Subject: [PATCH 4/6] layout shiz --- .../ServerOverview/ServerOverview.tsx | 12 +--- app/routes/index.tsx | 63 +++++++++++++++++-- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/app/features/ServerOverview/ServerOverview.tsx b/app/features/ServerOverview/ServerOverview.tsx index a55b084..59a9236 100644 --- a/app/features/ServerOverview/ServerOverview.tsx +++ b/app/features/ServerOverview/ServerOverview.tsx @@ -1,13 +1,3 @@ -import { Logout } from "#~/basics/logout"; - export function ServerOverview() { - return ( -
- -
-
-
- ); + return
butts (ServerOverview)
; } diff --git a/app/routes/index.tsx b/app/routes/index.tsx index b973783..440cc03 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -1,8 +1,17 @@ -import { Login } from "#~/basics/login"; -import { ServerOverview } from "#~/features/ServerOverview"; +import type { PropsWithChildren } from "react"; + +// import { REST } from "@discordjs/rest"; +import type { Route } from "./+types/index"; + +// import { retrieveDiscordToken } from "#~/models/session.server.js"; +// import { discordToken } from "#~/helpers/env.server.js"; +import { ServerOverview } from "#~/features/ServerOverview"; import { useOptionalUser } from "#~/utils"; +import { Login } from "#~/basics/login"; +import { Logout } from "#~/basics/logout.js"; + const EmojiBackdrop = () => { return (
{ ); }; -export default function Index() { +// const botDiscord = new REST().setToken(discordToken); +// const userDiscord = new REST({ authPrefix: "Bearer" }); + +// export const loader = async ({ request }: Route.LoaderArgs) => { +// let token; +// try { +// token = await retrieveDiscordToken(request); +// } catch (e) { +// console.error(e); +// return; +// } + +// userDiscord.setToken(token.token.access_token as string); + +// return { +// guilds: await fetchGuilds(userDiscord, botDiscord), +// }; +// }; + +interface LayoutProps extends PropsWithChildren { + guilds: Exclude["guilds"]; +} + +const Layout = ({ /* guilds, */ children }: LayoutProps) => { + return ( + <> + +
{children}
+
+ + ); +}; + +export default function Index({ loaderData }: Route.ComponentProps) { const user = useOptionalUser(); - if (!user) { + if (!user || !loaderData) { return (
@@ -67,5 +114,11 @@ export default function Index() {
); } - return ; + + const { guilds } = loaderData; + return ( + + + + ); } From 470fc70e6fc5ec0cf0c7873fbb4d2d0b82ff66f3 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Thu, 19 Jun 2025 18:58:26 -0400 Subject: [PATCH 5/6] Only store user ID in db session --- app/models/session.server.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/models/session.server.ts b/app/models/session.server.ts index 4cd1671..c3cd455 100644 --- a/app/models/session.server.ts +++ b/app/models/session.server.ts @@ -107,18 +107,18 @@ const { export type DbSession = Awaited>; export const CookieSessionKeys = { - userId: "userId", discordToken: "discordToken", + authState: "state", + authRedirect: "redirectTo", } as const; export const DbSessionKeys = { - authState: "state", - authRedirect: "redirectTo", + userId: "userId", } as const; async function getUserId(request: Request): Promise { - const session = await getCookieSession(request.headers.get("Cookie")); - const userId = session.get(CookieSessionKeys.userId); + const session = await getDbSession(request.headers.get("Cookie")); + const userId = session.get(DbSessionKeys.userId); return userId; } @@ -162,14 +162,14 @@ export async function initOauthLogin({ redirectTo?: string; }) { const { origin } = new URL(request.url); - const dbSession = await getDbSession(request.headers.get("Cookie")); + const cookieSession = await getCookieSession(request.headers.get("Cookie")); const state = randomUUID(); - dbSession.set(DbSessionKeys.authState, state); + cookieSession.set(CookieSessionKeys.authState, state); if (redirectTo) { - dbSession.set(DbSessionKeys.authRedirect, redirectTo); + cookieSession.set(CookieSessionKeys.authRedirect, redirectTo); } - const cookie = await commitDbSession(dbSession, { + const cookie = await commitCookieSession(cookieSession, { maxAge: 60 * 60 * 1, // 1 hour }); return redirect( @@ -221,17 +221,16 @@ export async function completeOauthLogin( getDbSession(reqCookie), ]); - const dbState = dbSession.get(DbSessionKeys.authState); + const dbState = cookieSession.get(CookieSessionKeys.authState); // Redirect to login if the state arg doesn't match if (dbState !== state) { console.error("DB state didn’t match cookie state"); throw redirect("/login"); } - cookieSession.set(CookieSessionKeys.userId, userId); + dbSession.set(DbSessionKeys.userId, userId); // @ts-expect-error token.toJSON() isn't in the types but it works cookieSession.set(CookieSessionKeys.discordToken, token.toJSON()); - dbSession.unset(DbSessionKeys.authState); const [cookie, dbCookie] = await Promise.all([ commitCookieSession(cookieSession, { maxAge: 60 * 60 * 24 * 7, // 7 days @@ -242,7 +241,7 @@ export async function completeOauthLogin( headers.append("Set-Cookie", cookie); headers.append("Set-Cookie", dbCookie); - return redirect(dbSession.get(DbSessionKeys.authRedirect) ?? "/", { + return redirect(cookieSession.get(CookieSessionKeys.authRedirect) ?? "/", { headers, }); } From 8b8c0426229c957e6c7e9388f7086cac703d3339 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 24 Jun 2025 13:53:01 -0400 Subject: [PATCH 6/6] fix --- app/routes/__auth.tsx | 2 +- app/routes/auth.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/__auth.tsx b/app/routes/__auth.tsx index 61633d2..fc85d94 100644 --- a/app/routes/__auth.tsx +++ b/app/routes/__auth.tsx @@ -1,6 +1,6 @@ import type { Route } from "./+types/__auth"; import { Outlet, useLocation, type LoaderFunctionArgs } from "react-router"; -import { Login } from "#~/components/login"; +import { Login } from "#~/basics/login"; import { isProd } from "#~/helpers/env.server"; import { getUser } from "#~/models/session.server"; import { useOptionalUser } from "#~/utils"; diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx index 5509bff..cfab1c8 100644 --- a/app/routes/auth.tsx +++ b/app/routes/auth.tsx @@ -2,7 +2,7 @@ import type { Route } from "./+types/auth"; import { redirect } from "react-router"; import { initOauthLogin } from "#~/models/session.server"; -import { Login } from "#~/components/login"; +import { Login } from "#~/basics/login"; // eslint-disable-next-line no-empty-pattern export async function loader({}: Route.LoaderArgs) {