feat: refine global search RBAC and fix image loading
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m24s
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:
@@ -460,30 +460,29 @@ 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
|
||||
let membersQ = 'SELECT id, name, email, slug, role, team_id, avatar_url FROM users WHERE (name LIKE ? OR email LIKE ?)';
|
||||
const membersParams = [queryStr, queryStr];
|
||||
// 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];
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
membersQ += ' AND tenant_id = ?';
|
||||
membersParams.push(req.user.tenant_id);
|
||||
} 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);
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
membersQ += ' AND tenant_id = ?';
|
||||
membersParams.push(req.user.tenant_id);
|
||||
} 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);
|
||||
}
|
||||
const [members] = await pool.query(membersQ, membersParams);
|
||||
results.members = members;
|
||||
}
|
||||
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];
|
||||
|
||||
|
||||
@@ -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,35 +219,68 @@ 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">
|
||||
{/* Members Section */}
|
||||
{searchResults.members.length > 0 && (
|
||||
{/* 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">Membros</div>
|
||||
{searchResults.members.map(m => (
|
||||
<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={m.id}
|
||||
key={o.id}
|
||||
onClick={() => {
|
||||
navigate(`/users/${m.slug || m.id}`);
|
||||
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"
|
||||
>
|
||||
<img
|
||||
src={m.avatar_url?.startsWith('http') ? m.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${m.avatar_url || ''}`}
|
||||
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`; }}
|
||||
/>
|
||||
<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">{m.name}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</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 => {
|
||||
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={() => {
|
||||
navigate(`/users/${m.slug || m.id}`);
|
||||
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"
|
||||
>
|
||||
<img
|
||||
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`; }}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{m.name}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams Section */}
|
||||
{searchResults.teams.length > 0 && (
|
||||
<div className="mb-4">
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user