feat: tooltip, password hardening

This commit is contained in:
nxtkofi 2026-04-01 20:59:46 +02:00
parent 547594294a
commit da062bcc27
14 changed files with 468 additions and 164 deletions

View file

@ -7,4 +7,8 @@
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"sortImports": {
"order": "asc",
"ignoreCase": true
}
}

View file

@ -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()],
};
};

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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">
<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
View file

@ -0,0 +1,5 @@
import { AuthForm } from '@/components/auth/AuthForm';
export default function SignInPage() {
return <AuthForm mode="sign-in" />;
}

View file

@ -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" />;
}

View 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
View 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,
}

View 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 }

View 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 }

View file

@ -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' });
}

View file

@ -1 +0,0 @@
// Add your hooks here

View file

@ -1,29 +1,11 @@
export const routes = {
public: {
home: '/',
signIn: '/sign-in',
signUp: '/sign-up',
},
private: {
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));
}
},
};