feat: tooltip, password hardening
This commit is contained in:
parent
547594294a
commit
da062bcc27
14 changed files with 468 additions and 164 deletions
|
|
@ -7,4 +7,8 @@
|
|||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"sortImports": {
|
||||
"order": "asc",
|
||||
"ignoreCase": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { createClient, type GenericCtx } from '@convex-dev/better-auth';
|
||||
import { convex } from '@convex-dev/better-auth/plugins';
|
||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
|
||||
import { haveIBeenPwned } from 'better-auth/plugins';
|
||||
|
||||
import { components } from './_generated/api';
|
||||
import { DataModel } from './_generated/dataModel';
|
||||
import { query } from './_generated/server';
|
||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
|
||||
import authConfig from './auth.config';
|
||||
import authSchema from './betterAuth/schema';
|
||||
|
||||
|
|
@ -15,20 +17,23 @@ export const authComponent = createClient<DataModel, typeof authSchema>(
|
|||
local: {
|
||||
schema: authSchema,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createAuthOptions = (ctx: GenericCtx<DataModel>): BetterAuthOptions => {
|
||||
export const createAuthOptions = (
|
||||
ctx: GenericCtx<DataModel>,
|
||||
): BetterAuthOptions => {
|
||||
return {
|
||||
baseURL: siteUrl,
|
||||
database: authComponent.adapter(ctx),
|
||||
emailAndPassword: {
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
autoSignIn: true,
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
plugins: [
|
||||
convex({ authConfig }),
|
||||
],
|
||||
plugins: [convex({ authConfig }), haveIBeenPwned()],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
{
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
},
|
||||
"AuthPage": {
|
||||
"SignUpTitle": "Sign up",
|
||||
"SignInTitle": "Sign in",
|
||||
"SignUpLink": "Sign in",
|
||||
"SignInLink": "Sign up",
|
||||
"NameLabel": "Name",
|
||||
"NamePlaceholder": "John Doe",
|
||||
"EmailLabel": "Email",
|
||||
"EmailPlaceholder": "email@example.com",
|
||||
"PasswordLabel": "Password",
|
||||
"PasswordPlaceholder": "******",
|
||||
"HidePasswordTooltip": "Hide password",
|
||||
"ShowPasswordTooltip": "Show password",
|
||||
"Submit": "Submit",
|
||||
"RegisteredSuccessfully": "Registered successfully"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
{
|
||||
"HomePage": {
|
||||
"title": "Witaj świecie!"
|
||||
},
|
||||
"AuthPage": {
|
||||
"SignUpTitle": "Zarejestruj się",
|
||||
"SignInTitle": "Zaloguj się",
|
||||
"SignUpLink": "Zaloguj się",
|
||||
"SignInLink": "Zarejestruj się",
|
||||
"NameLabel": "Imię i nazwisko",
|
||||
"NamePlaceholder": "Jan Kowalski",
|
||||
"EmailLabel": "Email",
|
||||
"EmailPlaceholder": "email@example.com",
|
||||
"PasswordLabel": "Hasło",
|
||||
"PasswordPlaceholder": "******",
|
||||
"HidePasswordTooltip": "Ukryj hasło",
|
||||
"ShowPasswordTooltip": "Pokaż hasło",
|
||||
"Submit": "Wyślij",
|
||||
"RegisteredSuccessfully": "Zarejestrowano pomyślnie"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ThemeProvider } from '@wrksz/themes/next';
|
|||
import { Geist_Mono } from 'next/font/google';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '../components/ui/tooltip';
|
||||
|
||||
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' });
|
||||
|
||||
|
|
@ -25,12 +26,14 @@ export default function RootLayout({
|
|||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
||||
<NextIntlClientProvider>
|
||||
<main>{children}</main>
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
||||
<NextIntlClientProvider>
|
||||
<main>{children}</main>
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
5
src/app/sign-in/page.tsx
Normal file
5
src/app/sign-in/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AuthForm } from '@/components/auth/AuthForm';
|
||||
|
||||
export default function SignInPage() {
|
||||
return <AuthForm mode="sign-in" />;
|
||||
}
|
||||
|
|
@ -1,118 +1,5 @@
|
|||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { deafultPasswordValidator } from '@/constants';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod/v4';
|
||||
import { AuthForm } from '@/components/auth/AuthForm';
|
||||
|
||||
const signUpSchema = z.object({
|
||||
email: z.email(),
|
||||
password: deafultPasswordValidator(),
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export default function SignUp() {
|
||||
const form = useForm<z.infer<typeof signUpSchema>>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
async function onSubmit(values: z.infer<typeof signUpSchema>) {
|
||||
const email = values.email;
|
||||
const password = values.password;
|
||||
const name = values.name;
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email, // user email address
|
||||
password, // user password -> min 8 characters by default
|
||||
name,
|
||||
callbackURL: '/dashboard', // A URL to redirect to after the user verifies their email (optional)
|
||||
},
|
||||
{
|
||||
onRequest: (ctx) => {
|
||||
//show loading
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
//redirect to the dashboard or sign in page
|
||||
toast('Registered succesfully');
|
||||
},
|
||||
onError: (ctx) => {
|
||||
// display the error message
|
||||
toast(ctx.error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return (
|
||||
<form id="form-signup" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-name">Name</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-name"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="John Doe"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-email">Email</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="email@example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-password">Password</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-password"
|
||||
type="password"
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
placeholder="******"
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" form="form-signup">
|
||||
Submit
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
export default function SignUpPage() {
|
||||
return <AuthForm mode="sign-up" />;
|
||||
}
|
||||
|
|
|
|||
221
src/components/auth/AuthForm.tsx
Normal file
221
src/components/auth/AuthForm.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { EyeIcon, EyeOff } from '@hugeicons/core-free-icons';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { defaultPasswordValidator } from '@/constants';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/src/components/ui/card';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@/src/components/ui/input-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/src/components/ui/tooltip';
|
||||
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.email(),
|
||||
password: defaultPasswordValidator(),
|
||||
});
|
||||
|
||||
const signUpSchema = z.object({
|
||||
email: z.email(),
|
||||
password: defaultPasswordValidator(),
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
type SignInValues = z.infer<typeof signInSchema>;
|
||||
type SignUpValues = z.infer<typeof signUpSchema>;
|
||||
|
||||
interface AuthFormProps {
|
||||
mode: 'sign-up' | 'sign-in';
|
||||
redirectPath?: string;
|
||||
}
|
||||
|
||||
export function AuthForm({ mode, redirectPath = '/dashboard' }: AuthFormProps) {
|
||||
const t = useTranslations('AuthPage');
|
||||
const isSignUp = mode === 'sign-up';
|
||||
const [passwordVisible, setPasswordVisible] = useState<boolean>();
|
||||
|
||||
const form = useForm<SignInValues | SignUpValues>({
|
||||
resolver: zodResolver(isSignUp ? signUpSchema : signInSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
async function onSubmit(values: SignInValues | SignUpValues) {
|
||||
const email = values.email;
|
||||
const password = values.password;
|
||||
const name = 'name' in values ? values.name : undefined;
|
||||
|
||||
if (isSignUp) {
|
||||
const result = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: name!,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast(result.error.message);
|
||||
} else {
|
||||
toast(t('RegisteredSuccessfully'));
|
||||
}
|
||||
} else {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast(result.error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{isSignUp ? t('SignUpTitle') : t('SignInTitle')}</CardTitle>
|
||||
<CardAction>
|
||||
<Button variant={'link'}>
|
||||
<Link href={isSignUp ? '/sign-in' : '/sign-up'}>
|
||||
{isSignUp ? t('SignUpLink') : t('SignInLink')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="form-auth" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
{isSignUp && (
|
||||
<Controller
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-name">
|
||||
{t('NameLabel')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-name"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder={t('NamePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-email">
|
||||
{t('EmailLabel')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id="form-email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder={t('EmailPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-password">
|
||||
{t('PasswordLabel')}
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
id="form-password"
|
||||
type={passwordVisible ? 'text' : 'password'}
|
||||
aria-invalid={fieldState.invalid}
|
||||
autoComplete="off"
|
||||
placeholder={t('PasswordPlaceholder')}
|
||||
/>
|
||||
<InputGroupAddon
|
||||
align={'inline-end'}
|
||||
onClick={() => setPasswordVisible(!passwordVisible)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{passwordVisible ? (
|
||||
<HugeiconsIcon icon={EyeOff} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={EyeIcon} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{passwordVisible ? (
|
||||
<p>{t('HidePasswordTooltip')}</p>
|
||||
) : (
|
||||
<p>{t('ShowPasswordTooltip')}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" form="form-auth" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner /> : t('Submit')}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
11
src/components/ui/spinner.tsx
Normal file
11
src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Loading03Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<HugeiconsIcon icon={Loading03Icon} strokeWidth={2} role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
|
@ -2,13 +2,8 @@ import z from 'zod/v4';
|
|||
|
||||
export const supportedLocales = ['en', 'pl'];
|
||||
|
||||
export function deafultPasswordValidator() {
|
||||
export function defaultPasswordValidator() {
|
||||
return z
|
||||
.string()
|
||||
.refine((val) => val.length >= 8, { error: 'Hasło jest za krótkie' })
|
||||
.refine((val) => /[A-Z]/.test(val), { error: 'Wymagana wielka litera' })
|
||||
.refine((val) => /[0-9]/.test(val), { error: 'Wymagana cyfra' })
|
||||
.refine((val) => /[^A-Za-z0-9]/.test(val), {
|
||||
error: 'Wymagany znak specjalny',
|
||||
});
|
||||
.refine((val) => val.length >= 8, { error: 'Hasło jest za krótkie' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
// Add your hooks here
|
||||
|
|
@ -1,29 +1,11 @@
|
|||
export const routes = {
|
||||
home: '/',
|
||||
signIn: '/sign-in',
|
||||
signUp: '/sign-up',
|
||||
dashboard: '/dashboard',
|
||||
settings: '/settings',
|
||||
} as const;
|
||||
|
||||
export const publicRoutes = [
|
||||
routes.home,
|
||||
routes.signIn,
|
||||
routes.signUp,
|
||||
'/pricing',
|
||||
] as const;
|
||||
|
||||
export const authRoutes = [routes.signIn, routes.signUp] as const;
|
||||
|
||||
export function matchesRoute(pathname: string, route: string) {
|
||||
if (route === '/') return pathname === '/';
|
||||
return pathname === route || pathname.startsWith(`${route}/`);
|
||||
}
|
||||
|
||||
export function isPublicRoute(pathname: string) {
|
||||
return publicRoutes.some((route) => matchesRoute(pathname, route));
|
||||
}
|
||||
|
||||
export function isAuthRoute(pathname: string) {
|
||||
return authRoutes.some((route) => matchesRoute(pathname, route));
|
||||
}
|
||||
public: {
|
||||
home: '/',
|
||||
signIn: '/sign-in',
|
||||
signUp: '/sign-up',
|
||||
},
|
||||
private: {
|
||||
dashboard: '/dashboard',
|
||||
settings: '/settings',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue