Skip to content

Commit

Permalink
Redirect after login
Browse files Browse the repository at this point in the history
* Redirect users to `/login?redirect=…` if any endpoint responds with specific 401 Unauthorized message
* Redirect users to requested resource after login succeeds
* Use `/login?redirect=%2Fapi%2Fresource%2F…` in Signal error messages with screenshot URL
* Put error messages in `commons` package
  • Loading branch information
marvinruder committed Jun 9, 2023
1 parent 0c67891 commit df09863
Show file tree
Hide file tree
Showing 16 changed files with 111 additions and 58 deletions.
3 changes: 2 additions & 1 deletion packages/backend/src/controllers/AuthController.live.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ALREADY_REGISTERED_ERROR_MESSAGE,
registerEndpointPath,
signInEndpointPath,
userEndpointPath,
Expand Down Expand Up @@ -147,7 +148,7 @@ tests.push({
testFunction: async () => {
const res = await supertest.get(`/api${registerEndpointPath}?email=jane.doe%40example.com&name=Jane%20Doe`);
expect(res.status).toBe(403);
expect(res.body.message).toMatch("This email address is already registered. Please sign in.");
expect(res.body.message).toMatch(ALREADY_REGISTERED_ERROR_MESSAGE);
},
});

Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/controllers/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import APIError from "../utils/apiError.js";
import { createUser, readUserWithCredentials, updateUserWithCredentials, userExists } from "../db/tables/userTable.js";
import { sessionTTLInSeconds } from "../redis/repositories/sessionRepository.js";
import {
ALREADY_REGISTERED_ERROR_MESSAGE,
GENERAL_ACCESS,
optionalUserValuesNull,
registerEndpointPath,
Expand Down Expand Up @@ -53,7 +54,7 @@ export class AuthController {
// Users are required to provide an email address and a name to register.
if (typeof email === "string" && typeof name === "string") {
if (await userExists(email)) {
throw new APIError(403, "This email address is already registered. Please sign in.");
throw new APIError(403, ALREADY_REGISTERED_ERROR_MESSAGE);
}
// We generate the registration options and store the challenge for later verification.
const options = SimpleWebAuthnServer.generateRegistrationOptions({
Expand Down Expand Up @@ -127,7 +128,7 @@ export class AuthController {
})
))
) {
throw new APIError(403, "This email address is already registered. Please sign in.");
throw new APIError(403, ALREADY_REGISTERED_ERROR_MESSAGE);
}
if (verified) {
res.status(201).end();
Expand Down
9 changes: 3 additions & 6 deletions packages/backend/src/fetchers/spFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FetcherWorkspace } from "../controllers/FetchController.js";
import { getDriver, openPageAndWait, quitDriver, takeScreenshot } from "../utils/webdriver.js";
import logger, { PREFIX_SELENIUM } from "../utils/logger.js";
import { formatDistance } from "date-fns";
import { Stock } from "@rating-tracker/commons";
import { SP_PREMIUM_STOCK_ERROR_MESSAGE, Stock } from "@rating-tracker/commons";
import { By, until } from "selenium-webdriver";
import chalk from "chalk";
import * as signal from "../signal/signal.js";
Expand Down Expand Up @@ -73,16 +73,13 @@ const spFetcher = async (req: Request, stocks: FetcherWorkspace<Stock>): Promise
) {
// If the content is available for premium subscribers only, we throw an error.
// Sadly, we are not a premium subscriber :(
throw new Error("This stock’s ESG Score is available for S&P Premium subscribers only.");
throw new Error(SP_PREMIUM_STOCK_ERROR_MESSAGE);
}

spESGScore = +(await driver.findElement(By.id("esg-score")).getText());

// Update the stock in the database.
await updateStock(stock.ticker, {
spLastFetch: new Date(),
spESGScore,
});
await updateStock(stock.ticker, { spLastFetch: new Date(), spESGScore });
stocks.successful.push(await readStock(stock.ticker));
} catch (e) {
if (req.query.ticker) {
Expand Down
5 changes: 2 additions & 3 deletions packages/backend/src/utils/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextFunction, Request, Response, Router as expressRouter } from "expres
import APIError from "./apiError.js";
export const router = expressRouter();
import rateLimit from "express-rate-limit";
import { FORBIDDEN_ERROR_MESSAGE, UNAUTHORIZED_ERROR_MESSAGE } from "@rating-tracker/commons";

/**
* Rate limiter in use by authentication routes.
Expand Down Expand Up @@ -66,9 +67,7 @@ export default <This>(options: RouterOptions): any => {
throw new APIError(
// Use the correct error code and message based on whether a user is authenticated.
res.locals.user ? 403 : 401,
res.locals.user
? "The authenticated user account does not have the rights necessary to access this endpoint."
: "This endpoint is available to authenticated clients only. Please sign in."
res.locals.user ? FORBIDDEN_ERROR_MESSAGE : UNAUTHORIZED_ERROR_MESSAGE
);
}
} catch (err) {
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/utils/webdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ export const takeScreenshot = async (driver: WebDriver, stock: Stock, dataProvid
);
return `For additional information, see https://${process.env.SUBDOMAIN ? process.env.SUBDOMAIN + "." : ""}${
process.env.DOMAIN
}/api${resourceEndpointPath}/${screenshotID}.`;
// Ensure the user is logged in before accessing the resource API endpoint.
}/login?redirect=${encodeURIComponent(`/api${resourceEndpointPath}/${screenshotID}`)}.`;
} catch (e) {
logger.warn(PREFIX_SELENIUM + chalk.yellowBright(`Unable to take screenshot “${screenshotID}”: ${e}`));
return "";
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/test/liveTestHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import initSupertest, { CallbackHandler, Test } from "supertest";
import { Stock, stockListEndpointPath } from "@rating-tracker/commons";
import { Stock, UNAUTHORIZED_ERROR_MESSAGE, stockListEndpointPath } from "@rating-tracker/commons";
import { TestFunction } from "vitest";

/**
Expand Down Expand Up @@ -34,7 +34,7 @@ export const expectRouteToBePrivate = async (
method = method ?? supertest.get;
const res = await method(route);
expect(res.status).toBe(401);
expect(res.body.message).toMatch("This endpoint is available to authenticated clients only. Please sign in.");
expect(res.body.message).toMatch(UNAUTHORIZED_ERROR_MESSAGE);
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/commons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export * from "./lib/stylebox/Style.js";

export * from "./lib/Currency.js";

export * from "./lib/ErrorMessages.js";

export * from "./lib/MessageType.js";

export * from "./lib/SortableAttribute.js";
Expand Down
22 changes: 22 additions & 0 deletions packages/commons/src/lib/ErrorMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* An error message when fetching from Standard & Poor’s fails because we attempted to fetch a stock that is only
* available for S&P Premium subscribers, which we, sadly, are not :(
*/
export const SP_PREMIUM_STOCK_ERROR_MESSAGE = "This stock’s ESG Score is available for S&P Premium subscribers only.";

/**
* An error message when an unauthenticated user attempts to access a protected endpoint.
*/
export const UNAUTHORIZED_ERROR_MESSAGE = "This endpoint is available to authenticated clients only. Please sign in.";

/**
* An error message when an authenticated user attempts to access a protected endpoint without the necessary access
* rights.
*/
export const FORBIDDEN_ERROR_MESSAGE =
"The authenticated user account does not have the rights necessary to access this endpoint.";

/**
* An error message when a user attempts to register a new account with an email address that is already registered.
*/
export const ALREADY_REGISTERED_ERROR_MESSAGE = "This email address is already registered. Please sign in.";
7 changes: 1 addition & 6 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import router from "./router";

import { CssBaseline } from "@mui/material";
import ThemeProvider from "./theme/ThemeProvider";
import { NotificationSnackbar } from "./components/etc/NotificationSnackbar";
import { ParticleBackground } from "./components/etc/ParticleBackground";
import { NotificationProvider } from "./contexts/NotificationContext";

/**
* The Rating Tracker Application.
Expand All @@ -21,10 +19,7 @@ const App = (): JSX.Element => {
return (
<ThemeProvider>
<CssBaseline />
<NotificationProvider>
{content}
<NotificationSnackbar snackbarProps={{ anchorOrigin: { horizontal: "center", vertical: "bottom" } }} />
</NotificationProvider>
{content}
<ParticleBackground />
</ThemeProvider>
);
Expand Down
13 changes: 5 additions & 8 deletions packages/frontend/src/components/dialogs/AddStock/AddStock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import AddBoxIcon from "@mui/icons-material/AddBox";
import AddLinkIcon from "@mui/icons-material/AddLink";
import AutoGraphIcon from "@mui/icons-material/AutoGraph";
import LinkIcon from "@mui/icons-material/Link";
import axios from "axios";
import axios, { AxiosError } from "axios";
import { baseUrl } from "../../../router";
import {
countryArray,
Expand All @@ -34,6 +34,7 @@ import {
isCountry,
OmitDynamicAttributesStock,
optionalStockValuesNull,
SP_PREMIUM_STOCK_ERROR_MESSAGE,
Stock,
stockEndpointPath,
} from "@rating-tracker/commons";
Expand Down Expand Up @@ -265,16 +266,12 @@ export const AddStock = (props: AddStockProps): JSX.Element => {
params: { ticker: stock.ticker.trim(), noSkip: true },
})
.then(() => {})
.catch((e) => {
if (
(e.response?.data?.message as string | undefined)?.includes(
"This stock’s ESG Score is available for S&P Premium subscribers only"
)
) {
.catch((e: AxiosError<{ message: string }>) => {
if (e.response?.data?.message?.includes(SP_PREMIUM_STOCK_ERROR_MESSAGE)) {
setNotification({
severity: "warning",
title: `Unable to fetch S&P Information for stock “${stock.name}” (${stock.ticker})`,
message: "This stock’s ESG Score is available for S&P Premium subscribers only",
message: e.response?.data?.message,
});
} else {
setErrorNotification(e, "fetching information from S&P");
Expand Down
13 changes: 5 additions & 8 deletions packages/frontend/src/components/dialogs/EditStock/EditStock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import {
fetchSPEndpointPath,
fetchSustainalyticsEndpointPath,
fetchMorningstarEndpointPath,
SP_PREMIUM_STOCK_ERROR_MESSAGE,
} from "@rating-tracker/commons";
import axios from "axios";
import axios, { AxiosError } from "axios";
import { useState } from "react";
import { baseUrl } from "../../../router";
import { useNotification } from "../../../contexts/NotificationContext";
Expand Down Expand Up @@ -247,16 +248,12 @@ export const EditStock = (props: EditStockProps): JSX.Element => {
params: { ticker: props.stock.ticker, noSkip: true, clear },
})
.then(() => {})
.catch((e) => {
if (
(e.response?.data?.message as string | undefined)?.includes(
"This stock’s ESG Score is available for S&P Premium subscribers only"
)
) {
.catch((e: AxiosError<{ message: string }>) => {
if (e.response?.data?.message?.includes(SP_PREMIUM_STOCK_ERROR_MESSAGE)) {
setNotification({
severity: "warning",
title: `Unable to fetch S&P Information for stock “${props.stock.name}” (${props.stock.ticker})`,
message: "This stock’s ESG Score is available for S&P Premium subscribers only",
message: e.response?.data?.message,
});
} else {
setErrorNotification(e, "fetching information from S&P");
Expand Down
9 changes: 4 additions & 5 deletions packages/frontend/src/content/pages/Login/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Box, Button, Card, CardContent, Grid, TextField, Typography } from "@mui/material";
import FingerprintIcon from "@mui/icons-material/Fingerprint";
import QueryStatsIcon from "@mui/icons-material/QueryStats";
import { useState } from "react";
import { useContext, useState } from "react";
import * as SimpleWebAuthnBrowser from "@simplewebauthn/browser";
import axios from "axios";
import { baseUrl } from "../../../router";
import { UserContext, baseUrl } from "../../../router";
import { SwitchSelector } from "../../../components/etc/SwitchSelector";
import { useNavigate } from "react-router";
import { useNotification } from "../../../contexts/NotificationContext";
import { registerEndpointPath, signInEndpointPath } from "@rating-tracker/commons";

Expand All @@ -16,13 +15,13 @@ import { registerEndpointPath, signInEndpointPath } from "@rating-tracker/common
* @returns {JSX.Element} The component.
*/
export const LoginPage = (): JSX.Element => {
const navigate = useNavigate();
const [action, setAction] = useState<"signIn" | "register">("signIn");
const [email, setEmail] = useState<string>("");
const [name, setName] = useState<string>("");
const [emailError, setEmailError] = useState<boolean>(false);
const [nameError, setNameError] = useState<boolean>(false);
const { setNotification, setErrorNotification } = useNotification();
const { refetchUser } = useContext(UserContext);

/**
* Validates the email input field.
Expand Down Expand Up @@ -109,7 +108,7 @@ export const LoginPage = (): JSX.Element => {
title: "Welcome back!",
message: "Authentication successful",
});
navigate("/");
refetchUser(); // After refetching, the user is redirected automatically
} catch (e) {
setErrorNotification(e, "processing authorization response");
}
Expand Down
6 changes: 6 additions & 0 deletions packages/frontend/src/contexts/NotificationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { createContext, useContext, useState } from "react";
import { Notification } from "../types/Notification";
import { AxiosError } from "axios";
import { UNAUTHORIZED_ERROR_MESSAGE } from "@rating-tracker/commons";
import { UserContext } from "../router";

/**
* An object provided by the notification context.
Expand Down Expand Up @@ -33,6 +35,8 @@ const NotificationContext = createContext<NotificationContextType>({} as Notific
*/
export const NotificationProvider = (props: NotificationProviderProps): JSX.Element => {
const [notification, setNotification] = useState<Notification | undefined>(undefined);
const { clearUser } = useContext(UserContext);

const setErrorNotification = (e: AxiosError<{ message: string }>, actionDescription: string) => {
setNotification({
severity: "error",
Expand All @@ -42,6 +46,8 @@ export const NotificationProvider = (props: NotificationProviderProps): JSX.Elem
? `Response Status Code ${e.response.status}: ${e.response.data.message}`
: e.message ?? "No additional information available.",
});
// If the user is no longer authenticated, clear the user information so that they are redirected to the login page
e.response?.status === 401 && e.response?.data?.message === UNAUTHORIZED_ERROR_MESSAGE && clearUser();
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const HeaderUserbox = (): JSX.Element => {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const { setNotification, setErrorNotification } = useContext(NotificationContext);
const { user, clearUser } = useContext(UserContext);
const { user } = useContext(UserContext);

/**
* Sign out the current user.
Expand All @@ -36,13 +36,12 @@ export const HeaderUserbox = (): JSX.Element => {
// Delete the session
await axios.delete(baseUrl + sessionEndpointPath);
// This is only reached if signing out was successful
clearUser();
setNotification({
severity: "success",
title: "See you next time!",
message: "Signed out successfully",
});
navigate("/login");
navigate("/");
} catch (e) {
setErrorNotification(e, "signing out");
}
Expand Down
6 changes: 3 additions & 3 deletions packages/frontend/src/layouts/SidebarLayout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { HeaderUserbox } from "./Userbox";
export const Header = (): JSX.Element => {
const { toggleSidebar } = useContext(SidebarContext);
const theme = useTheme();
const location = useLocation();
const { pathname } = useLocation();

return (
<Box
Expand Down Expand Up @@ -71,15 +71,15 @@ export const Header = (): JSX.Element => {
sx={{
mr: 1,
my: 1,
display: location.pathname.split("/").filter((component) => component).length > 1 ? "inline-block" : "none",
display: pathname.split("/").filter((component) => component).length > 1 ? "inline-block" : "none",
}}
>
<Tooltip arrow title="Go back">
<IconButton
color="primary"
disableRipple
component={NavLink}
to={location.pathname.split("/").slice(0, -1).join("/")}
to={pathname.split("/").slice(0, -1).join("/")}
>
<ArrowBackIcon />
</IconButton>
Expand Down
Loading

0 comments on commit df09863

Please sign in to comment.