Implement rate limiting and enhance authentication flow
CI / Backend (build + test) (push) Successful in 1m17s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Added rate limiting configuration for authentication endpoints, allowing customizable request limits via environment variables.
- Updated authentication flow to utilize HttpRequest for cookie management, ensuring secure handling of refresh tokens.
- Introduced a new endpoint to retrieve user subscription details.
- Enhanced the handling of Telegram bot token validation to prevent errors with empty tokens.
- Updated the application to serialize enums as strings for better documentation and compatibility with TypeScript.
- Improved test coverage for new features and adjustments in command handlers.
This commit is contained in:
Leonid Pershin
2026-07-02 12:40:23 +03:00
parent ed07221ca5
commit 8067be3c35
106 changed files with 8823 additions and 172 deletions
+22
View File
@@ -0,0 +1,22 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { type HTMLAttributes } from 'react'
import { cn } from '@/shared/lib/cn'
const badgeVariants = cva('inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium', {
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
outline: 'border-border text-foreground',
success: 'border-transparent bg-emerald-900/50 text-emerald-300',
warning: 'border-transparent bg-amber-900/50 text-amber-300',
destructive: 'border-transparent bg-red-900/50 text-red-300',
},
},
defaultVariants: { variant: 'default' },
})
export type BadgeProps = HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
}
+37
View File
@@ -0,0 +1,37 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:opacity-90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
destructive: 'bg-red-600 text-white hover:bg-red-700',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
},
)
Button.displayName = 'Button'
+34
View File
@@ -0,0 +1,34 @@
import { type HTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border border-border bg-background shadow-sm', className)} {...props} />
))
Card.displayName = 'Card'
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />
))
CardHeader.displayName = 'CardHeader'
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
),
)
CardTitle.displayName = 'CardTitle'
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />,
)
CardDescription.displayName = 'CardDescription'
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
))
CardFooter.displayName = 'CardFooter'
+33
View File
@@ -0,0 +1,33 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/shared/lib/cn'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export function DialogContent({ className, children, ...props }: DialogPrimitive.DialogContentProps) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('mb-4 flex flex-col gap-1', className)} {...props} />
}
export const DialogTitle = DialogPrimitive.Title
export const DialogDescription = DialogPrimitive.Description
+17
View File
@@ -0,0 +1,17 @@
import { type InputHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
)
Input.displayName = 'Input'
+15
View File
@@ -0,0 +1,15 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Label = forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
+10
View File
@@ -0,0 +1,10 @@
import { cn } from '@/shared/lib/cn'
export function Progress({ value, className }: { value: number; className?: string }) {
const clamped = Math.min(100, Math.max(0, value))
return (
<div className={cn('h-2 w-full overflow-hidden rounded-full bg-muted', className)}>
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${clamped}%` }} />
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import { forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Select = SelectPrimitive.Root
export const SelectValue = SelectPrimitive.Value
export const SelectTrigger = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
export const SelectContent = forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'z-50 max-h-64 min-w-[8rem] overflow-y-auto rounded-md border border-border bg-background shadow-lg',
className,
)}
position="popper"
sideOffset={4}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
export const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export type ToastVariant = 'default' | 'success' | 'error'
export type ToastItem = { id: number; message: string; variant: ToastVariant }
let nextId = 1
let pushImpl: ((message: string, variant: ToastVariant) => void) | null = null
type ToastContextValue = {
toasts: ToastItem[]
dismiss: (id: number) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const push = useCallback((message: string, variant: ToastVariant) => {
setToasts((prev) => [...prev, { id: nextId++, message, variant }])
}, [])
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
pushImpl = push
return <ToastContext value={{ toasts, dismiss }}>{children}</ToastContext>
}
export function useToastContext() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToastContext must be used within ToastProvider')
return ctx
}
/** Императивный вызов из любого места (не только компонентов) — как sonner/react-hot-toast. */
export const toast = {
success: (message: string) => pushImpl?.(message, 'success'),
error: (message: string) => pushImpl?.(message, 'error'),
message: (message: string) => pushImpl?.(message, 'default'),
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { cn } from '@/shared/lib/cn'
import { useToastContext, type ToastItem } from './toast-store'
const AUTO_DISMISS_MS = 4000
function ToastCard({ toast, dismiss }: { toast: ToastItem; dismiss: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => dismiss(toast.id), AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [toast.id, dismiss])
return (
<div
role="status"
className={cn(
'pointer-events-auto flex items-start gap-2 rounded-md border px-4 py-3 text-sm shadow-lg',
toast.variant === 'error' && 'border-red-900/50 bg-red-950 text-red-200',
toast.variant === 'success' && 'border-emerald-900/50 bg-emerald-950 text-emerald-200',
toast.variant === 'default' && 'border-border bg-muted text-foreground',
)}
>
<span className="flex-1">{toast.message}</span>
<button
type="button"
aria-label="Close"
className="text-current opacity-60 hover:opacity-100"
onClick={() => dismiss(toast.id)}
>
×
</button>
</div>
)
}
export function Toaster() {
const { toasts, dismiss } = useToastContext()
return (
<div className="pointer-events-none fixed right-4 top-4 z-[999999] flex w-full max-w-sm flex-col gap-2">
{toasts.map((t) => (
<ToastCard key={t.id} toast={t} dismiss={dismiss} />
))}
</div>
)
}