From da062bcc27927aaa3211acc62ddd90611fce466b Mon Sep 17 00:00:00 2001 From: nxtkofi Date: Wed, 1 Apr 2026 20:59:46 +0200 Subject: [PATCH] feat: tooltip, password hardening --- .oxfmtrc.jsonc | 4 + convex/auth.ts | 17 ++- messages/en.json | 16 +++ messages/pl.json | 16 +++ src/app/layout.tsx | 15 ++- src/app/sign-in/page.tsx | 5 + src/app/sign-up/page.tsx | 119 +---------------- src/components/auth/AuthForm.tsx | 221 +++++++++++++++++++++++++++++++ src/components/ui/card.tsx | 103 ++++++++++++++ src/components/ui/spinner.tsx | 11 ++ src/components/ui/tooltip.tsx | 57 ++++++++ src/constants.ts | 9 +- src/hooks/index.ts | 1 - src/lib/routes.ts | 38 ++---- 14 files changed, 468 insertions(+), 164 deletions(-) create mode 100644 src/app/sign-in/page.tsx create mode 100644 src/components/auth/AuthForm.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/spinner.tsx create mode 100644 src/components/ui/tooltip.tsx delete mode 100644 src/hooks/index.ts diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index ef7ab21..1eec9a2 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -7,4 +7,8 @@ "trailingComma": "all", "bracketSpacing": true, "arrowParens": "always", + "sortImports": { + "order": "asc", + "ignoreCase": true + } } diff --git a/convex/auth.ts b/convex/auth.ts index 6a429f5..e513458 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -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( local: { schema: authSchema, }, - } + }, ); -export const createAuthOptions = (ctx: GenericCtx): BetterAuthOptions => { +export const createAuthOptions = ( + ctx: GenericCtx, +): 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()], }; }; diff --git a/messages/en.json b/messages/en.json index 8a71bae..0a5c438 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" } } diff --git a/messages/pl.json b/messages/pl.json index 7647b64..641bc13 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 074e91d..8e51c13 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 > - - -
{children}
- -
-
+ + + +
{children}
+ +
+
+
); diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx new file mode 100644 index 0000000..36b664f --- /dev/null +++ b/src/app/sign-in/page.tsx @@ -0,0 +1,5 @@ +import { AuthForm } from '@/components/auth/AuthForm'; + +export default function SignInPage() { + return ; +} diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx index 2859074..f474d30 100644 --- a/src/app/sign-up/page.tsx +++ b/src/app/sign-up/page.tsx @@ -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>({ - resolver: zodResolver(signUpSchema), - defaultValues: { - email: '', - password: '', - }, - }); - async function onSubmit(values: z.infer) { - 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 ( -
- - ( - - Name - - {fieldState.invalid && } - - )} - /> - ( - - Email - - {fieldState.invalid && } - - )} - /> - ( - - Password - - {fieldState.invalid && } - - )} - /> - - -
- ); +export default function SignUpPage() { + return ; } diff --git a/src/components/auth/AuthForm.tsx b/src/components/auth/AuthForm.tsx new file mode 100644 index 0000000..2efe605 --- /dev/null +++ b/src/components/auth/AuthForm.tsx @@ -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; +type SignUpValues = z.infer; + +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(); + + const form = useForm({ + 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 ( + + + {isSignUp ? t('SignUpTitle') : t('SignInTitle')} + + + + + +
+ + {isSignUp && ( + ( + + + {t('NameLabel')} + + + {fieldState.invalid && ( + + )} + + )} + /> + )} + ( + + + {t('EmailLabel')} + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + {t('PasswordLabel')} + + + + setPasswordVisible(!passwordVisible)} + > + + + {passwordVisible ? ( + + ) : ( + + )} + + + {passwordVisible ? ( +

{t('HidePasswordTooltip')}

+ ) : ( +

{t('ShowPasswordTooltip')}

+ )} +
+
+
+
+ {fieldState.invalid && ( + + )} +
+ )} + /> + +
+
+
+
+ ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/src/components/ui/card.tsx @@ -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 ( +
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 ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..abe52c9 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -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 ( + + ) +} + +export { Spinner } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..bb1ea52 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -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) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/src/constants.ts b/src/constants.ts index e7d9c98..3d21688 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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' }); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts deleted file mode 100644 index f585e01..0000000 --- a/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Add your hooks here diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 0990fbe..4bf6852 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -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', + }, +};