basic auth portal save

This commit is contained in:
Milo Schwartz 2024-11-23 16:36:07 -05:00
parent f9e0c33368
commit 0b3ca5f999
No known key found for this signature in database
12 changed files with 511 additions and 269 deletions

View file

@ -90,7 +90,7 @@ export async function verifyResourceSession(
return allowed(res); return allowed(res);
} }
const redirectUrl = `${config.app.base_url}/${resource.orgId}/auth/resource/${resource.resourceId}?redirect=${originalRequestURL}`; const redirectUrl = `${config.app.base_url}/${resource.orgId}/auth/resource/${resource.resourceId}?r=${originalRequestURL}`;
if (sso && sessions.session) { if (sso && sessions.session) {
const { session, user } = await validateSessionToken( const { session, user } = await validateSessionToken(

View file

@ -23,12 +23,13 @@ export type GetResourceAuthInfoResponse = {
pincode: boolean; pincode: boolean;
sso: boolean; sso: boolean;
blockAccess: boolean; blockAccess: boolean;
url: string;
}; };
export async function getResourceAuthInfo( export async function getResourceAuthInfo(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction,
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = getResourceAuthInfoSchema.safeParse(req.params); const parsedParams = getResourceAuthInfoSchema.safeParse(req.params);
@ -36,8 +37,8 @@ export async function getResourceAuthInfo(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString() fromError(parsedParams.error).toString(),
) ),
); );
} }
@ -48,11 +49,11 @@ export async function getResourceAuthInfo(
.from(resources) .from(resources)
.leftJoin( .leftJoin(
resourcePincode, resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId) eq(resourcePincode.resourceId, resources.resourceId),
) )
.leftJoin( .leftJoin(
resourcePassword, resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId) eq(resourcePassword.resourceId, resources.resourceId),
) )
.where(eq(resources.resourceId, resourceId)) .where(eq(resources.resourceId, resourceId))
.limit(1); .limit(1);
@ -61,9 +62,11 @@ export async function getResourceAuthInfo(
const pincode = result?.resourcePincode; const pincode = result?.resourcePincode;
const password = result?.resourcePassword; const password = result?.resourcePassword;
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
if (!resource) { if (!resource) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found") createHttpError(HttpCode.NOT_FOUND, "Resource not found"),
); );
} }
@ -75,6 +78,7 @@ export async function getResourceAuthInfo(
pincode: pincode !== null, pincode: pincode !== null,
sso: resource.sso, sso: resource.sso,
blockAccess: resource.blockAccess, blockAccess: resource.blockAccess,
url,
}, },
success: true, success: true,
error: false, error: false,
@ -83,7 +87,10 @@ export async function getResourceAuthInfo(
}); });
} catch (error) { } catch (error) {
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
); );
} }
} }

View file

@ -31,7 +31,7 @@ const updateResourceBodySchema = z
export async function updateResource( export async function updateResource(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction,
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = updateResourceParamsSchema.safeParse(req.params); const parsedParams = updateResourceParamsSchema.safeParse(req.params);
@ -39,8 +39,8 @@ export async function updateResource(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString() fromError(parsedParams.error).toString(),
) ),
); );
} }
@ -49,8 +49,8 @@ export async function updateResource(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString() fromError(parsedBody.error).toString(),
) ),
); );
} }
@ -67,8 +67,8 @@ export async function updateResource(
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found` `Resource with ID ${resourceId} not found`,
) ),
); );
} }
@ -76,16 +76,23 @@ export async function updateResource(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
"Resource does not have a domain" "Resource does not have a domain",
) ),
); );
} }
const fullDomain = `${updateData.subdomain}.${resource[0].orgs.domain}`; const fullDomain = updateData.subdomain
? `${updateData.subdomain}.${resource[0].orgs.domain}`
: undefined;
const updatePayload = {
...updateData,
...(fullDomain && { fullDomain }),
};
const updatedResource = await db const updatedResource = await db
.update(resources) .update(resources)
.set({ ...updateData, fullDomain }) .set(updatePayload)
.where(eq(resources.resourceId, resourceId)) .where(eq(resources.resourceId, resourceId))
.returning(); .returning();
@ -93,8 +100,8 @@ export async function updateResource(
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found` `Resource with ID ${resourceId} not found`,
) ),
); );
} }
@ -108,7 +115,10 @@ export async function updateResource(
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
); );
} }
} }

View file

@ -29,6 +29,12 @@ import {
InputOTPGroup, InputOTPGroup,
InputOTPSlot, InputOTPSlot,
} from "@app/components/ui/input-otp"; } from "@app/components/ui/input-otp";
import api from "@app/api";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/utils";
import { AxiosResponse } from "axios";
import { LoginResponse } from "@server/routers/auth";
const pinSchema = z.object({ const pinSchema = z.object({
pin: z pin: z
@ -40,16 +46,60 @@ const pinSchema = z.object({
const passwordSchema = z.object({ const passwordSchema = z.object({
password: z password: z
.string() .string()
.min(8, { message: "Password must be at least 8 characters long" }), .min(1, { message: "Password must be at least 1 character long" }),
}); });
const userSchema = z.object({ const userSchema = z.object({
email: z.string().email(), email: z.string().email({ message: "Please enter a valid email address" }),
password: z.string().min(1), password: z
.string()
.min(1, { message: "Password must be at least 1 character long" }),
}); });
export default function ResourceAuthPortal() { type ResourceAuthPortalProps = {
const [activeTab, setActiveTab] = useState("pin"); methods: {
password: boolean;
pincode: boolean;
sso: boolean;
};
resource: {
name: string;
id: number;
};
redirect: string;
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const [passwordError, setPasswordError] = useState<string | null>(null);
const [userError, setUserError] = useState<string | null>(null);
function getDefaultSelectedMethod() {
if (props.methods.sso) {
return "sso";
}
if (props.methods.password) {
return "password";
}
if (props.methods.pincode) {
return "pin";
}
}
const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod());
const getColLength = () => {
let colLength = 0;
if (props.methods.pincode) colLength++;
if (props.methods.password) colLength++;
if (props.methods.sso) colLength++;
return colLength;
};
const [numMethods, setNumMethods] = useState(getColLength());
const pinForm = useForm<z.infer<typeof pinSchema>>({ const pinForm = useForm<z.infer<typeof pinSchema>>({
resolver: zodResolver(pinSchema), resolver: zodResolver(pinSchema),
@ -79,178 +129,256 @@ export default function ResourceAuthPortal() {
}; };
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => { const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
console.log("Password authentication", values); api.post(`/resource/${props.resource.id}/auth/password`, {
// Implement password authentication logic here password: values.password,
})
.then((res) => {
window.location.href = props.redirect;
})
.catch((e) => {
console.error(e);
setPasswordError(
formatAxiosError(e, "Failed to authenticate with password"),
);
});
}; };
const handleSSOAuth = () => { const handleSSOAuth = (values: z.infer<typeof userSchema>) => {
console.log("SSO authentication"); console.log("SSO authentication");
// Implement SSO authentication logic here
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"),
);
});
}; };
return ( return (
<div className="w-full max-w-md mx-auto"> <div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Authentication Required</CardTitle> <CardTitle>Authentication Required</CardTitle>
<CardDescription> <CardDescription>
Choose your preferred method {numMethods > 1
? `Choose your preferred method to access ${props.resource.name}`
: `You must authenticate to access ${props.resource.name}`}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3"> {numMethods > 1 && (
<TabsTrigger value="pin"> <TabsList
<Binary className="w-4 h-4 mr-1" /> PIN className={`grid w-full grid-cols-${numMethods}`}
</TabsTrigger> >
<TabsTrigger value="password"> {props.methods.pincode && (
<Key className="w-4 h-4 mr-1" /> Password <TabsTrigger value="pin">
</TabsTrigger> <Binary className="w-4 h-4 mr-1" /> PIN
<TabsTrigger value="sso"> </TabsTrigger>
<User className="w-4 h-4 mr-1" /> User )}
</TabsTrigger> {props.methods.password && (
</TabsList> <TabsTrigger value="password">
<TabsContent value="pin"> <Key className="w-4 h-4 mr-1" />{" "}
<Form {...pinForm}> Password
<form </TabsTrigger>
onSubmit={pinForm.handleSubmit(onPinSubmit)} )}
className="space-y-4" {props.methods.sso && (
> <TabsTrigger value="sso">
<FormField <User className="w-4 h-4 mr-1" /> User
control={pinForm.control} </TabsTrigger>
name="pin" )}
render={({ field }) => ( </TabsList>
<FormItem> )}
<FormLabel> {props.methods.pincode && (
Enter 6-digit PIN <TabsContent value="pin">
</FormLabel> <Form {...pinForm}>
<FormControl> <form
<div className="flex justify-center"> onSubmit={pinForm.handleSubmit(
<InputOTP onPinSubmit,
maxLength={6} )}
className="space-y-4"
>
<FormField
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>
Enter 6-digit PIN
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={
0
}
/>
<InputOTPSlot
index={
1
}
/>
<InputOTPSlot
index={
2
}
/>
<InputOTPSlot
index={
3
}
/>
<InputOTPSlot
index={
4
}
/>
<InputOTPSlot
index={
5
}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
>
<LockIcon className="w-4 h-4 mr-2" />
Login with PIN
</Button>
</form>
</Form>
</TabsContent>
)}
{props.methods.password && (
<TabsContent value="password">
<Form {...passwordForm}>
<form
onSubmit={passwordForm.handleSubmit(
onPasswordSubmit,
)}
className="space-y-4"
>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
Password
</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field} {...field}
> />
<InputOTPGroup className="flex"> </FormControl>
<InputOTPSlot <FormMessage />
index={0} </FormItem>
/> )}
<InputOTPSlot />
index={1} {passwordError && (
/> <Alert variant="destructive">
<InputOTPSlot <AlertDescription>
index={2} {passwordError}
/> </AlertDescription>
<InputOTPSlot </Alert>
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)} )}
/> <Button
<Button type="submit" className="w-full"> type="submit"
<LockIcon className="w-4 h-4 mr-2" /> className="w-full"
Login with PIN >
</Button> <LockIcon className="w-4 h-4 mr-2" />
</form> Login with Password
</Form> </Button>
</TabsContent> </form>
<TabsContent value="password"> </Form>
<Form {...passwordForm}> </TabsContent>
<form )}
onSubmit={passwordForm.handleSubmit( {props.methods.sso && (
onPasswordSubmit, <TabsContent value="sso">
)} <Form {...userForm}>
className="space-y-4" <form
> onSubmit={userForm.handleSubmit(
<FormField handleSSOAuth,
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Enter password"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)} )}
/> className="space-y-4"
<Button type="submit" className="w-full"> >
<LockIcon className="w-4 h-4 mr-2" /> <FormField
Login with Password control={userForm.control}
</Button> name="email"
</form> render={({ field }) => (
</Form> <FormItem>
</TabsContent> <FormLabel>Email</FormLabel>
<TabsContent value="sso"> <FormControl>
<Form {...userForm}> <Input
<form placeholder="Enter email"
onSubmit={userForm.handleSubmit( type="email"
(values) => { {...field}
console.log( />
"User authentication", </FormControl>
values, <FormMessage />
); </FormItem>
// Implement user authentication logic here )}
}, />
)} <FormField
className="space-y-4" control={userForm.control}
> name="password"
<FormField render={({ field }) => (
control={userForm.control} <FormItem>
name="email" <FormLabel>
render={({ field }) => ( Password
<FormItem> </FormLabel>
<FormLabel>Email</FormLabel> <FormControl>
<FormControl> <Input
<Input placeholder="Enter password"
placeholder="Enter email" type="password"
type="email" {...field}
{...field} />
/> </FormControl>
</FormControl> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
/>
{userError && (
<Alert variant="destructive">
<AlertDescription>
{userError}
</AlertDescription>
</Alert>
)} )}
/> <Button
<FormField type="submit"
control={userForm.control} className="w-full"
name="password" >
render={({ field }) => ( <LockIcon className="w-4 h-4 mr-2" />
<FormItem> Login as User
<FormLabel>Password</FormLabel> </Button>
<FormControl> </form>
<Input </Form>
placeholder="Enter password" </TabsContent>
type="password" )}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
<LockIcon className="w-4 h-4 mr-2" />
Login as User
</Button>
</form>
</Form>
</TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -0,0 +1,24 @@
// import { Card, CardContent, 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>
// );
// }

View file

@ -1,16 +1,85 @@
import {
GetResourceAuthInfoResponse,
GetResourceResponse,
} from "@server/routers/resource";
import ResourceAuthPortal from "./components/ResourceAuthPortal"; import ResourceAuthPortal from "./components/ResourceAuthPortal";
import { internal } from "@app/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
export default async function ResourceAuthPage(props: { export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number; orgId: string }>; params: Promise<{ resourceId: number; orgId: string }>;
searchParams: Promise<{ r: string }>;
}) { }) {
const params = await props.params; const params = await props.params;
const searchParams = await props.searchParams;
console.log(params); let authInfo: GetResourceAuthInfoResponse | undefined;
try {
const res = await internal.get<
AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${params.resourceId}/auth`, await authCookieHeader());
if (res && res.status === 200) {
authInfo = res.data.data;
}
} catch (e) {
console.error(e);
console.log("resource not found");
}
const getUser = cache(verifySession);
const user = await getUser();
if (!authInfo) {
return <>Resource not found</>;
}
const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode;
let userIsUnauthorized = false;
if (user && authInfo.sso) {
let doRedirect = false;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
await authCookieHeader(),
);
console.log(res.data);
doRedirect = true;
} catch (e) {
console.error(e);
userIsUnauthorized = true;
}
if (doRedirect) {
redirect(searchParams.r || authInfo.url);
}
}
if (userIsUnauthorized && isSSOOnly) {
return <>You do not have access to this resource</>;
}
return ( return (
<> <>
<div className="p-3 md:mt-32"> <div className="w-full max-w-md mx-auto p-3 md:mt-32">
<ResourceAuthPortal /> <ResourceAuthPortal
methods={{
password: authInfo.password,
pincode: authInfo.pincode,
sso: authInfo.sso && !userIsUnauthorized,
}}
resource={{
name: authInfo.resourceName,
id: authInfo.resourceId,
}}
redirect={searchParams.r || authInfo.url}
/>
</div> </div>
</> </>
); );

View file

@ -415,7 +415,7 @@ export default function ResourceAuthenticationPage() {
<section className="space-y-8"> <section className="space-y-8">
<SettingsSectionTitle <SettingsSectionTitle
title="Authentication Methods" title="Authentication Methods"
description="You can also anyone to access the resource via the below methods" description="Allow anyone to access the resource via the below methods"
size="1xl" size="1xl"
/> />

View file

@ -39,66 +39,64 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
}; };
return ( return (
<Card className="shadow-none"> <Alert>
<Alert> <InfoIcon className="h-4 w-4" />
<InfoIcon className="h-4 w-4" /> <AlertTitle className="font-semibold">
<AlertTitle className="font-semibold"> Resource Information
Resource Information </AlertTitle>
</AlertTitle> <AlertDescription className="mt-3">
<AlertDescription className="mt-3"> <div className="space-y-3">
<div className="space-y-3"> <div>
<div> {authInfo.password ||
{authInfo.password || authInfo.pincode ||
authInfo.pincode || authInfo.sso ? (
authInfo.sso ? ( <div className="flex items-center space-x-2 text-green-500">
<div className="flex items-center space-x-2 text-green-500"> <ShieldCheck />
<ShieldCheck /> <span>
<span> This resource is protected with at least one
This resource is protected with at least auth method.
one auth method.
</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff />
<span>
This resource is not protected with any
auth method. Anyone can access this
resource.
</span>
</div>
)}
</div>
<div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline truncate"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<CopyIcon className="h-4 w-4" />
)}
<span className="ml-2">
{copied ? "Copied!" : "Copy"}
</span> </span>
</Button> </div>
</div> ) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff />
<span>
This resource is not protected with any auth
method. Anyone can access this resource.
</span>
</div>
)}
</div>
{/* <p className="mt-3"> <div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline truncate"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<CopyIcon className="h-4 w-4" />
)}
<span className="ml-2">
{copied ? "Copied!" : "Copy"}
</span>
</Button>
</div>
{/* <p className="mt-3">
To create a proxy to your private services,{" "} To create a proxy to your private services,{" "}
<Link <Link
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`} href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
@ -108,9 +106,8 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</Link>{" "} </Link>{" "}
to this resource to this resource
</p> */} </p> */}
</div> </div>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</Card>
); );
} }

View file

@ -99,12 +99,12 @@ export default function InviteStatusCard({
<div className="p-3 md:mt-32 flex items-center justify-center"> <div className="p-3 md:mt-32 flex items-center justify-center">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4"> {/* <div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
<XCircle <XCircle
className="w-10 h-10 text-red-600" className="w-10 h-10 text-red-600"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div> */}
<CardTitle className="text-center text-2xl font-bold"> <CardTitle className="text-center text-2xl font-bold">
Invite Not Accepted Invite Not Accepted
</CardTitle> </CardTitle>

View file

@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{ {
variants: { variants: {
variant: { variant: {

View file

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground",
className className,
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = "Card";
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -37,12 +37,12 @@ const CardTitle = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-2xl font-semibold leading-none tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -53,16 +53,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -73,7 +73,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)} className={cn("flex items-center p-6 pt-0", className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
className className
)} )}
{...props} {...props}