added resource auth status cards and moved login to reusable login form

This commit is contained in:
Milo Schwartz 2024-11-23 17:56:21 -05:00
parent 795c144e1e
commit 78b23a8956
No known key found for this signature in database
14 changed files with 507 additions and 454 deletions

View file

@ -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<any> {
logger.debug("Badger sent", req.body); // remove when done testing
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
if (!parsedBody.success) {

View file

@ -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<null>(res, {
data: null,
success: true,

View file

@ -0,0 +1,23 @@
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@app/components/ui/card";
export default async function ResourceAccessDenied() {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
Access Denied
</CardTitle>
</CardHeader>
<CardContent>
You're not alowed to access this resource. If this is a mistake,
please contact the administrator.
</CardContent>
</Card>
);
}

View file

@ -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<string | null>(null);
const [userError, setUserError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState<boolean>(false);
function getDefaultSelectedMethod() {
if (props.methods.sso) {
@ -115,14 +110,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
},
});
const userForm = useForm<z.infer<typeof userSchema>>({
resolver: zodResolver(userSchema),
defaultValues: {
email: "",
password: "",
},
});
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
console.log("PIN authentication", values);
// Implement PIN authentication logic here
@ -143,26 +130,21 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
});
};
const handleSSOAuth = (values: z.infer<typeof userSchema>) => {
async function handleSSOAuth() {
console.log("SSO authentication");
api.post<AxiosResponse<LoginResponse>>("/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 (
<div>
{!accessDenied ? (
<div>
<Card>
<CardHeader>
@ -174,14 +156,18 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
>
{numMethods > 1 && (
<TabsList
className={`grid w-full grid-cols-${numMethods}`}
>
{props.methods.pincode && (
<TabsTrigger value="pin">
<Binary className="w-4 h-4 mr-1" /> PIN
<Binary className="w-4 h-4 mr-1" />{" "}
PIN
</TabsTrigger>
)}
{props.methods.password && (
@ -192,13 +178,17 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
)}
{props.methods.sso && (
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" /> User
<User className="w-4 h-4 mr-1" />{" "}
User
</TabsTrigger>
)}
</TabsList>
)}
{props.methods.pincode && (
<TabsContent value="pin">
<TabsContent
value="pin"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<Form {...pinForm}>
<form
onSubmit={pinForm.handleSubmit(
@ -212,12 +202,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
Enter 6-digit PIN
Enter 6-digit
PIN
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
maxLength={
6
}
{...field}
>
<InputOTPGroup className="flex">
@ -271,7 +264,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</TabsContent>
)}
{props.methods.password && (
<TabsContent value="password">
<TabsContent
value="password"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(
@ -280,7 +276,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className="space-y-4"
>
<FormField
control={passwordForm.control}
control={
passwordForm.control
}
name="password"
render={({ field }) => (
<FormItem>
@ -317,72 +315,21 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</TabsContent>
)}
{props.methods.sso && (
<TabsContent value="sso">
<Form {...userForm}>
<form
onSubmit={userForm.handleSubmit(
handleSSOAuth,
)}
className="space-y-4"
<TabsContent
value="sso"
className={`${numMethods <= 1 ? "mt-0" : ""}`}
>
<FormField
control={userForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter email"
type="email"
{...field}
<LoginForm
onLogin={async () =>
await handleSSOAuth()
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={userForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
Password
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{userError && (
<Alert variant="destructive">
<AlertDescription>
{userError}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
>
<LockIcon className="w-4 h-4 mr-2" />
Login as User
</Button>
</form>
</Form>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
{activeTab === "sso" && (
{/* {activeTab === "sso" && (
<div className="flex justify-center mt-4">
<p className="text-sm text-muted-foreground">
Don't have an account?{" "}
@ -391,6 +338,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</a>
</p>
</div>
)} */}
</div>
) : (
<ResourceAccessDenied />
)}
</div>
);

View file

@ -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 (
// <Card className="w-full max-w-md">
// <CardHeader>
// {/* <div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
// <XCircle
// className="w-10 h-10 text-red-600"
// aria-hidden="true"
// />
// </div> */}
// <CardTitle className="text-center text-2xl font-bold">
// Invite Not Accepted
// </CardTitle>
// </CardHeader>
// <CardContent>{renderBody()}</CardContent>
// <CardFooter className="flex justify-center space-x-4">
// {renderFooter()}
// </CardFooter>
// </Card>
// );
// }
export default async function ResourceNotFound() {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center text-2xl font-bold">
Resource Not Found
</CardTitle>
</CardHeader>
<CardContent>
The resource you're trying to access does not exist
</CardContent>
</Card>
);
}

View file

@ -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 (
<div className="w-full max-w-md mx-auto p-3 md:mt-32">
<ResourceNotFound />
</div>
);
}
const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso;
const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode;
if (!hasAuth) {
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32">
<ResourceAccessDenied />
</div>
);
}
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 (
<div className="w-full max-w-md mx-auto p-3 md:mt-32">
<ResourceAccessDenied />
</div>
);
}
return (

View file

@ -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<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
cookie,
),
);
await getOrg();
} catch {
redirect(`/`);
}
return <>{props.children}</>;
}

View file

@ -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"
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start">
@ -157,10 +157,10 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
Organization
</span>
<span className="text-sm text-muted-foreground">
{orgName
{orgId
? orgs.find(
(org) =>
org.name === orgName,
org.orgId === orgId,
)?.name
: "Select organization..."}
</span>
@ -189,7 +189,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
<Check
className={cn(
"mr-2 h-4 w-4",
orgName === org.name
orgId === org.orgId
? "opacity-100"
: "opacity-0",
)}
@ -204,7 +204,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
</Popover>
{/* <Select
defaultValue={orgName}
defaultValue={orgId}
onValueChange={(val) => {
router.push(`/${val}/settings`);
}}

View file

@ -95,7 +95,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<div className="container mx-auto flex flex-col content-between gap-4 ">
<Header
email={user.email}
orgName={params.orgId}
orgId={params.orgId}
orgs={orgs}
/>
<TopbarNav items={topNavItems} orgId={params.orgId} />

View file

@ -0,0 +1,38 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import LoginForm from "@app/components/LoginForm";
import { useRouter } from "next/navigation";
type DashboardLoginFormProps = {
redirect?: string;
};
export default function DashboardLoginForm({
redirect,
}: DashboardLoginFormProps) {
const router = useRouter();
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your dashboard
</CardDescription>
</CardHeader>
<CardContent>
<LoginForm
redirect={redirect}
onLogin={() => router.push("/")}
/>
</CardContent>
</Card>
);
}

View file

@ -1,165 +0,0 @@
"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;
};
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 }: LoginFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
setLoading(true);
const res = await api
.post<AxiosResponse<LoginResponse>>("/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 {
router.push("/");
}
}
setLoading(false);
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your dashboard
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
loading={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Login
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View file

@ -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 (
<>
<LoginForm redirect={searchParams.redirect as string} />
<DashboardLoginForm redirect={searchParams.redirect as string} />
<p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "}

View file

@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
setLoading(true);
const res = await api
.post<AxiosResponse<LoginResponse>>("/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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" loading={loading}>
<LockIcon className="w-4 h-4 mr-2" />
Login
</Button>
</form>
</Form>
);
}

View file

@ -44,7 +44,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-6 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}