gentelella
Version:
Gentelella v4 — free admin template. 60 pages, 20 chart variants, fully interactive inbox & kanban, live theme generator, component playground, PWA-ready. Vite 8, vanilla JS, no Bootstrap, no jQuery.
242 lines (225 loc) • 12.8 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User management | Gentelella 2026 v4</title>
<link rel="icon" href="../images/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main-v4.js"></script>
</head>
<body data-shell="admin" data-page="user_management" data-breadcrumb="Home > Admin > Users">
<main class="main">
<div class="page-wrapper">
<div class="page-header">
<div class="page-header-row">
<div>
<div class="page-pretitle">Admin</div>
<h1 class="page-title">User management</h1>
</div>
<div class="page-actions">
<button class="btn btn-outline">Export</button>
<button class="btn btn-primary" id="invite-btn">+ Invite user</button>
</div>
</div>
</div>
<div class="row col-3" style="margin-bottom:16px">
<div class="card"><div class="stat"><div class="stat-icon teal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7"/></svg></div><div class="stat-content"><div class="stat-label">Total users</div><div class="stat-value-row"><span class="stat-value">12</span></div><div class="stat-subtext">3 active right now</div></div></div></div>
<div class="card"><div class="stat"><div class="stat-icon blue"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1l3 6 6 .9-4.5 4.4 1 6.7L12 16l-5.5 3 1-6.7L3 7.9 9 7z"/></svg></div><div class="stat-content"><div class="stat-label">Admins</div><div class="stat-value-row"><span class="stat-value">2</span></div><div class="stat-subtext">Owner + 1 admin</div></div></div></div>
<div class="card"><div class="stat"><div class="stat-icon yellow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg></div><div class="stat-content"><div class="stat-label">Pending invites</div><div class="stat-value-row"><span class="stat-value">2</span></div><div class="stat-subtext">Awaiting acceptance</div></div></div></div>
</div>
<div class="card">
<div class="card-header" style="gap:12px;flex-wrap:wrap">
<div class="users-filters">
<div class="search-box" style="width:240px">
<svg class="s-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg>
<input type="text" id="users-search" placeholder="Search by name or email…" aria-label="Search users">
</div>
<select class="form-control" id="filter-role" style="width:140px;height:32px" aria-label="Filter by role">
<option value="">All roles</option>
<option value="Owner">Owner</option>
<option value="Admin">Admin</option>
<option value="Editor">Editor</option>
<option value="Viewer">Viewer</option>
</select>
<select class="form-control" id="filter-status" style="width:140px;height:32px" aria-label="Filter by status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
</select>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th style="width:40px"><input type="checkbox" id="select-all" aria-label="Select all rows"></th>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Last active</th>
<th>Joined</th>
<th data-orderable="false"></th>
</tr>
</thead>
<tbody id="users-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<script type="module">
import { showToast } from '/src/v4/toast.js';
import { showModal } from '/src/v4/modal.js';
import { openMenu } from '/src/v4/menus.js';
const USERS = [
{ name:'Aigars Silkalns', email:'aigars@example.com', ini:'A', col:'primary', role:'Owner', status:'active', lastActive:'just now', joined:'Mar 2024' },
{ name:'Sarah Kowalski', email:'sarah@example.com', ini:'SK', col:'azure', role:'Admin', status:'active', lastActive:'2 min ago', joined:'Apr 2024' },
{ name:'Michael Reyes', email:'michael@example.com', ini:'MR', col:'purple', role:'Editor', status:'active', lastActive:'14 min ago', joined:'May 2024' },
{ name:'Emily Wang', email:'emily@example.com', ini:'EW', col:'yellow', role:'Editor', status:'active', lastActive:'1 hour ago', joined:'Jun 2024' },
{ name:'Mark Kim', email:'mark@example.com', ini:'MK', col:'red', role:'Viewer', status:'active', lastActive:'3 hours ago', joined:'Jul 2024' },
{ name:'Lina Park', email:'lina@example.com', ini:'LP', col:'green', role:'Editor', status:'active', lastActive:'Yesterday', joined:'Sep 2024' },
{ name:'Diego Reyes', email:'diego@example.com', ini:'DR', col:'blue', role:'Editor', status:'active', lastActive:'2 days ago', joined:'Sep 2024' },
{ name:'Yuki Tanaka', email:'yuki@example.com', ini:'YT', col:'primary', role:'Viewer', status:'active', lastActive:'4 days ago', joined:'Oct 2024' },
{ name:'Tom Hardy', email:'tom@example.com', ini:'TH', col:'purple', role:'Editor', status:'active', lastActive:'1 week ago', joined:'Nov 2024' },
{ name:'Robert Jones', email:'robert@example.com', ini:'RJ', col:'red', role:'Viewer', status:'suspended', lastActive:'2 weeks ago', joined:'Dec 2024' },
{ name:'invite@acme.com', email:'invite@acme.com', ini:'?', col:'gray', role:'Editor', status:'pending', lastActive:'—', joined:'Apr 28, 2026', pending:true },
{ name:'newhire@team.io', email:'newhire@team.io', ini:'?', col:'gray', role:'Viewer', status:'pending', lastActive:'—', joined:'Apr 27, 2026', pending:true }
];
const COLORS = { primary:'var(--primary)', azure:'var(--azure)', purple:'var(--purple)', yellow:'var(--yellow)', red:'var(--red)', green:'var(--green)', blue:'var(--blue)', gray:'var(--text-disabled)' };
const STATUS_CLS = { active:'green', pending:'yellow', suspended:'red' };
const ROLE_CLS = { Owner:'purple', Admin:'red', Editor:'blue', Viewer:'green' };
const ROLES = ['Owner', 'Admin', 'Editor', 'Viewer'];
let filterText = '', filterRole = '', filterStatus = '';
function visible() {
const q = filterText.toLowerCase();
return USERS.filter((u) =>
(!q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)) &&
(!filterRole || u.role === filterRole) &&
(!filterStatus || u.status === filterStatus)
);
}
function render() {
const rows = document.getElementById('users-rows');
const items = visible();
rows.innerHTML = items.map((u, idx) => `
<tr data-email="${u.email}">
<td><input type="checkbox" class="row-cb" aria-label="Select row"></td>
<td>
<div class="cell-customer">
<div class="cell-avatar${u.pending ? ' pending' : ''}" style="background:${u.pending ? 'var(--bg-surface-secondary)' : COLORS[u.col]};color:${u.pending ? 'var(--text-muted)' : 'white'}">${u.ini}</div>
<div>
<div class="cell-strong">${u.pending ? '<em style="font-style:normal;color:var(--text-muted)">Invited</em>' : u.name}</div>
<div style="font-size:11.5px;color:var(--text-muted)">${u.email}</div>
</div>
</div>
</td>
<td><button type="button" class="role-chip role-${ROLE_CLS[u.role]}" data-role-edit data-email="${u.email}">${u.role} <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg></button></td>
<td><span class="status status-${STATUS_CLS[u.status]}">${u.status[0].toUpperCase() + u.status.slice(1)}</span></td>
<td style="font-size:12.5px;color:var(--text-muted)">${u.lastActive}</td>
<td style="font-size:12.5px;color:var(--text-muted)">${u.joined}</td>
<td><button class="card-opt-btn" data-row-menu data-email="${u.email}" aria-label="More"><svg viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="3" r="1.2"/><circle cx="8" cy="8" r="1.2"/><circle cx="8" cy="13" r="1.2"/></svg></button></td>
</tr>
`).join('');
}
document.getElementById('users-search').addEventListener('input', (e) => { filterText = e.target.value; render(); });
document.getElementById('filter-role').addEventListener('change', (e) => { filterRole = e.target.value; render(); });
document.getElementById('filter-status').addEventListener('change', (e) => { filterStatus = e.target.value; render(); });
document.getElementById('select-all').addEventListener('change', (e) => {
document.querySelectorAll('#users-rows .row-cb').forEach((cb) => { cb.checked = e.target.checked; });
});
document.getElementById('users-rows').addEventListener('click', (e) => {
const roleBtn = e.target.closest('[data-role-edit]');
const moreBtn = e.target.closest('[data-row-menu]');
if (roleBtn) {
e.stopPropagation();
const email = roleBtn.dataset.email;
const u = USERS.find((x) => x.email === email);
if (!u) return;
openMenu(roleBtn, ROLES.map((r) => ({
label: r + (r === u.role ? ' ✓' : ''),
action: () => {
if (u.role === 'Owner' || r === 'Owner') {
showToast('Owner role cannot be changed inline', { variant: 'warning' });
return;
}
u.role = r;
render();
showToast(`${u.name}: role set to ${r}`, { variant: 'success' });
}
})));
} else if (moreBtn) {
e.stopPropagation();
const email = moreBtn.dataset.email;
const u = USERS.find((x) => x.email === email);
if (!u) return;
const items = [
{ label: 'View profile', action: () => showToast(`Profile: ${u.name}`) },
{ label: 'Reset password', action: () => showToast(`Reset link sent to ${u.email}`, { variant: 'success' }) },
{ label: u.status === 'suspended' ? 'Reactivate' : 'Suspend', action: () => {
u.status = u.status === 'suspended' ? 'active' : 'suspended';
render();
showToast(`${u.name}: ${u.status}`);
}
},
'-',
{ label: 'Remove from workspace', action: () => {
const i = USERS.indexOf(u);
if (i > -1) USERS.splice(i, 1);
render();
showToast(`Removed ${u.name}`);
}
}
];
openMenu(moreBtn, items);
}
});
document.getElementById('invite-btn').addEventListener('click', (e) => {
e.stopPropagation();
showModal({
title: 'Invite a user',
body: `
<form class="modal-form" novalidate>
<div class="modal-form-row">
<label for="iv-email">Email address</label>
<input type="email" id="iv-email" name="email" placeholder="teammate@example.com" autocomplete="off" required>
</div>
<div class="modal-form-row">
<label for="iv-role">Role</label>
<select id="iv-role" name="role">
<option value="Viewer">Viewer — can only view</option>
<option value="Editor" selected>Editor — can view and edit</option>
<option value="Admin">Admin — can manage users</option>
</select>
</div>
<div class="modal-form-row">
<label for="iv-message">Personal message (optional)</label>
<textarea id="iv-message" name="message" rows="3" placeholder="Welcome to the team!"></textarea>
</div>
</form>
`,
actions: [
{ label: 'Cancel', variant: 'outline' },
{ label: 'Send invite', variant: 'primary', action: ({ body }) => {
const form = body.querySelector('form');
const fd = new FormData(form);
const email = (fd.get('email') || '').toString().trim();
const role = fd.get('role').toString();
if (!email) { showToast('Email is required', { variant: 'warning' }); return false; }
USERS.push({ name: email, email, ini: '?', col: 'gray', role, status: 'pending', lastActive: '—', joined: 'just now', pending: true });
render();
showToast(`Invite sent to ${email}`, { variant: 'success' });
return true;
} }
]
});
});
render();
</script>
</body>
</html>