SaaS — Team Members & Roles
A polished access-management page for a fictional SaaS workspace: a searchable, filterable members table with avatars, inline role selects, status badges and last-active times, plus an invite-by-email form with validation, a live pending-invites list with resend and revoke, a role-permissions summary, a seats-used meter, and a confirm-to-remove dialog. Ships with a working light and dark theme toggle and toast feedback on every action.
MCP
Code
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #fbfcfe;
--ink: #0f1222;
--muted: #646b85;
--brand: #6366f1;
--brand-d: #4f46e5;
--brand-soft: #eef0ff;
--ok: #16a34a;
--ok-soft: #e7f6ec;
--warn: #d97706;
--warn-soft: #fdf1e0;
--danger: #dc2626;
--danger-soft: #fdecec;
--line: rgba(15, 18, 34, .1);
--line-2: rgba(15, 18, 34, .06);
--shadow: 0 1px 2px rgba(15, 18, 34, .04), 0 8px 24px rgba(15, 18, 34, .06);
--shadow-pop: 0 12px 40px rgba(15, 18, 34, .18);
--radius: 14px;
--radius-sm: 9px;
}
[data-theme="dark"] {
--bg: #0b0d16;
--surface: #141726;
--surface-2: #181c2e;
--ink: #eef0f8;
--muted: #9aa1bd;
--brand: #818cf8;
--brand-d: #a5b0ff;
--brand-soft: #20243c;
--ok-soft: #14271d;
--warn-soft: #2a2110;
--danger-soft: #2c1518;
--line: rgba(255, 255, 255, .12);
--line-2: rgba(255, 255, 255, .07);
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 30px rgba(0, 0, 0, .4);
--shadow-pop: 0 18px 50px rgba(0, 0, 0, .6);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 { margin: 0; line-height: 1.25; letter-spacing: -.01em; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip-link {
position: absolute; left: 12px; top: -48px; z-index: 100;
background: var(--brand); color: #fff; padding: 9px 14px;
border-radius: 8px; text-decoration: none; transition: top .15s;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- shell ---------- */
.shell { max-width: 1200px; margin: 0 auto; padding: 0 20px 56px; }
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 16px 0; flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid; place-items: center; width: 40px; height: 40px;
border-radius: 11px; color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: 0 6px 16px rgba(99, 102, 241, .35);
}
.brand-name { display: flex; flex-direction: column; line-height: 1.2; }
.brand-name strong { font-size: 15px; font-weight: 700; }
.brand-sub { font-size: 12px; color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.workspace-pill {
display: inline-flex; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--line);
padding: 7px 12px; border-radius: 999px; font-size: 13px; font-weight: 500;
box-shadow: var(--shadow);
}
.workspace-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
.theme-toggle {
display: inline-flex; align-items: center; gap: 7px;
background: var(--surface); border: 1px solid var(--line);
color: var(--ink); padding: 7px 12px; border-radius: 999px;
font-size: 13px; font-weight: 500; cursor: pointer;
box-shadow: var(--shadow); transition: border-color .15s, transform .05s;
}
.theme-toggle:hover { border-color: var(--brand); }
.theme-toggle:active { transform: translateY(1px); }
.theme-ico { font-size: 14px; }
/* ---------- page head ---------- */
.page-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 20px; flex-wrap: wrap; margin: 14px 0 22px;
}
.page-head h1 { font-size: 26px; font-weight: 800; }
.lede { color: var(--muted); margin: 6px 0 0; }
.seats { display: flex; align-items: center; gap: 14px; }
.seats-meter { min-width: 190px; }
.seats-bar {
height: 8px; border-radius: 999px; background: var(--line-2);
overflow: hidden; border: 1px solid var(--line-2);
}
.seats-bar span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--brand), var(--brand-d));
border-radius: 999px; transition: width .4s cubic-bezier(.4,0,.2,1);
}
.seats-bar.is-full span { background: linear-gradient(90deg, var(--warn), #b45309); }
.seats-text { font-size: 12.5px; color: var(--muted); margin-top: 6px; }
.seats-text strong { color: var(--ink); }
/* ---------- layout grid ---------- */
.grid {
display: grid; grid-template-columns: minmax(0, 1fr) 340px;
gap: 20px; align-items: start;
}
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow);
}
.members-card { overflow: hidden; }
.card-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; padding: 18px 20px; flex-wrap: wrap;
}
.card-head.tight { padding-bottom: 8px; }
.card-head h2 { font-size: 16px; font-weight: 700; }
.muted { color: var(--muted); font-size: 13px; margin: 3px 0 0; }
.card-tools { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.search { position: relative; display: flex; align-items: center; }
.search-ico {
position: absolute; left: 11px; color: var(--muted); font-size: 15px; pointer-events: none;
}
.search input {
font: inherit; font-size: 13px; color: var(--ink);
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 9px; padding: 8px 12px 8px 32px; width: 210px; max-width: 52vw;
transition: border-color .15s, box-shadow .15s;
}
.search input::placeholder { color: var(--muted); }
.search input:focus-visible { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.filter { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
font: inherit; font-size: 12.5px; font-weight: 500;
background: var(--surface-2); border: 1px solid var(--line);
color: var(--muted); padding: 6px 12px; border-radius: 999px; cursor: pointer;
transition: all .12s;
}
.chip:hover { color: var(--ink); border-color: var(--brand); }
.chip.is-active {
background: var(--brand); border-color: var(--brand); color: #fff;
box-shadow: 0 3px 10px rgba(99, 102, 241, .3);
}
/* ---------- table ---------- */
.table-wrap { width: 100%; overflow-x: auto; }
table.members { width: 100%; border-collapse: collapse; min-width: 640px; }
.members thead th {
text-align: left; font-size: 11.5px; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--muted);
padding: 10px 16px; border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line); background: var(--surface-2);
}
.members tbody td {
padding: 13px 16px; border-bottom: 1px solid var(--line-2); vertical-align: middle;
}
.members tbody tr { transition: background .12s; }
.members tbody tr:hover { background: var(--surface-2); }
.members tbody tr:last-child td { border-bottom: 0; }
.th-actions { width: 48px; }
.person { display: flex; align-items: center; gap: 12px; }
.avatar {
flex: none; width: 38px; height: 38px; border-radius: 50%;
display: grid; place-items: center; color: #fff; font-weight: 700; font-size: 13px;
box-shadow: inset 0 0 0 1px rgba(255,255,255,.25);
}
.person-meta { min-width: 0; }
.person-name { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 7px; }
.you-tag {
font-size: 10.5px; font-weight: 600; color: var(--brand-d);
background: var(--brand-soft); padding: 1px 6px; border-radius: 999px;
}
.person-email {
font-size: 12.5px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 220px;
}
.role-select {
font: inherit; font-size: 13px; font-weight: 500; color: var(--ink);
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 8px; padding: 6px 28px 6px 10px; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M3 4.5 6 7.5 9 4.5' stroke='%23646b85' stroke-width='1.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
background-repeat: no-repeat; background-position: right 9px center;
transition: border-color .15s, box-shadow .15s;
}
.role-select:hover { border-color: var(--brand); }
.role-select:focus-visible { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.role-select:disabled { opacity: .65; cursor: not-allowed; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.active { color: var(--ok); background: var(--ok-soft); }
.badge.invited { color: var(--warn); background: var(--warn-soft); }
.badge.suspended { color: var(--danger); background: var(--danger-soft); }
.last-active { font-size: 13px; color: var(--muted); }
.row-action {
display: grid; place-items: center; width: 32px; height: 32px;
border-radius: 8px; border: 1px solid transparent; background: transparent;
color: var(--muted); cursor: pointer; font-size: 16px; transition: all .12s;
}
.row-action:hover { background: var(--danger-soft); color: var(--danger); border-color: var(--line); }
.row-action:disabled { opacity: .3; cursor: not-allowed; }
.empty {
text-align: center; padding: 48px 20px; color: var(--muted);
}
.empty-ico { font-size: 30px; margin-bottom: 8px; }
.empty p { margin: 0 0 14px; }
/* ---------- side column ---------- */
.side { display: flex; flex-direction: column; gap: 20px; }
.invite-card { padding: 20px; }
.invite-card h2 { font-size: 16px; font-weight: 700; }
.invite-card .muted { margin-bottom: 16px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12.5px; font-weight: 600; margin-bottom: 6px; }
.field input, .field select {
font: inherit; font-size: 13.5px; width: 100%; color: var(--ink);
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 9px; padding: 9px 11px;
transition: border-color .15s, box-shadow .15s;
}
.field select { appearance: none; cursor: pointer;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M3 4.5 6 7.5 9 4.5' stroke='%23646b85' stroke-width='1.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
background-repeat: no-repeat; background-position: right 11px center; padding-right: 30px;
}
.field input:focus-visible, .field select:focus-visible {
outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft);
}
.field input.invalid { border-color: var(--danger); }
.err { color: var(--danger); font-size: 12px; margin: 6px 0 0; }
.btn {
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
border-radius: 9px; padding: 9px 16px; border: 1px solid transparent;
transition: all .12s; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--brand); color: #fff; width: 100%; box-shadow: 0 4px 12px rgba(99,102,241,.3); }
.btn-primary:hover { background: var(--brand-d); }
.btn-ghost { background: var(--surface); color: var(--ink); border-color: var(--line); }
.btn-ghost:hover { border-color: var(--brand); color: var(--brand-d); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { filter: brightness(.93); }
/* ---------- pending ---------- */
.pending-card { padding: 20px; }
.count {
font-size: 12px; font-weight: 600; color: var(--brand-d);
background: var(--brand-soft); padding: 1px 8px; border-radius: 999px; margin-left: 4px;
}
.pending-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.pending-item {
display: flex; align-items: center; gap: 11px;
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 10px; padding: 10px 12px; animation: pop .25s ease;
}
@keyframes pop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.pending-item .pi-ico {
flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center;
background: var(--warn-soft); color: var(--warn); font-size: 14px;
}
.pi-meta { min-width: 0; flex: 1; }
.pi-email { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pi-sub { font-size: 11.5px; color: var(--muted); }
.pi-actions { display: flex; gap: 6px; flex: none; }
.link-btn {
font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
background: transparent; border: 1px solid var(--line); color: var(--ink);
padding: 5px 9px; border-radius: 7px; transition: all .12s;
}
.link-btn:hover { border-color: var(--brand); color: var(--brand-d); }
.link-btn.danger:hover { border-color: var(--danger); color: var(--danger); }
.pending-empty { margin: 12px 0 0; text-align: center; }
/* ---------- permissions ---------- */
.perms-card { padding: 20px; }
.perms-card h2 { font-size: 16px; font-weight: 700; margin-bottom: 12px; }
.perms { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.perm {
display: grid; grid-template-columns: 16px 1fr; gap: 11px;
padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2);
}
.perm-dot { width: 12px; height: 12px; border-radius: 50%; margin-top: 4px; }
.perm-name { font-size: 13.5px; font-weight: 700; }
.perm-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
/* ---------- modal ---------- */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 18, 34, .5);
display: grid; place-items: center; padding: 20px; z-index: 60;
animation: fade .15s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: var(--surface); border: 1px solid var(--line);
border-radius: 16px; box-shadow: var(--shadow-pop);
padding: 24px; max-width: 380px; width: 100%; animation: rise .2s ease;
}
@keyframes rise { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
.modal h3 { font-size: 17px; font-weight: 700; }
.modal p { color: var(--muted); font-size: 13.5px; margin: 8px 0 20px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: 10px; z-index: 80; width: min(92vw, 420px);
}
.toast {
display: flex; align-items: center; gap: 10px;
background: var(--ink); color: var(--bg);
padding: 12px 16px; border-radius: 11px; box-shadow: var(--shadow-pop);
font-size: 13.5px; font-weight: 500; animation: toastIn .25s ease;
}
.toast.ok .t-ico { color: #4ade80; }
.toast.warn .t-ico { color: #fbbf24; }
.toast.info .t-ico { color: #818cf8; }
.t-ico { font-size: 15px; }
.toast.leaving { animation: toastOut .25s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes toastOut { to { opacity: 0; transform: translateY(12px); } }
/* ---------- responsive ---------- */
@media (max-width: 920px) {
.grid { grid-template-columns: 1fr; }
.side { flex-direction: row; flex-wrap: wrap; }
.side > .card { flex: 1 1 280px; }
}
@media (max-width: 600px) {
.page-head { align-items: flex-start; }
.seats { width: 100%; justify-content: space-between; }
.seats-meter { flex: 1; min-width: 0; }
.card-head { flex-direction: column; }
.side { flex-direction: column; }
}
@media (max-width: 380px) {
.shell { padding: 0 12px 40px; }
.theme-label { display: none; }
.person-email { max-width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
const SEATS_TOTAL = 12;
const ROLES = ["Owner", "Admin", "Member", "Viewer"];
const AVATAR_COLORS = [
"#6366f1", "#0ea5e9", "#14b8a6", "#f59e0b",
"#ec4899", "#8b5cf6", "#ef4444", "#10b981",
];
// ---- seed data ----
let members = [
{ id: 1, name: "Maya Okafor", email: "maya@northwind.co", role: "Owner", status: "active", last: "Active now", you: true },
{ id: 2, name: "Diego Romero", email: "diego@northwind.co", role: "Admin", status: "active", last: "12 min ago" },
{ id: 3, name: "Priya Nair", email: "priya@northwind.co", role: "Member", status: "active", last: "2 hours ago" },
{ id: 4, name: "Liam Sørensen", email: "liam@northwind.co", role: "Member", status: "active", last: "Yesterday" },
{ id: 5, name: "Aisha Bello", email: "aisha@northwind.co", role: "Viewer", status: "active", last: "3 days ago" },
{ id: 6, name: "Tom Becker", email: "tom@contractor.io", role: "Member", status: "invited", last: "Never" },
{ id: 7, name: "Sofia Lindqvist", email: "sofia@northwind.co", role: "Admin", status: "suspended", last: "2 weeks ago" },
];
let pending = [
{ id: 101, email: "wei.zhang@northwind.co", role: "Member", sent: "Sent 2 days ago" },
];
let uid = 200;
let activeFilter = "all";
let searchTerm = "";
let pendingRemoveId = null;
const PERMS = [
{ role: "Owner", color: "#6366f1", desc: "Full control, billing, and can delete the workspace." },
{ role: "Admin", color: "#0ea5e9", desc: "Manage members, roles, and all project settings." },
{ role: "Member", color: "#16a34a", desc: "Create and edit projects; cannot manage the team." },
{ role: "Viewer", color: "#646b85", desc: "Read-only access to projects and dashboards." },
];
// ---- helpers ----
const $ = (s) => document.querySelector(s);
const initials = (name) =>
name.trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
const colorFor = (id) => AVATAR_COLORS[id % AVATAR_COLORS.length];
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
// ---- toast ----
const toastWrap = $("#toastWrap");
function toast(msg, kind = "ok") {
const icons = { ok: "✓", warn: "!", info: "ℹ", danger: "✕" };
const el = document.createElement("div");
el.className = "toast " + kind;
el.innerHTML = `<span class="t-ico" aria-hidden="true">${icons[kind] || "ℹ"}</span><span>${esc(msg)}</span>`;
toastWrap.appendChild(el);
setTimeout(() => {
el.classList.add("leaving");
el.addEventListener("animationend", () => el.remove(), { once: true });
}, 3200);
}
// ---- seats ----
function seatsUsed() {
return members.filter((m) => m.status !== "removed").length + pending.length;
}
function renderSeats() {
const used = seatsUsed();
const pct = Math.min(100, Math.round((used / SEATS_TOTAL) * 100));
$("#seatsUsed").textContent = used;
$("#seatsTotal").textContent = SEATS_TOTAL;
$("#seatsFill").style.width = pct + "%";
$("#seatsFill").parentElement.classList.toggle("is-full", used >= SEATS_TOTAL);
}
// ---- members table ----
function visibleMembers() {
return members.filter((m) => {
if (m.status === "removed") return false;
if (activeFilter !== "all" && m.role !== activeFilter) return false;
if (searchTerm) {
const hay = (m.name + " " + m.email).toLowerCase();
if (!hay.includes(searchTerm)) return false;
}
return true;
});
}
function statusBadge(status) {
const map = {
active: ["active", "Active"],
invited: ["invited", "Invited"],
suspended: ["suspended", "Suspended"],
};
const [cls, label] = map[status] || ["active", status];
return `<span class="badge ${cls}">${label}</span>`;
}
function roleOptions(selected, isOwner) {
return ROLES.map((r) => {
// Only Owner row keeps the Owner option available.
if (r === "Owner" && !isOwner) return "";
return `<option value="${r}"${r === selected ? " selected" : ""}>${r}</option>`;
}).join("");
}
function renderMembers() {
const rows = visibleMembers();
const tbody = $("#memberRows");
const empty = $("#emptyState");
const total = members.filter((m) => m.status !== "removed").length;
$("#memberCount").textContent =
`${total} ${total === 1 ? "person" : "people"} in this workspace`;
if (rows.length === 0) {
tbody.innerHTML = "";
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((m) => {
const isOwner = m.role === "Owner";
const lockRole = isOwner; // owner role can't be changed in this demo
return `
<tr data-id="${m.id}">
<td>
<div class="person">
<span class="avatar" style="background:${colorFor(m.id)}" aria-hidden="true">${initials(m.name)}</span>
<span class="person-meta">
<span class="person-name">${esc(m.name)}${m.you ? '<span class="you-tag">You</span>' : ""}</span>
<span class="person-email" title="${esc(m.email)}">${esc(m.email)}</span>
</span>
</div>
</td>
<td>
<label class="sr-only" for="role-${m.id}">Role for ${esc(m.name)}</label>
<select class="role-select" id="role-${m.id}" data-id="${m.id}"${lockRole ? " disabled" : ""}>
${roleOptions(m.role, isOwner)}
</select>
</td>
<td>${statusBadge(m.status)}</td>
<td><span class="last-active">${esc(m.last)}</span></td>
<td>
<button class="row-action" data-remove="${m.id}" type="button"
aria-label="Remove ${esc(m.name)}"${isOwner ? " disabled title='Owner cannot be removed'" : ""}>✕</button>
</td>
</tr>`;
}).join("");
}
// ---- pending invites ----
function renderPending() {
const list = $("#pendingList");
$("#pendingCount").textContent = pending.length;
$("#pendingEmpty").hidden = pending.length > 0;
list.innerHTML = pending.map((p) => `
<li class="pending-item" data-pid="${p.id}">
<span class="pi-ico" aria-hidden="true">✉</span>
<span class="pi-meta">
<span class="pi-email" title="${esc(p.email)}">${esc(p.email)}</span>
<span class="pi-sub">${esc(p.role)} · ${esc(p.sent)}</span>
</span>
<span class="pi-actions">
<button class="link-btn" data-resend="${p.id}" type="button">Resend</button>
<button class="link-btn danger" data-revoke="${p.id}" type="button">Revoke</button>
</span>
</li>`).join("");
}
// ---- permissions summary ----
function renderPerms() {
$("#permsList").innerHTML = PERMS.map((p) => `
<li class="perm">
<span class="perm-dot" style="background:${p.color}"></span>
<span>
<span class="perm-name">${p.role}</span>
<span class="perm-desc">${p.desc}</span>
</span>
</li>`).join("");
}
function renderAll() {
renderMembers();
renderPending();
renderSeats();
}
// ---- events: search & filter ----
$("#search").addEventListener("input", (e) => {
searchTerm = e.target.value.trim().toLowerCase();
renderMembers();
});
document.querySelectorAll(".chip").forEach((chip) => {
chip.addEventListener("click", () => {
document.querySelectorAll(".chip").forEach((c) => c.classList.remove("is-active"));
chip.classList.add("is-active");
activeFilter = chip.dataset.role;
renderMembers();
});
});
$("#clearSearch").addEventListener("click", () => {
searchTerm = "";
activeFilter = "all";
$("#search").value = "";
document.querySelectorAll(".chip").forEach((c) => c.classList.remove("is-active"));
document.querySelector('.chip[data-role="all"]').classList.add("is-active");
renderMembers();
});
// ---- events: table (delegated) ----
$("#memberRows").addEventListener("change", (e) => {
const sel = e.target.closest(".role-select");
if (!sel) return;
const id = Number(sel.dataset.id);
const m = members.find((x) => x.id === id);
if (!m) return;
m.role = sel.value;
toast(`${m.name} is now a ${m.role}`, "info");
});
$("#memberRows").addEventListener("click", (e) => {
const btn = e.target.closest("[data-remove]");
if (!btn || btn.disabled) return;
pendingRemoveId = Number(btn.dataset.remove);
const m = members.find((x) => x.id === pendingRemoveId);
if (!m) return;
$("#confirmTitle").textContent = `Remove ${m.name}?`;
$("#confirmBody").textContent =
`${m.name} will lose access to Northwind Org immediately. This frees up one seat.`;
openModal();
});
// ---- events: pending (delegated) ----
$("#pendingList").addEventListener("click", (e) => {
const resend = e.target.closest("[data-resend]");
const revoke = e.target.closest("[data-revoke]");
if (resend) {
const p = pending.find((x) => x.id === Number(resend.dataset.resend));
if (p) { p.sent = "Sent just now"; renderPending(); toast(`Invite resent to ${p.email}`, "ok"); }
} else if (revoke) {
const id = Number(revoke.dataset.revoke);
const p = pending.find((x) => x.id === id);
pending = pending.filter((x) => x.id !== id);
renderPending(); renderSeats();
if (p) toast(`Invite to ${p.email} revoked`, "warn");
}
});
// ---- invite form ----
const inviteForm = $("#inviteForm");
const emailInput = $("#inviteEmail");
const emailErr = $("#emailErr");
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function showEmailError(msg) {
emailErr.textContent = msg;
emailErr.hidden = false;
emailInput.classList.add("invalid");
emailInput.setAttribute("aria-invalid", "true");
}
function clearEmailError() {
emailErr.hidden = true;
emailInput.classList.remove("invalid");
emailInput.removeAttribute("aria-invalid");
}
emailInput.addEventListener("input", clearEmailError);
inviteForm.addEventListener("submit", (e) => {
e.preventDefault();
const email = emailInput.value.trim().toLowerCase();
const role = $("#inviteRole").value;
if (!email) return showEmailError("Enter an email address.");
if (!EMAIL_RE.test(email)) return showEmailError("That doesn't look like a valid email.");
const dupMember = members.some((m) => m.status !== "removed" && m.email.toLowerCase() === email);
const dupPending = pending.some((p) => p.email.toLowerCase() === email);
if (dupMember) return showEmailError("This person is already a member.");
if (dupPending) return showEmailError("An invite is already pending for this email.");
if (seatsUsed() >= SEATS_TOTAL) {
toast("No seats left — upgrade your plan to invite more.", "warn");
return;
}
pending.push({ id: ++uid, email, role, sent: "Sent just now" });
renderPending();
renderSeats();
clearEmailError();
inviteForm.reset();
$("#inviteRole").value = "Member";
toast(`Invitation sent to ${email}`, "ok");
});
// ---- modal ----
const backdrop = $("#confirmBackdrop");
let lastFocus = null;
function openModal() {
lastFocus = document.activeElement;
backdrop.hidden = false;
$("#confirmOk").focus();
document.addEventListener("keydown", onModalKey);
}
function closeModal() {
backdrop.hidden = true;
pendingRemoveId = null;
document.removeEventListener("keydown", onModalKey);
if (lastFocus) lastFocus.focus();
}
function onModalKey(e) {
if (e.key === "Escape") closeModal();
}
$("#confirmCancel").addEventListener("click", closeModal);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeModal(); });
$("#confirmOk").addEventListener("click", () => {
const m = members.find((x) => x.id === pendingRemoveId);
if (m) {
m.status = "removed";
toast(`${m.name} removed from the workspace`, "danger");
}
closeModal();
renderAll();
});
// ---- manage seats / plan ----
$("#manageSeats").addEventListener("click", () => {
toast(`Using ${seatsUsed()} of ${SEATS_TOTAL} seats on the Team plan.`, "info");
});
// ---- theme toggle ----
const themeToggle = $("#themeToggle");
themeToggle.addEventListener("click", () => {
const dark = document.documentElement.getAttribute("data-theme") === "dark";
document.documentElement.setAttribute("data-theme", dark ? "light" : "dark");
themeToggle.setAttribute("aria-pressed", String(!dark));
});
// ---- init ----
renderPerms();
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Acme Cloud — Team Members & Roles</title>
<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@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<div class="shell">
<header class="topbar" role="banner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 7v10M7.5 9.5 12 12l4.5-2.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
</svg>
</span>
<div class="brand-name">
<strong>Acme Cloud</strong>
<span class="brand-sub">Workspace settings</span>
</div>
</div>
<div class="topbar-actions">
<div class="workspace-pill" title="Current workspace">
<span class="dot" aria-hidden="true"></span> Northwind Org
</div>
<button class="theme-toggle" id="themeToggle" type="button" aria-pressed="false" title="Toggle dark mode">
<span class="theme-ico" aria-hidden="true">◐</span>
<span class="theme-label">Theme</span>
</button>
</div>
</header>
<main id="main" class="content" role="main">
<div class="page-head">
<div>
<h1>Team members & roles</h1>
<p class="lede">Manage who can access this workspace and what they can do.</p>
</div>
<div class="seats" role="group" aria-label="Seat usage">
<div class="seats-meter">
<div class="seats-bar"><span id="seatsFill" style="width:0%"></span></div>
<div class="seats-text">
<strong id="seatsUsed">0</strong> of <span id="seatsTotal">12</span> seats used
</div>
</div>
<button class="btn btn-ghost" id="manageSeats" type="button">Manage plan</button>
</div>
</div>
<div class="grid">
<!-- LEFT: members + table -->
<section class="card members-card" aria-labelledby="membersHead">
<div class="card-head">
<div>
<h2 id="membersHead">Members</h2>
<p class="muted" id="memberCount">—</p>
</div>
<div class="card-tools">
<div class="search">
<span class="search-ico" aria-hidden="true">⌕</span>
<input id="search" type="search" placeholder="Search name or email" aria-label="Search members" />
</div>
<div class="filter" role="group" aria-label="Filter by role">
<button class="chip is-active" data-role="all" type="button">All</button>
<button class="chip" data-role="Owner" type="button">Owner</button>
<button class="chip" data-role="Admin" type="button">Admin</button>
<button class="chip" data-role="Member" type="button">Member</button>
<button class="chip" data-role="Viewer" type="button">Viewer</button>
</div>
</div>
</div>
<div class="table-wrap">
<table class="members" aria-label="Workspace members">
<thead>
<tr>
<th scope="col">Member</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Last active</th>
<th scope="col" class="th-actions"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="memberRows"><!-- JS --></tbody>
</table>
<div class="empty" id="emptyState" hidden>
<div class="empty-ico" aria-hidden="true">🔍</div>
<p>No members match your search.</p>
<button class="btn btn-ghost" id="clearSearch" type="button">Clear filters</button>
</div>
</div>
</section>
<!-- RIGHT: invite + pending + permissions -->
<div class="side">
<section class="card invite-card" aria-labelledby="inviteHead">
<h2 id="inviteHead">Invite teammates</h2>
<p class="muted">They'll get an email to join Northwind Org.</p>
<form id="inviteForm" novalidate>
<div class="field">
<label for="inviteEmail">Email address</label>
<input id="inviteEmail" name="email" type="email" placeholder="name@company.com" autocomplete="off" required />
<p class="err" id="emailErr" role="alert" hidden></p>
</div>
<div class="field">
<label for="inviteRole">Role</label>
<select id="inviteRole" name="role">
<option value="Admin">Admin</option>
<option value="Member" selected>Member</option>
<option value="Viewer">Viewer</option>
</select>
</div>
<button class="btn btn-primary" type="submit">Send invite</button>
</form>
</section>
<section class="card pending-card" aria-labelledby="pendingHead">
<div class="card-head tight">
<h2 id="pendingHead">Pending invites <span class="count" id="pendingCount">0</span></h2>
</div>
<ul class="pending-list" id="pendingList" aria-live="polite"></ul>
<p class="pending-empty muted" id="pendingEmpty">No pending invites.</p>
</section>
<section class="card perms-card" aria-labelledby="permsHead">
<h2 id="permsHead">Role permissions</h2>
<ul class="perms" id="permsList"></ul>
</section>
</div>
</div>
</main>
</div>
<!-- Confirm dialog -->
<div class="modal-backdrop" id="confirmBackdrop" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirmTitle" aria-describedby="confirmBody">
<h3 id="confirmTitle">Remove member?</h3>
<p id="confirmBody">This person will lose access to Northwind Org immediately.</p>
<div class="modal-actions">
<button class="btn btn-ghost" id="confirmCancel" type="button">Cancel</button>
<button class="btn btn-danger" id="confirmOk" type="button">Remove</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Team Members & Roles
A self-contained team and access-management page for a fictional SaaS workspace. The left column holds a members table — avatar, name, email, an inline role select, a status badge (active, invited, suspended), and last-active time — with a live search box and role filter chips. The right column stacks an invite-by-email form, a pending-invites list, and a role-permissions summary, while the header tracks seats used against the plan limit.
Every interaction works in vanilla JS. Inviting a valid, non-duplicate email adds an animated pending row and fires a toast; changing a role updates inline; removing a member opens a focus-trapped confirm dialog and frees a seat. Pending invites can be resent or revoked, search and filters narrow the table with an intentional empty state, and a theme toggle switches between fully styled light and dark modes. The Owner row is protected from removal and role changes, and the seats meter turns amber when the workspace is full.
Illustrative SaaS UI only — fictional product, metrics, and billing. No real backend.