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",
|
"trailingComma": "all",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
|
"sortImports": {
|
||||||
|
"order": "asc",
|
||||||
|
"ignoreCase": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { createClient, type GenericCtx } from '@convex-dev/better-auth';
|
import { createClient, type GenericCtx } from '@convex-dev/better-auth';
|
||||||
import { convex } from '@convex-dev/better-auth/plugins';
|
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 { components } from './_generated/api';
|
||||||
import { DataModel } from './_generated/dataModel';
|
import { DataModel } from './_generated/dataModel';
|
||||||
import { query } from './_generated/server';
|
import { query } from './_generated/server';
|
||||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
|
|
||||||
import authConfig from './auth.config';
|
import authConfig from './auth.config';
|
||||||
import authSchema from './betterAuth/schema';
|
import authSchema from './betterAuth/schema';
|
||||||
|
|
||||||
|
|
@ -15,20 +17,23 @@ export const authComponent = createClient<DataModel, typeof authSchema>(
|
||||||
local: {
|
local: {
|
||||||
schema: authSchema,
|
schema: authSchema,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createAuthOptions = (ctx: GenericCtx<DataModel>): BetterAuthOptions => {
|
export const createAuthOptions = (
|
||||||
|
ctx: GenericCtx<DataModel>,
|
||||||
|
): BetterAuthOptions => {
|
||||||
return {
|
return {
|
||||||
baseURL: siteUrl,
|
baseURL: siteUrl,
|
||||||
database: authComponent.adapter(ctx),
|
database: authComponent.adapter(ctx),
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
|
minPasswordLength: 8,
|
||||||
|
maxPasswordLength: 128,
|
||||||
|
autoSignIn: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
requireEmailVerification: false,
|
requireEmailVerification: false,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [convex({ authConfig }), haveIBeenPwned()],
|
||||||
convex({ authConfig }),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
{
|
{
|
||||||
"HomePage": {
|
"HomePage": {
|
||||||
"title": "Hello world!"
|
"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": {
|
"HomePage": {
|
||||||
"title": "Witaj świecie!"
|
"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 { Geist_Mono } from 'next/font/google';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { TooltipProvider } from '../components/ui/tooltip';
|
||||||
|
|
||||||
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' });
|
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-mono' });
|
||||||
|
|
||||||
|
|
@ -25,12 +26,14 @@ export default function RootLayout({
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
<TooltipProvider>
|
||||||
<NextIntlClientProvider>
|
<ThemeProvider attribute="class" enableSystem defaultTheme="system">
|
||||||
<main>{children}</main>
|
<NextIntlClientProvider>
|
||||||
<Toaster />
|
<main>{children}</main>
|
||||||
</NextIntlClientProvider>
|
<Toaster />
|
||||||
</ThemeProvider>
|
</NextIntlClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</TooltipProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { AuthForm } from '@/components/auth/AuthForm';
|
||||||
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';
|
|
||||||
|
|
||||||
const signUpSchema = z.object({
|
export default function SignUpPage() {
|
||||||
email: z.email(),
|
return <AuthForm mode="sign-up" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 const supportedLocales = ['en', 'pl'];
|
||||||
|
|
||||||
export function deafultPasswordValidator() {
|
export function defaultPasswordValidator() {
|
||||||
return z
|
return z
|
||||||
.string()
|
.string()
|
||||||
.refine((val) => val.length >= 8, { error: 'Hasło jest za krótkie' })
|
.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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// Add your hooks here
|
|
||||||
|
|
@ -1,29 +1,11 @@
|
||||||
export const routes = {
|
export const routes = {
|
||||||
home: '/',
|
public: {
|
||||||
signIn: '/sign-in',
|
home: '/',
|
||||||
signUp: '/sign-up',
|
signIn: '/sign-in',
|
||||||
dashboard: '/dashboard',
|
signUp: '/sign-up',
|
||||||
settings: '/settings',
|
},
|
||||||
} as const;
|
private: {
|
||||||
|
dashboard: '/dashboard',
|
||||||
export const publicRoutes = [
|
settings: '/settings',
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue