diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml new file mode 100644 index 0000000..6fdf88e --- /dev/null +++ b/.github/workflows/builder.yml @@ -0,0 +1,22 @@ +# .github/workflows/builder.yml + +name: Builder +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: "16" + - name: Install dependencies + run: pnpm install + - name: Build + run: pnpm run build diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 257b77d..03b81b7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { authenticatedMiddleware, afterRouteLogicMiddleware, } from "./lib/functions/middleware"; +import { API_ERROR_MESSAGES } from "shared"; interface Env {} @@ -44,7 +45,8 @@ export const api = HonoBetterAuth() return c.json( { - error: "Internal Server Error", + message: "An unexpected error occurred.", + code: API_ERROR_MESSAGES.GENERIC_ERROR, }, 500, ); diff --git a/apps/api/src/lib/functions/database.ts b/apps/api/src/lib/functions/database.ts index 2aeb760..6e12e3f 100644 --- a/apps/api/src/lib/functions/database.ts +++ b/apps/api/src/lib/functions/database.ts @@ -45,11 +45,12 @@ export function isSiteAdminUser( } export async function leaveTeam(userId: string, teamId: string) { - await db + return db .delete(userToTeam) .where( and(eq(userToTeam.userId, userId), eq(userToTeam.teamId, teamId)), - ); + ) + .returning({ teamId: userToTeam.teamId }); } export async function getAdminUserForTeam(userId: string, teamId: string) { @@ -61,7 +62,7 @@ export async function getAdminUserForTeam(userId: string, teamId: string) { ), }); } - +// TODO: This function is lowkey pivotal so we should ensure it is WAI. export async function isUserSiteAdminOrQueryHasPermissions( userSiteRole: SiteRoleType, // Accept either a Promise (already invoked query) or a function that returns a Promise diff --git a/apps/api/src/lib/functions/middleware.ts b/apps/api/src/lib/functions/middleware.ts index c2e85b4..0397b0b 100644 --- a/apps/api/src/lib/functions/middleware.ts +++ b/apps/api/src/lib/functions/middleware.ts @@ -16,10 +16,7 @@ export async function setUserSessionContextMiddleware(c: Context, next: Next) { const requestId = nanoid(); c.set("requestId", requestId); - await logInfo( - `Middleware for request path ${c.req.path} for ${userString}`, - c, - ); + logInfo(`Middleware for request path ${c.req.path} for ${userString}`, c); if (!session) { c.set("user", null); @@ -44,8 +41,14 @@ export async function authenticatedMiddleware(c: ApiContext, next: Next) { const user = c.get("user"); const session = c.get("session"); if (!(user && session)) { - await logInfo(`Unauthorized access attempt to ${c.req.path}`, c); - return c.json({ error: API_ERROR_MESSAGES.notAuthorized }, 401); + logInfo(`Unauthorized access attempt to ${c.req.path}`, c); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } return next(); } diff --git a/apps/api/src/routes/log.ts b/apps/api/src/routes/log.ts index 52f088a..477431e 100644 --- a/apps/api/src/routes/log.ts +++ b/apps/api/src/routes/log.ts @@ -27,10 +27,17 @@ const logHandler = HonoBetterAuth() .get("/admin/all", async (c) => { const user = c.get("user"); if (!user || !isSiteAdminUser(user.siteRole)) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: + "You are not authorized to access this endpoint. Only site admins can access all logs.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); } const allLogs = await db.query.log.findMany(); - return c.json({ message: allLogs }, 200); + return c.json({ data: allLogs }, 200); }) // This route needs to be made to get logs from a team. Logs should be paginated and alllow for basic filtering on the frontend .get("/:teamId", zValidator("param", teamIdSchema), async (c) => { @@ -38,7 +45,13 @@ const logHandler = HonoBetterAuth() const teamId = c.req.valid("param").teamId; if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const hasPermissions = await isUserSiteAdminOrQueryHasPermissions( @@ -46,11 +59,18 @@ const logHandler = HonoBetterAuth() getAdminUserForTeam(user.id, teamId), ); if (!hasPermissions) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: + "You are not authorized to access this endpoint. Only admins can access team logs.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); } const logs = await db.query.log.findMany({ where: eq(log.teamId, teamId), }); - return c.json({ message: logs }, 200); + return c.json({ data: logs }, 200); }); export default logHandler; diff --git a/apps/api/src/routes/team.ts b/apps/api/src/routes/team.ts index debccbd..97cbe16 100644 --- a/apps/api/src/routes/team.ts +++ b/apps/api/src/routes/team.ts @@ -5,7 +5,6 @@ import { db, eq, and, - isNull, getUserTeamsQuery, userToTeam, teamInvite, @@ -37,31 +36,56 @@ const teamHandler = HonoBetterAuth() .get("/", async (c) => { const user = c.get("user"); if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const userTeams = await getUserTeamsQuery(user.id); - return c.json({ message: userTeams }, 200); + return c.json({ data: userTeams }, 200); }) // Retrieve all of the teams .get("/admin", async (c) => { const user = c.get("user"); if (!user || !isSiteAdminUser(user.siteRole)) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const allTeams = await db.query.team.findMany(); - return c.json({ message: allTeams }, 200); + return c.json({ data: allTeams }, 200); }) + // Retrieve all of the teams .post("/join", zValidator("query", joinTeamSchema), async (c) => { const inv = c.req.valid("query").inv; const user = c.get("user"); if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } if (!inv) { - return c.json({ message: API_ERROR_MESSAGES.noInviteCode }, 400); + return c.json( + { + message: "Invite code is required to join a team.", + code: API_ERROR_MESSAGES.NO_INVITE_CODE, + }, + 400, + ); } const inviteRequest = await db.query.teamInvite.findFirst({ where: and( @@ -71,15 +95,33 @@ const teamHandler = HonoBetterAuth() }); if (!inviteRequest) { - return c.json({ message: API_ERROR_MESSAGES.codeNotFound }, 400); + return c.json( + { + message: "Invite code not found for this email.", + code: API_ERROR_MESSAGES.CODE_NOT_FOUND, + }, + 400, + ); } if (inviteRequest.acceptedAt) { - return c.json({ message: API_ERROR_MESSAGES.alreadyMember }, 400); + return c.json( + { + message: "User is already a member of this team.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, + 400, + ); } // Check if the invite has expired if (inviteRequest.expiresAt && isPast(inviteRequest.expiresAt)) { - return c.json({ message: API_ERROR_MESSAGES.codeExpired }, 400); + return c.json( + { + message: "Invite code has expired.", + code: API_ERROR_MESSAGES.CODE_EXPIRED, + }, + 400, + ); } c.set("teamId", inviteRequest.teamId); @@ -102,34 +144,51 @@ const teamHandler = HonoBetterAuth() } catch (e) { const errorCode = maybeGetDbErrorCode(e); if (errorCode === "SQLITE_CONSTRAINT") { - await logWarning( + logWarning( `User with ID ${user.id} is already a member of team with ID ${inviteRequest.teamId}. Transaction has been rolled back.`, c, ); return c.json( - { message: API_ERROR_MESSAGES.alreadyMember }, + { + message: "User is already a member of this team.", + code: API_ERROR_MESSAGES.ALREADY_MEMBER, + }, 400, ); } - await logError( + logError( `Error occurred while user with ID ${user.id} was attempting to join team with ID ${inviteRequest.teamId}. Transaction has been rolled back. Error details: ${e}`, c, ); - return c.json({ message: API_ERROR_MESSAGES.genericError }, 500); + return c.json( + { + message: + "An error occurred while attempting to join the team. Please try again later.", + code: API_ERROR_MESSAGES.GENERIC_ERROR, + }, + 500, + ); } const teamInfo = await db.query.team.findFirst({ where: eq(team.id, inviteRequest.teamId), }); if (!teamInfo) { - await logError( + logError( `Team with ID ${inviteRequest.teamId} not found after accepting invite. This should not happen and indicates a critical issue. Please investigate immediately.`, c, ); - return c.json({ message: API_ERROR_MESSAGES.notFound }, 500); + return c.json( + { + message: + "Team not found after accepting invite. Please contact support.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 500, + ); } - return c.json({ message: teamInfo }, 200); + return c.json({ data: teamInfo }, 200); }) .get("/:teamId", zValidator("param", teamIdSchema), async (c) => { const teamId = c.req.valid("param").teamId; @@ -138,7 +197,13 @@ const teamHandler = HonoBetterAuth() c.set("teamId", teamId); if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const asyncCallback = db.query.userToTeam.findFirst({ where: and( @@ -153,8 +218,12 @@ const teamHandler = HonoBetterAuth() if (!canUserView) { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, - 401, + { + message: + "You cannot view this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, ); } @@ -163,10 +232,16 @@ const teamHandler = HonoBetterAuth() }); if (!teamInfo) { - return c.json({ message: API_ERROR_MESSAGES.notFound }, 404); + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); } - return c.json({ message: teamInfo }, 200); + return c.json({ data: teamInfo }, 200); }) // Not too sure if we should enhance this. Perhaps mark it for deletion instead and allow the users to recover it? .delete("/:teamId", zValidator("param", teamIdSchema), async (c) => { @@ -174,7 +249,13 @@ const teamHandler = HonoBetterAuth() const teamId = c.req.valid("param").teamId; if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const canUserDelete = await isUserSiteAdminOrQueryHasPermissions( @@ -184,23 +265,47 @@ const teamHandler = HonoBetterAuth() if (!canUserDelete) { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, + { + message: + "You cannot delete this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, 401, ); } - await db.delete(team).where(eq(team.id, teamId)); + const deletedTeamData = await db + .delete(team) + .where(eq(team.id, teamId)) + .returning(); - return c.json({ message: "Success" }, 200); + if (deletedTeamData.length === 0) { + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + + return c.json({ data: deletedTeamData[0] }, 200); }) .get("/:teamId/admin", zValidator("param", teamIdSchema), async (c) => { const teamId = c.req.valid("param").teamId; const user = c.get("user"); if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } + // Either team or site admins can view so we need to check. const canUserView = await isUserSiteAdminOrQueryHasPermissions( user.siteRole, getAdminUserForTeam(user.id, teamId), @@ -208,8 +313,12 @@ const teamHandler = HonoBetterAuth() if (!canUserView) { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, - 401, + { + message: + "You cannot view this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, ); } @@ -224,17 +333,29 @@ const teamHandler = HonoBetterAuth() }); if (!allTeamInfo) { - return c.json({ message: API_ERROR_MESSAGES.notFound }, 404); + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); } - return c.json({ message: allTeamInfo }, 200); + return c.json({ data: allTeamInfo }, 200); }) .get("/:teamId/members", zValidator("param", teamIdSchema), async (c) => { const teamId = c.req.valid("param").teamId; const user = c.get("user"); if (!user) { - return c.json({ message: API_ERROR_MESSAGES.notAuthorized }, 401); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } const canUserView = await isUserSiteAdminOrQueryHasPermissions( user.siteRole, @@ -243,8 +364,12 @@ const teamHandler = HonoBetterAuth() if (!canUserView) { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, - 401, + { + message: + "You cannot view this team's members. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, ); } @@ -256,10 +381,16 @@ const teamHandler = HonoBetterAuth() }); if (!teamMembers) { - return c.json({ message: API_ERROR_MESSAGES.notFound }, 404); + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); } - return c.json({ message: teamMembers }, 200); + return c.json({ data: teamMembers }, 200); }) .patch( "/:teamId/update", @@ -272,7 +403,10 @@ const teamHandler = HonoBetterAuth() if (!user) { return c.json( - { message: API_ERROR_MESSAGES.notAuthorized }, + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, 401, ); } @@ -284,19 +418,34 @@ const teamHandler = HonoBetterAuth() if (!canUserUpdate) { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, - 401, + { + message: + "You cannot update this team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, ); } - await db + const newTeamData = await db .update(team) .set({ name: newTeamNameSchema.name, }) - .where(eq(team.id, teamId)); + .where(eq(team.id, teamId)) + .returning(); + + if (newTeamData.length === 0) { + return c.json( + { + message: "Team not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } - return c.json({ message: "Success" }, 200); + return c.json({ data: newTeamData[0] }, 200); }, ) .delete( @@ -309,39 +458,48 @@ const teamHandler = HonoBetterAuth() if (!user) { return c.json( - { message: API_ERROR_MESSAGES.notAuthorized }, + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, 401, ); } - // If the user is attempting to remove themselves, we will allow this. - if (userIdToRemove === user.id) { - await leaveTeam(user.id, teamId); - - return c.json( - { message: "Successfully removed from team." }, - 200, - ); - } - - // If not, we know that it is a user attempting to remove another user and we need to ensure they have the right permissions for this. + // Users can remove themselves at any time. + const isUserAttemptingToRemoveThemselves = + userIdToRemove === user.id; const canUserRemove = await isUserSiteAdminOrQueryHasPermissions( user.siteRole, getAdminUserForTeam(user.id, teamId), ); - if (!canUserRemove) { + if (isUserAttemptingToRemoveThemselves || canUserRemove) { + const teamIdUserRemovedFrom = await leaveTeam( + userIdToRemove, + teamId, + ); + if (teamIdUserRemovedFrom.length === 0) { + return c.json( + { + message: "Team or user not found.", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); + } + return c.json({ data: teamIdUserRemovedFrom[0] }, 200); + } else { return c.json( - { message: API_ERROR_MESSAGES.invalidPermissions }, - 401, + { + message: + "You cannot remove this user from the team. Please contact your administrator if you believe this is an error.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, ); } - - // Lastly, if they are good, we can finally remove the user - await leaveTeam(userIdToRemove, teamId); - - return c.json({ message: "Success" }, 200); }, ); export default teamHandler; diff --git a/apps/api/src/routes/user.ts b/apps/api/src/routes/user.ts index 7f5df34..52a1948 100644 --- a/apps/api/src/routes/user.ts +++ b/apps/api/src/routes/user.ts @@ -3,18 +3,26 @@ import { z } from "zod"; import { HonoBetterAuth } from "../lib/functions"; import { db, eq } from "db"; import { user } from "db/schema"; +import { API_ERROR_MESSAGES } from "shared"; +import { isSiteAdminUser } from "../lib/functions/database"; const userhandler = HonoBetterAuth() .get("/", async (c) => { const user = c.get("user"); if (!user) { - return c.json({ error: "User has been deleted." }, 400); + return c.json( + { + message: "Please log in.", + code: API_ERROR_MESSAGES.NOT_AUTHENTICATED, + }, + 401, + ); } - return c.json({ user }, 200); + return c.json({ data: user }, 200); }) // This needs a permission check. Only admins of the site should be able to see ths endpoint .get( - "/:userId", + "/admin/:userId", zValidator( "param", z.object({ @@ -22,14 +30,31 @@ const userhandler = HonoBetterAuth() }), ), async (c) => { + const maybeAdminUser = c.get("user"); + if (!(maybeAdminUser && isSiteAdminUser(maybeAdminUser.siteRole))) { + return c.json( + { + message: + "You are not authorized to access this endpoint. Only site admins can access other user data.", + code: API_ERROR_MESSAGES.NOT_AUTHORIZED, + }, + 403, + ); + } const userId = c.req.valid("param").userId; const requestedUser = await db.query.user.findFirst({ where: eq(user.id, userId), }); if (!requestedUser) { - return c.json({ error: "User not found" }, 404); + return c.json( + { + message: "User not found", + code: API_ERROR_MESSAGES.NOT_FOUND, + }, + 404, + ); } - return c.json({ user: requestedUser }, 200); + return c.json({ data: requestedUser }, 200); }, ); diff --git a/apps/web/src/components/shared/Navbar/UserButton.tsx b/apps/web/src/components/shared/Navbar/UserButton.tsx index 7243ae2..0373ed7 100644 --- a/apps/web/src/components/shared/Navbar/UserButton.tsx +++ b/apps/web/src/components/shared/Navbar/UserButton.tsx @@ -31,7 +31,7 @@ export default function UserButton() { isLoading: isFetchingUserTeams, isError, } = useQuery(getUserTeamsQueryClient); - const userTeams = userTeamsResult?.message; + const userTeams = userTeamsResult?.data; const { invalidate, navigate } = useRouter(); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 9234aa7..37a6efa 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -18,7 +18,12 @@ export function getFirstName(fullName: string): string { } export function getInitials(fullName: string): string { - const names = fullName.split(" "); + // Filter out empty strings from whitespace splitting + const names = fullName + .trim() + .split(/\s+/) + .filter((name) => name.length > 0); + if (names.length === 0) return ""; if (names.length === 1) return names[0][0].toUpperCase(); return names[0][0].toUpperCase() + names[names.length - 1][0].toUpperCase(); diff --git a/packages/db/schema.ts b/packages/db/schema.ts index b4853d7..b091846 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -136,6 +136,8 @@ export const teamJoinRequest = sqliteTable("team_join_request", { status: teamJoinRequestStatusType.notNull().default("PENDING"), }); +// TODO(https://github.com/acmutsa/Fallback/issues/35): Come back and add team join requests + export const backupJob = sqliteTable("backup_job", { id: standardIdFactory("job_").primaryKey(), name: standardVarcharFactory(), diff --git a/packages/shared/constants.ts b/packages/shared/constants.ts index f312339..758ee71 100644 --- a/packages/shared/constants.ts +++ b/packages/shared/constants.ts @@ -65,12 +65,12 @@ export const THEME_CONFIG = { }; export const API_ERROR_MESSAGES = { - noInviteCode: "no_invite_code", - codeNotFound: "code_not_found", - codeExpired: "code_expired", - invalidPermissions: "invalid_permissions", - notFound: "not_found", - notAuthorized: "unauthorized", - alreadyMember: "already_member", - genericError: "generic_error", + NO_INVITE_CODE: "NO_INVITE_CODE", + CODE_NOT_FOUND: "CODE_NOT_FOUND", + CODE_EXPIRED: "CODE_EXPIRED", + NOT_FOUND: "NOT_FOUND", + NOT_AUTHORIZED: "NOT_AUTHORIZED", + ALREADY_MEMBER: "ALREADY_MEMBER", + GENERIC_ERROR: "GENERIC_ERROR", + NOT_AUTHENTICATED: "NOT_AUTHENTICATED", };