v1.4: Segurança multi-tenant, file serving via API e UX humanizada

-  Validação cross-tenant no login e rotas protegidas
-  File serving via /api/files/{bucket}/{path} (eliminação DNS)
-  Mensagens de erro humanizadas inline (sem pop-ups)
-  Middleware tenant detection via headers customizados
-  Upload de logos retorna URLs via API
-  README atualizado com changelog v1.4 completo
This commit is contained in:
Erik Silva
2025-12-13 15:05:51 -03:00
parent 04c954c3d9
commit 2f1cf2bb2a
42 changed files with 2215 additions and 872 deletions

View File

@@ -29,30 +29,48 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
"inline-flex items-center justify-center font-medium rounded-[6px] transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer";
const variants = {
primary: "text-white hover:opacity-90 active:opacity-80",
primary: "bg-brand-500 text-white hover:opacity-90 active:opacity-80 shadow-sm hover:shadow-md transition-all",
secondary:
"bg-[#E5E5E5] dark:bg-gray-700 text-[#000000] dark:text-white hover:opacity-90 active:opacity-80",
"bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 active:bg-gray-300 dark:active:bg-gray-600",
outline:
"border border-[#E5E5E5] dark:border-gray-600 text-[#000000] dark:text-white hover:bg-[#E5E5E5]/10 dark:hover:bg-gray-700/50 active:bg-[#E5E5E5]/20 dark:active:bg-gray-700",
ghost: "text-[#000000] dark:text-white hover:bg-[#E5E5E5]/20 dark:hover:bg-gray-700/30 active:bg-[#E5E5E5]/30 dark:active:bg-gray-700/50",
"border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800 active:bg-gray-100 dark:active:bg-gray-700",
ghost: "text-gray-700 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700",
};
const sizes = {
sm: "h-9 px-3 text-[13px]",
md: "h-10 px-4 text-[14px]",
lg: "h-12 px-6 text-[14px]",
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
};
return (
<button
ref={ref}
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
style={variant === 'primary' ? { background: 'var(--gradient-primary)' } : undefined}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<i className="ri-loader-4-line animate-spin mr-2 text-[20px]" />
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{!isLoading && leftIcon && (
<i className={`${leftIcon} mr-2 text-[20px]`} />

View File

@@ -1,7 +1,7 @@
'use client';
import { Fragment, useState, useEffect, useRef } from 'react';
import { Combobox, Dialog, Transition } from '@headlessui/react';
import { useState, useEffect, useRef } from 'react';
import { Combobox, Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useRouter } from 'next/navigation';
import {
@@ -84,123 +84,107 @@ export default function CommandPalette({ isOpen, setIsOpen }: CommandPaletteProp
};
return (
<Transition.Root show={isOpen} as={Fragment} afterLeave={() => setQuery('')}>
<Dialog as="div" className="relative z-50" onClose={setIsOpen} initialFocus={inputRef}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<Dialog open={isOpen} onClose={setIsOpen} className="relative z-50" initialFocus={inputRef}>
<DialogBackdrop
transition
className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity duration-300 data-[closed]:opacity-0"
/>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<DialogPanel
transition
className="mx-auto max-w-2xl transform overflow-hidden rounded-xl bg-white dark:bg-zinc-900 shadow-2xl transition-all duration-300 data-[closed]:opacity-0 data-[closed]:scale-95"
>
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<Combobox onChange={handleSelect}>
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
<Combobox.Input
ref={inputRef}
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:ring-0 sm:text-sm font-medium"
placeholder="O que você procura?"
onChange={(event) => setQuery(event.target.value)}
displayValue={(item: any) => item?.name}
autoComplete="off"
/>
</div>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="mx-auto max-w-2xl transform overflow-hidden rounded-xl bg-white dark:bg-zinc-900 shadow-2xl transition-all">
<Combobox onChange={handleSelect}>
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
<Combobox.Input
ref={inputRef}
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:ring-0 sm:text-sm font-medium"
placeholder="O que você procura?"
onChange={(event) => setQuery(event.target.value)}
displayValue={(item: any) => item?.name}
autoComplete="off"
/>
</div>
{filteredItems.length > 0 && (
<Combobox.Options static className="max-h-[60vh] scroll-py-2 overflow-y-auto py-2 text-sm text-zinc-800 dark:text-zinc-200">
{Object.entries(groups).map(([category, items]) => (
<div key={category}>
<div className="px-4 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-wider bg-zinc-50/50 dark:bg-zinc-800/50 mt-2 first:mt-0 mb-1">
{category}
</div>
{items.map((item) => (
<Combobox.Option
key={item.href}
value={item}
className={({ active }) =>
`cursor-pointer select-none px-4 py-2.5 transition-colors ${active
? '[background:var(--gradient)] text-white'
: ''
}`
}
>
{({ active }) => (
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${active
? 'bg-white/20 text-white'
: 'bg-zinc-50 dark:bg-zinc-900 text-zinc-400'
}`}>
<item.icon
className="h-4 w-4"
aria-hidden="true"
/>
</div>
<span className={`flex-auto truncate font-medium ${active ? 'text-white' : 'text-zinc-600 dark:text-zinc-400'}`}>
{item.name}
</span>
{active && (
<ArrowRightIcon className="h-4 w-4 text-white/70" />
)}
</div>
{filteredItems.length > 0 && (
<Combobox.Options static className="max-h-[60vh] scroll-py-2 overflow-y-auto py-2 text-sm text-zinc-800 dark:text-zinc-200">
{Object.entries(groups).map(([category, items]) => (
<div key={category}>
<div className="px-4 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-wider bg-zinc-50/50 dark:bg-zinc-800/50 mt-2 first:mt-0 mb-1">
{category}
</div>
{items.map((item) => (
<Combobox.Option
key={item.href}
value={item}
className={({ active }) =>
`cursor-pointer select-none px-4 py-2.5 transition-colors ${active
? '[background:var(--gradient)] text-white'
: ''
}`
}
>
{({ active }) => (
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-md ${active
? 'bg-white/20 text-white'
: 'bg-zinc-50 dark:bg-zinc-900 text-zinc-400'
}`}>
<item.icon
className="h-4 w-4"
aria-hidden="true"
/>
</div>
<span className={`flex-auto truncate font-medium ${active ? 'text-white' : 'text-zinc-600 dark:text-zinc-400'}`}>
{item.name}
</span>
{active && (
<ArrowRightIcon className="h-4 w-4 text-white/70" />
)}
</Combobox.Option>
))}
</div>
</div>
)}
</Combobox.Option>
))}
</Combobox.Options>
)}
</div>
))}
</Combobox.Options>
)}
{query !== '' && filteredItems.length === 0 && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<MagnifyingGlassIcon className="mx-auto h-6 w-6 text-zinc-400" aria-hidden="true" />
<p className="mt-4 font-semibold text-zinc-900 dark:text-white">Nenhum resultado encontrado</p>
<p className="mt-2 text-zinc-500">Não conseguimos encontrar nada para &quot;{query}&quot;. Tente buscar por páginas ou ações.</p>
</div>
)}
{query !== '' && filteredItems.length === 0 && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<MagnifyingGlassIcon className="mx-auto h-6 w-6 text-zinc-400" aria-hidden="true" />
<p className="mt-4 font-semibold text-zinc-900 dark:text-white">Nenhum resultado encontrado</p>
<p className="mt-2 text-zinc-500">Não conseguimos encontrar nada para &quot;{query}&quot;. Tente buscar por páginas ou ações.</p>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 bg-zinc-50 dark:bg-zinc-900/50">
<div className="flex gap-4 text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Selecionar
</span>
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Navegar
</span>
</div>
<div className="text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-auto px-1.5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800">Esc</kbd>
Fechar
</span>
</div>
</div>
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<div className="flex items-center justify-between px-4 py-3 bg-zinc-50 dark:bg-zinc-900/50">
<div className="flex gap-4 text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Selecionar
</span>
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
<kbd className="flex h-5 w-5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800"></kbd>
Navegar
</span>
</div>
<div className="text-[10px] text-zinc-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="flex h-5 w-auto px-1.5 items-center justify-center rounded bg-white font-sans text-xs text-zinc-400 dark:bg-zinc-800">Esc</kbd>
Fechar
</span>
</div>
</div>
</Combobox>
</DialogPanel>
</div>
</Dialog>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import { InputHTMLAttributes, forwardRef, useState } from "react";
import { InputHTMLAttributes, forwardRef, useState, ReactNode } from "react";
import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
leftIcon?: string;
rightIcon?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
onRightIconClick?: () => void;
}
@@ -41,26 +42,26 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)}
<div className="relative">
{leftIcon && (
<i
className={`${leftIcon} absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] dark:text-gray-400 text-[20px]`}
/>
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[#7D7D7D] dark:text-gray-400 w-5 h-5">
{leftIcon}
</div>
)}
<input
ref={ref}
type={inputType}
className={`
w-full px-3.5 py-3 text-[14px] font-normal
border rounded-md bg-white dark:bg-gray-700 dark:text-white
placeholder:text-zinc-500 dark:placeholder:text-gray-400
transition-all
w-full px-4 py-2.5 text-sm font-normal
border rounded-lg bg-white dark:bg-gray-800 dark:text-white
placeholder:text-gray-400 dark:placeholder:text-gray-500
transition-all duration-200
${leftIcon ? "pl-11" : ""}
${isPassword || rightIcon ? "pr-11" : ""}
${error
? "border-red-500 focus:border-red-500"
: "border-zinc-200 dark:border-gray-600 focus:border-brand-500"
? "border-red-500 focus:border-red-500 focus:ring-4 focus:ring-red-500/10"
: "border-gray-200 dark:border-gray-700 focus:border-brand-500 focus:ring-4 focus:ring-brand-500/10"
}
outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none
disabled:bg-zinc-100 disabled:cursor-not-allowed
outline-none
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
${className}
`}
{...props}
@@ -71,9 +72,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
>
<i
className={`${showPassword ? "ri-eye-off-line" : "ri-eye-line"} text-[20px]`}
/>
{showPassword ? (
<EyeSlashIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</button>
)}
{!isPassword && rightIcon && (
@@ -82,13 +85,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
onClick={onRightIconClick}
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
>
<i className={`${rightIcon} text-[20px]`} />
<div className="w-5 h-5">{rightIcon}</div>
</button>
)}
</div>
{error && (
<p className="mt-1 text-[13px] text-red-500 flex items-center gap-1">
<i className="ri-error-warning-line" />
<ExclamationCircleIcon className="w-4 h-4" />
{error}
</p>
)}