From 78b23a89560312fc29ad08e54476cba001f31419 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 23 Nov 2024 17:56:21 -0500 Subject: [PATCH] added resource auth status cards and moved login to reusable login form --- server/routers/badger/verifySession.ts | 3 + server/routers/resource/authWithPassword.ts | 4 +- .../components/ResourceAccessDenied.tsx | 23 + .../components/ResourceAuthPortal.tsx | 453 ++++++++---------- .../components/ResourceNotFound.tsx | 44 +- .../auth/resource/[resourceId]/page.tsx | 23 +- src/app/[orgId]/layout.tsx | 33 ++ .../[orgId]/settings/components/Header.tsx | 14 +- src/app/[orgId]/settings/layout.tsx | 2 +- src/app/auth/login/DashboardLoginForm.tsx | 38 ++ src/app/auth/login/LoginForm.tsx | 165 ------- src/app/auth/login/page.tsx | 6 +- src/components/LoginForm.tsx | 151 ++++++ src/components/ui/tabs.tsx | 2 +- 14 files changed, 507 insertions(+), 454 deletions(-) create mode 100644 src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAccessDenied.tsx create mode 100644 src/app/[orgId]/layout.tsx create mode 100644 src/app/auth/login/DashboardLoginForm.tsx delete mode 100644 src/app/auth/login/LoginForm.tsx create mode 100644 src/components/LoginForm.tsx diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 4ab4d085..f3826607 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -16,6 +16,7 @@ import { and, eq } from "drizzle-orm"; import config from "@server/config"; import { validateResourceSessionToken } from "@server/auth/resource"; import { Resource, roleResources, userResources } from "@server/db/schema"; +import logger from "@server/logger"; const verifyResourceSessionSchema = z.object({ sessions: z.object({ @@ -44,6 +45,8 @@ export async function verifyResourceSession( res: Response, next: NextFunction ): Promise { + logger.debug("Badger sent", req.body); // remove when done testing + const parsedBody = verifyResourceSessionSchema.safeParse(req.body); if (!parsedBody.success) { diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index b450a0ba..ed9a04a0 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -13,6 +13,7 @@ import { createResourceSession, serializeResourceSessionCookie, } from "@server/auth/resource"; +import logger from "@server/logger"; export const authWithPasswordBodySchema = z.object({ password: z.string(), @@ -132,9 +133,10 @@ export async function authWithPassword( resource.fullDomain, secureCookie ); - res.appendHeader("Set-Cookie", cookie); + logger.debug(cookie); // remove after testing + return response(res, { data: null, success: true, diff --git a/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAccessDenied.tsx b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAccessDenied.tsx new file mode 100644 index 00000000..c352bb4d --- /dev/null +++ b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAccessDenied.tsx @@ -0,0 +1,23 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@app/components/ui/card"; + +export default async function ResourceAccessDenied() { + return ( + + + + Access Denied + + + + You're not alowed to access this resource. If this is a mistake, + please contact the administrator. + + + ); +} diff --git a/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx index 7879f3f2..bfb6fa4d 100644 --- a/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx +++ b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx @@ -35,6 +35,8 @@ import { Alert, AlertDescription } from "@app/components/ui/alert"; import { formatAxiosError } from "@app/lib/utils"; import { AxiosResponse } from "axios"; import { LoginResponse } from "@server/routers/auth"; +import ResourceAccessDenied from "./ResourceAccessDenied"; +import LoginForm from "@app/components/LoginForm"; const pinSchema = z.object({ pin: z @@ -49,13 +51,6 @@ const passwordSchema = z.object({ .min(1, { message: "Password must be at least 1 character long" }), }); -const userSchema = z.object({ - email: z.string().email({ message: "Please enter a valid email address" }), - password: z - .string() - .min(1, { message: "Password must be at least 1 character long" }), -}); - type ResourceAuthPortalProps = { methods: { password: boolean; @@ -73,7 +68,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const router = useRouter(); const [passwordError, setPasswordError] = useState(null); - const [userError, setUserError] = useState(null); + const [accessDenied, setAccessDenied] = useState(false); function getDefaultSelectedMethod() { if (props.methods.sso) { @@ -115,14 +110,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { }, }); - const userForm = useForm>({ - resolver: zodResolver(userSchema), - defaultValues: { - email: "", - password: "", - }, - }); - const onPinSubmit = (values: z.infer) => { console.log("PIN authentication", values); // Implement PIN authentication logic here @@ -143,254 +130,218 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { }); }; - const handleSSOAuth = (values: z.infer) => { + async function handleSSOAuth() { console.log("SSO authentication"); - api.post>("/auth/login", { - email: values.email, - password: values.password, - }) - .then((res) => { - // console.log(res) - window.location.href = props.redirect; - }) - .catch((e) => { - console.error(e); - setUserError( - formatAxiosError(e, "An error occurred while logging in"), - ); - }); - }; + await api.get(`/resource/${props.resource.id}`).catch((e) => { + setAccessDenied(true); + }); + + if (!accessDenied) { + window.location.href = props.redirect; + } + } return (
- - - Authentication Required - - {numMethods > 1 - ? `Choose your preferred method to access ${props.resource.name}` - : `You must authenticate to access ${props.resource.name}`} - - - - - {numMethods > 1 && ( - + + + Authentication Required + + {numMethods > 1 + ? `Choose your preferred method to access ${props.resource.name}` + : `You must authenticate to access ${props.resource.name}`} + + + + + {numMethods > 1 && ( + + {props.methods.pincode && ( + + {" "} + PIN + + )} + {props.methods.password && ( + + {" "} + Password + + )} + {props.methods.sso && ( + + {" "} + User + + )} + + )} {props.methods.pincode && ( - - PIN - + +
+ + ( + + + Enter 6-digit + PIN + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + + +
)} {props.methods.password && ( - - {" "} - Password - + +
+ + ( + + + Password + + + + + + + )} + /> + {passwordError && ( + + + {passwordError} + + + )} + + + +
)} {props.methods.sso && ( - - User - + + + await handleSSOAuth() + } + /> + )} -
- )} - {props.methods.pincode && ( - -
- - ( - - - Enter 6-digit PIN - - -
- - - - - - - - - - -
-
- -
- )} - /> - - - -
- )} - {props.methods.password && ( - -
- - ( - - - Password - - - - - - - )} - /> - {passwordError && ( - - - {passwordError} - - - )} - - - -
- )} - {props.methods.sso && ( - -
- - ( - - Email - - - - - - )} - /> - ( - - - Password - - - - - - - )} - /> - {userError && ( - - - {userError} - - - )} - - - -
- )} -
-
-
- {activeTab === "sso" && ( -
-

- Don't have an account?{" "} - - Sign up - -

+ + + + {/* {activeTab === "sso" && ( +
+

+ Don't have an account?{" "} + + Sign up + +

+
+ )} */}
+ ) : ( + )}
); diff --git a/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceNotFound.tsx b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceNotFound.tsx index cd3a5068..d72f5633 100644 --- a/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceNotFound.tsx +++ b/src/app/[orgId]/auth/resource/[resourceId]/components/ResourceNotFound.tsx @@ -1,24 +1,22 @@ -// import { Card, CardContent, CardHeader, CardTitle } from "@app/components/ui/card"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@app/components/ui/card"; -// export default async function ResourceNotFound() { -// return ( -// -// -// {/*
-//
*/} -// -// Invite Not Accepted -// -//
-// {renderBody()} - -// -// {renderFooter()} -// -//
-// ); -// } +export default async function ResourceNotFound() { + return ( + + + + Resource Not Found + + + + The resource you're trying to access does not exist + + + ); +} diff --git a/src/app/[orgId]/auth/resource/[resourceId]/page.tsx b/src/app/[orgId]/auth/resource/[resourceId]/page.tsx index 4a3b80dd..ecfa6a0b 100644 --- a/src/app/[orgId]/auth/resource/[resourceId]/page.tsx +++ b/src/app/[orgId]/auth/resource/[resourceId]/page.tsx @@ -9,6 +9,8 @@ import { authCookieHeader } from "@app/api/cookies"; import { cache } from "react"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; +import ResourceNotFound from "./components/ResourceNotFound"; +import ResourceAccessDenied from "./components/ResourceAccessDenied"; export default async function ResourceAuthPage(props: { params: Promise<{ resourceId: number; orgId: string }>; @@ -35,11 +37,24 @@ export default async function ResourceAuthPage(props: { const user = await getUser(); if (!authInfo) { - return <>Resource not found; + return ( +
+ +
+ ); } + const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso; const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode; + if (!hasAuth) { + return ( +
+ +
+ ); + } + let userIsUnauthorized = false; if (user && authInfo.sso) { let doRedirect = false; @@ -62,7 +77,11 @@ export default async function ResourceAuthPage(props: { } if (userIsUnauthorized && isSSOOnly) { - return <>You do not have access to this resource; + return ( +
+ +
+ ); } return ( diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx new file mode 100644 index 00000000..e02e8af2 --- /dev/null +++ b/src/app/[orgId]/layout.tsx @@ -0,0 +1,33 @@ +import { internal } from "@app/api"; +import { authCookieHeader } from "@app/api/cookies"; +import { GetOrgResponse } from "@server/routers/org"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export default async function OrgLayout(props: { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}) { + const cookie = await authCookieHeader(); + const params = await props.params; + const orgId = params.orgId; + + if (!orgId) { + redirect(`/`); + } + + try { + const getOrg = cache(() => + internal.get>( + `/org/${orgId}`, + cookie, + ), + ); + await getOrg(); + } catch { + redirect(`/`); + } + + return <>{props.children}; +} diff --git a/src/app/[orgId]/settings/components/Header.tsx b/src/app/[orgId]/settings/components/Header.tsx index ae595d92..863424ca 100644 --- a/src/app/[orgId]/settings/components/Header.tsx +++ b/src/app/[orgId]/settings/components/Header.tsx @@ -44,11 +44,11 @@ import { useState } from "react"; type HeaderProps = { name?: string; email: string; - orgName: string; + orgId: string; orgs: ListOrgsResponse["orgs"]; }; -export default function Header({ email, orgName, name, orgs }: HeaderProps) { +export default function Header({ email, orgId, name, orgs }: HeaderProps) { const { toast } = useToast(); const [open, setOpen] = useState(false); @@ -149,7 +149,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) { size="lg" role="combobox" aria-expanded={open} - className="w-full md:w-[200px] h-12 px-3 py-4" + className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral" >
@@ -157,10 +157,10 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) { Organization - {orgName + {orgId ? orgs.find( (org) => - org.name === orgName, + org.orgId === orgId, )?.name : "Select organization..."} @@ -189,7 +189,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) { {/* - - - - )} - /> - ( - - Password - - - - - - )} - /> - {error && ( - - {error} - - )} - - - - - - ); -} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e14fff25..4508d753 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,10 +1,10 @@ -import LoginForm from "@app/app/auth/login/LoginForm"; import { verifySession } from "@app/lib/auth/verifySession"; import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; +import DashboardLoginForm from "./DashboardLoginForm"; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export default async function Page(props: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; @@ -19,7 +19,7 @@ export default async function Page(props: { return ( <> - +

Don't have an account?{" "} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 00000000..ea132bb2 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { LoginResponse } from "@server/routers/auth"; +import { api } from "@app/api"; +import { useRouter } from "next/navigation"; +import { AxiosResponse } from "axios"; +import { formatAxiosError } from "@app/lib/utils"; +import { LockIcon } from "lucide-react"; + +type LoginFormProps = { + redirect?: string; + onLogin?: () => void; +}; + +const formSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + password: z + .string() + .min(8, { message: "Password must be at least 8 characters" }), +}); + +export default function LoginForm({ redirect, onLogin }: LoginFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + async function onSubmit(values: z.infer) { + const { email, password } = values; + + setLoading(true); + + const res = await api + .post>("/auth/login", { + email, + password, + }) + .catch((e) => { + console.error(e); + setError( + formatAxiosError(e, "An error occurred while logging in"), + ); + }); + + if (res && res.status === 200) { + setError(null); + + console.log(res); + + if (res.data?.data?.emailVerificationRequired) { + if (redirect) { + router.push(`/auth/verify-email?redirect=${redirect}`); + } else { + router.push("/auth/verify-email"); + } + return; + } + + if (redirect && redirect.includes("http")) { + window.location.href = redirect; + } else if (redirect) { + router.push(redirect); + } else { + if (onLogin) { + await onLogin(); + } + } + } + + setLoading(false); + } + + return ( +

+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + {error && ( + + {error} + + )} + + + + ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index c1f10d85..7316862e 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<