feat: refine global search RBAC and fix image loading
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m24s

- Restricted Agent search to Attendances only.

- Enabled Super Admin search for Organizations (Tenants).

- Fixed user avatar URL construction in search results.

- Added Organizations category to search dropdown for Super Admins.
This commit is contained in:
Cauê Faleiros
2026-03-09 16:09:41 -03:00
parent 13bcfc1314
commit 12d24e9255
3 changed files with 76 additions and 38 deletions

View File

@@ -460,13 +460,14 @@ apiRouter.post('/users/:id/avatar', upload.single('avatar'), async (req, res) =>
// --- Global Search ---
apiRouter.get('/search', async (req, res) => {
const { q } = req.query;
if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [] });
if (!q || q.length < 2) return res.json({ members: [], teams: [], attendances: [], organizations: [] });
const queryStr = `%${q}%`;
const results = { members: [], teams: [], attendances: [] };
const results = { members: [], teams: [], attendances: [], organizations: [] };
try {
// 1. Search Members
// 1. Search Members (only for roles above agent)
if (req.user.role !== 'agent') {
let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)';
const membersParams = [queryStr, queryStr];
@@ -478,12 +479,10 @@ apiRouter.get('/search', async (req, res) => {
} else if (req.user.role === 'manager') {
membersQ += ' AND tenant_id = ? AND (team_id = ? OR id = ?)';
membersParams.push(req.user.tenant_id, req.user.team_id, req.user.id);
} else {
membersQ += ' AND id = ?';
membersParams.push(req.user.id);
}
const [members] = await pool.query(membersQ, membersParams);
results.members = members;
}
// 2. Search Teams (only for roles above agent)
if (req.user.role !== 'agent') {
@@ -503,7 +502,13 @@ apiRouter.get('/search', async (req, res) => {
results.teams = teams;
}
// 3. Search Attendances
// 3. Search Organizations (only for super_admin)
if (req.user.role === 'super_admin') {
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ?', [queryStr, queryStr]);
results.organizations = orgs;
}
// 4. Search Attendances
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?';
const attendancesParams = [queryStr];

View File

@@ -32,7 +32,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
// Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ members: User[], teams: any[], attendances: any[] }>({ members: [], teams: [], attendances: [] });
const [searchResults, setSearchResults] = useState<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }>({ members: [], teams: [], attendances: [], organizations: [] });
const [isSearching, setIsSearching] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
@@ -45,7 +45,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
setIsSearching(false);
setShowSearchResults(true);
} else {
setSearchResults({ members: [], teams: [], attendances: [] });
setSearchResults({ members: [], teams: [], attendances: [], organizations: [] });
setShowSearchResults(false);
}
}, 300);
@@ -219,11 +219,43 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{showSearchResults && (
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="max-h-[480px] overflow-y-auto p-2">
{/* Organizations Section (Super Admin only) */}
{searchResults.organizations && searchResults.organizations.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
{searchResults.organizations.map(o => (
<button
key={o.id}
onClick={() => {
navigate(`/super-admin`);
setShowSearchResults(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-brand-yellow border border-zinc-200 dark:border-dark-border">
<Hexagon size={18} fill="currentColor" />
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{o.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{o.slug} {o.status}</div>
</div>
</button>
))}
</div>
)}
{/* Members Section */}
{searchResults.members.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
{searchResults.members.map(m => (
{searchResults.members.map(m => {
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
const avatarSrc = m.avatar_url
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
return (
<button
key={m.id}
onClick={() => {
@@ -234,7 +266,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<img
src={m.avatar_url?.startsWith('http') ? m.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${m.avatar_url || ''}`}
src={avatarSrc}
alt={m.name}
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
@@ -244,7 +276,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</div>
</div>
</button>
))}
);
})}
</div>
)}
@@ -303,7 +336,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
</div>
)}
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (!searchResults.organizations || searchResults.organizations.length === 0) && (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
Nenhum resultado encontrado para "{searchQuery}"
</div>

View File

@@ -17,7 +17,7 @@ const getHeaders = () => {
};
};
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[] }> => {
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
try {
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
headers: getHeaders()
@@ -26,7 +26,7 @@ export const searchGlobal = async (query: string): Promise<{ members: User[], te
return await response.json();
} catch (error) {
console.error("API Error (searchGlobal):", error);
return { members: [], teams: [], attendances: [] };
return { members: [], teams: [], attendances: [], organizations: [] };
}
};