diff --git a/apps/nowcasting-app/components/helpers/utils.ts b/apps/nowcasting-app/components/helpers/utils.ts index 4ef581da..fbce43db 100644 --- a/apps/nowcasting-app/components/helpers/utils.ts +++ b/apps/nowcasting-app/components/helpers/utils.ts @@ -406,11 +406,20 @@ export const axiosFetcherAuth = async (url: RequestInfo | URL) => { try { const response = await fetch("/api/get_token"); if (!response.ok) { - // Ensure SWR sees an error if token retrieval fails - const text = await response.text().catch(() => ""); - throw new Error( - `Failed to get access token (${response.status}): ${text || response.statusText}` - ); + const body = await response.json().catch((parseErr) => { + Sentry.captureException(parseErr, { tags: { error: "get_token_parse_failure" } }); + return {}; + }); + if (body.error === "trial_expired") { + Router.push(`/expired${body.email ? `?email=${encodeURIComponent(body.email)}` : ""}`); + throw new Error("trial_expired"); + } + if (body.error === "access_denied") { + Router.push(`/auth/denied?error_description=${encodeURIComponent(body.message)}`); + throw new Error("access_denied"); + } + const text = body.message || response.statusText; + throw new Error(`Failed to get access token (${response.status}): ${text}`); } const { accessToken } = await response.json(); diff --git a/apps/nowcasting-app/components/layout/header/index.tsx b/apps/nowcasting-app/components/layout/header/index.tsx index 1bbb5747..d2f8ec7a 100644 --- a/apps/nowcasting-app/components/layout/header/index.tsx +++ b/apps/nowcasting-app/components/layout/header/index.tsx @@ -4,7 +4,7 @@ import { OCFlogo } from "../../icons/logo"; import Link from "next/link"; import { Menu } from "@headlessui/react"; import { getViewTitle, VIEWS } from "../../../constant"; -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, ReactNode, SetStateAction } from "react"; import { ExternalLinkIcon } from "../../icons/icons"; import { CombinedData } from "../../types"; @@ -82,13 +82,15 @@ type HeaderProps = { setView: Dispatch>; isLoggedIn?: boolean; combinedData?: CombinedData | null; + children?: ReactNode; }; const Header: React.FC = ({ view, setView, isLoggedIn = true, - combinedData = null + combinedData = null, + children }) => { return (
@@ -151,6 +153,7 @@ const Header: React.FC = ({
{isLoggedIn && } + {children}
diff --git a/apps/nowcasting-app/pages/api/auth/[...auth0].ts b/apps/nowcasting-app/pages/api/auth/[...auth0].ts index 752c0094..214c2241 100644 --- a/apps/nowcasting-app/pages/api/auth/[...auth0].ts +++ b/apps/nowcasting-app/pages/api/auth/[...auth0].ts @@ -51,7 +51,8 @@ export default wrapApiHandlerWithSentry( redirect_uri: redirectUri, audience: process.env.NEXT_PUBLIC_AUTH0_API_AUDIENCE || "https://api.nowcasting.io/", // Production fallback scope: "openid profile email offline_access", - useRefreshTokens: true + useRefreshTokens: true, + ...(req.query.prompt === "login" && { prompt: "login" }) }, returnTo: returnTo }); @@ -62,7 +63,7 @@ export default wrapApiHandlerWithSentry( async logout(req: NextApiRequest, res: NextApiResponse) { setUser(null); - const returnTo = req.query.redirectToLogin ? "/api/auth/login" : "/logout"; + const returnTo = req.query.redirectToLogin ? "/api/auth/login?prompt=login" : "/logout"; await handleLogout(req, res, { returnTo }); diff --git a/apps/nowcasting-app/pages/api/get_token.ts b/apps/nowcasting-app/pages/api/get_token.ts index 9eba2174..e19eb002 100644 --- a/apps/nowcasting-app/pages/api/get_token.ts +++ b/apps/nowcasting-app/pages/api/get_token.ts @@ -1,4 +1,4 @@ -import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0"; +import { getAccessToken, getSession, withApiAuthRequired } from "@auth0/nextjs-auth0"; import { NextApiRequest, NextApiResponse } from "next"; export default process.env.NEXT_PUBLIC_DEV_MODE === "true" @@ -19,8 +19,16 @@ export default process.env.NEXT_PUBLIC_DEV_MODE === "true" } try { const accessToken = await getAccessToken(req, res); + const session = await getSession(req, res); + const trialEndsAt = session?.user?.trial_ends_at; + if (trialEndsAt && new Date(trialEndsAt) < new Date()) { + return res.status(403).json({ error: "trial_expired", email: session?.user?.email }); + } res.status(200).json(accessToken); } catch (error: any) { + if (error.message?.includes("access_denied")) { + return res.status(403).json({ error: "access_denied", message: error.message }); + } res.status(error.status || 400).end(error.message); } }); diff --git a/apps/nowcasting-app/pages/auth/denied.tsx b/apps/nowcasting-app/pages/auth/denied.tsx index f57cd273..ee85c8de 100644 --- a/apps/nowcasting-app/pages/auth/denied.tsx +++ b/apps/nowcasting-app/pages/auth/denied.tsx @@ -21,10 +21,14 @@ const AccessDeniedPage = ({ query }: { query: any }) => { document.cookie = "visited_access_denied=true; path=/; max-age=3600"; // 1 hour } }, []); + const isEmailVerification = errorDescription?.includes("Email not verified"); + return ( <> - Email Verification | Quartz Solar UI + + {isEmailVerification ? "Email Verification" : "Access Denied"} | Quartz Solar UI + @@ -32,35 +36,61 @@ const AccessDeniedPage = ({ query }: { query: any }) => {
{}} isLoggedIn={false} />
-

- Nearly there. -

-

Please check your email for a verification link.

- {hasVisited && - errorDescription && - errorDescription?.includes("Email not verified.") && ( -

- Hmm, it seems like you haven't verified your email address yet.
- Please check your inbox for a verification link. + {isEmailVerification ? ( + <> +

+ Nearly there. +

+

Please check your email for a verification link.

+ {hasVisited && ( +

+ Hmm, it seems like you haven't verified your email address yet.
+ Please check your inbox for a verification link. +

+ )} + + I've verified, continue + +

+ If you think this is a mistake, please contact the Quartz Solar team at{" "} + + support@quartz.solar + + . +

+ + ) : ( + <> +

+ Access denied. +

+

+ Your account does not currently have access to Quartz Solar. +

+

+ If you think this is a mistake, please contact us at{" "} + + support@quartz.solar + + .

- )} - - I've verified, continue - -

- If you think this is a mistake, you have verified your email, and should have access, - please contact the Quartz Solar team at{" "} - - support@quartz.solar - - . -

+ + Sign out + + + )}
diff --git a/apps/nowcasting-app/pages/expired.tsx b/apps/nowcasting-app/pages/expired.tsx index 171e5738..4efbdcf8 100644 --- a/apps/nowcasting-app/pages/expired.tsx +++ b/apps/nowcasting-app/pages/expired.tsx @@ -5,6 +5,7 @@ import Head from "next/head"; import { useSearchParams } from "next/navigation"; import Header from "../components/layout/header"; import { VIEWS } from "../constant"; +import React from "react"; const TrialExpiredPage = () => { const queryParams = useSearchParams(); @@ -17,7 +18,13 @@ const TrialExpiredPage = () => {
-
{}} isLoggedIn={false} /> +
{}} isLoggedIn={false}> + + + Sign out → + + +

Your Quartz Solar trial has now ended.

diff --git a/apps/nowcasting-app/styles/globals.css b/apps/nowcasting-app/styles/globals.css index 908d9970..cf5d9d18 100644 --- a/apps/nowcasting-app/styles/globals.css +++ b/apps/nowcasting-app/styles/globals.css @@ -11,6 +11,10 @@ @apply inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-mapbox-black bg-ocf-yellow hover:bg-ocf-yellow-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ocf-delta-500; } +.btn.btn-outline { + @apply inline-flex items-center justify-center px-4 py-2 border border-ocf-gray-700 rounded-full shadow-sm text-sm font-medium text-ocf-gray-700 bg-transparent hover:text-ocf-yellow hover:border-ocf-yellow focus:outline-none; +} + .pv-map a.mapboxgl-ctrl-logo, .delta-map a.mapboxgl-ctrl-logo { margin-left: 0.5rem; }