Skip to content

Role & permission assignment

The access-control screen every admin console needs. A role picker built on md-select drives an md-transfer-list whose target column is the role’s granted permissions. Moving an elevated permission — Delete users, Manage API keys — into the Granted column opens a confirming md-dialog first, and cancelling puts the item straight back. Saving raises an md-snackbar with a working Undo. One immovable item (Configure SSO) shows how disabled entries stay visible instead of vanishing.

Live preview — move Delete users into Granted to trigger the confirmation
Edit role access Pick a role, then move permissions between the columns. Elevated permissions ask for confirmation before they land.
Support agent Billing admin Engineer Compliance auditor
Save changes

This grants to every member of this role. Elevated actions are recorded in the audit log and cannot be limited to individual members.

Cancel Grant access
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-select id="role" label="Role" value="support">
  <md-select-option value="support" supporting-text="14 members">Support agent</md-select-option>
  <md-select-option value="billing" supporting-text="3 members">Billing admin</md-select-option>
</md-select>

<md-transfer-list id="permissions"
  source-title="Available permissions"
  target-title="Granted permissions"
  move-right-label="Grant selected permissions"
  move-left-label="Revoke selected permissions"
  move-all-right-label="Grant all permissions"
  move-all-left-label="Revoke all permissions"
  style="--md-transfer-list-height: 380px;"
  ></md-transfer-list>

  <md-button variant="filled" id="save">Save changes</md-button>

  <md-dialog id="confirm" headline="Grant elevated access?" icon="warning" scrim-dismissible="false">
    <p>This grants <strong id="elevated-names"></strong> to every member of this role.</p>
    <md-button slot="actions" variant="text" id="confirm-cancel">Cancel</md-button>
    <md-button slot="actions" variant="filled" id="confirm-grant">Grant access</md-button>
  </md-dialog>

  <md-snackbar id="saved" action="Undo" position="bottom-start"></md-snackbar>

<script type="module">
  const roleSelect = document.querySelector('[data-role]');
  const list = document.querySelector('[data-permissions]');
  const saveBtn = document.querySelector('[data-save]');
  const dialog = document.querySelector('[data-confirm]');
  const namesEl = document.querySelector('[data-elevated-names]');
  const bar = document.querySelector('[data-saved]');

  const PERMISSIONS = [
    { value: 'view-dashboards', label: 'View dashboards', description: 'Read-only analytics and usage' },
    { value: 'export-reports', label: 'Export reports', description: 'CSV and PDF downloads' },
    { value: 'invite-users', label: 'Invite users', description: 'Send and revoke invitations' },
    { value: 'manage-billing', label: 'Manage billing', description: 'Invoices and payment methods' },
    { value: 'issue-refunds', label: 'Issue refunds', description: 'Up to 5,000 EUR per order' },
    { value: 'view-audit-log', label: 'View audit log', description: 'Every privileged action, 90 days' },
    { value: 'manage-api-keys', label: 'Manage API keys', description: 'Create and revoke live keys' },
    { value: 'delete-users', label: 'Delete users', description: 'Permanent, after a 30-day grace period' },
    { value: 'configure-sso', label: 'Configure SSO', description: 'Requires the Enterprise plan', disabled: true },
  ];
  const PRESETS = {
    support: ['view-dashboards', 'issue-refunds'],
    billing: ['view-dashboards', 'export-reports', 'manage-billing', 'issue-refunds'],
    engineer: ['view-dashboards', 'view-audit-log', 'manage-api-keys'],
    auditor: ['view-dashboards', 'export-reports', 'view-audit-log'],
  };
  const ROLE_NAMES = {
    support: 'Support agent',
    billing: 'Billing admin',
    engineer: 'Engineer',
    auditor: 'Compliance auditor',
  };
  const ELEVATED = ['delete-users', 'manage-api-keys'];
  const LABELS = {};
  PERMISSIONS.forEach((p) => { LABELS[p.value] = p.label; });

  list.items = PERMISSIONS;
  let saved = PRESETS[roleSelect.value].slice();
  list.value = saved.slice();

  // Switching roles re-partitions the list. Assigning value emits no
  // mdChange/mdMove, so a role switch can never trip the elevated check.
  roleSelect.addEventListener('mdChange', (e) => {
    saved = (PRESETS[e.detail] || []).slice();
    list.value = saved.slice();
  });

  // Elevated grants need an explicit yes. mdMove describes the move;
  // if it carried an elevated permission, hold a revert snapshot and ask.
  let pendingRevert = null;
  list.addEventListener('mdMove', (e) => {
    if (e.detail.direction !== 'right') return;
    const risky = e.detail.moved.filter((v) => ELEVATED.indexOf(v) !== -1);
    if (risky.length === 0) return;
    pendingRevert = e.detail.target.filter((v) => risky.indexOf(v) === -1);
    namesEl.textContent = risky.map((v) => LABELS[v]).join(' and ');
    dialog.show();
  });

  const revert = () => {
    if (pendingRevert) {
      list.value = pendingRevert;
      pendingRevert = null;
    }
  };
  document.querySelector('[data-confirm-cancel]').addEventListener('mdClick', () => {
    revert();
    dialog.close();
  });
  document.querySelector('[data-confirm-grant]').addEventListener('mdClick', () => {
    pendingRevert = null;
    dialog.close();
  });
  // Escape is a dismissal too — treat it exactly like Cancel.
  dialog.addEventListener('mdCancel', revert);

  // Save commits the current target set; Undo restores the previous one.
  let lastSaved = saved.slice();
  saveBtn.addEventListener('mdClick', () => {
    lastSaved = saved.slice();
    saved = list.value.slice();
    bar.message = ROLE_NAMES[roleSelect.value] + ' now has ' + saved.length + ' permissions';
    bar.show();
  });
  bar.addEventListener('mdAction', () => {
    saved = lastSaved.slice();
    list.value = saved.slice();
    bar.hide('action');
  });
</script>
ComponentRole in this screen
md-transfer-listThe centre of the screen. One items pool; value is the Granted column, so switching roles is a single property write. mdMove describes every move (direction, moved, target) — exactly what the elevated-grant check needs. The disabled Configure SSO entry is visible but immovable.
md-selectPicks the role. Each md-select-option carries a member count as supporting-text; mdChange delivers the new value as a plain string.
md-dialogConfirms the elevated grant. scrim-dismissible="false" forces an explicit choice; slotted action buttons never close the dialog themselves, so both handlers call close().
md-snackbarPost-save feedback with a single reversing action. hide('action') from the mdAction handler is the only way reason: 'action' is produced.
md-buttonSave and the dialog’s action pair — dismissive Cancel on the leading side of the confirming Grant access, per M3.
  • Programmatic writes are silent by design. Assigning list.value emits neither mdChange nor mdMove, so switching roles or reverting after Cancel can never re-trigger the confirmation dialog — events fire only for real user moves.
  • The movers respect the filter, and never the disabled. The single-step / buttons act only on items both checked and visible under the current search; the bulk »/« buttons ignore the filter but still skip disabled items — Grant all can never sneak Configure SSO across.
  • Escape and Cancel are one code path. The dialog emits mdCancel only on dismissal (Escape here, since the scrim is off) and mdClose on every close, so the revert handler is written idempotent — a dismissal fires both events without double-reverting.
  • Undo is a real reversal, not a toast. The snackbar’s action button never dismisses the surface on its own; the handler restores the previous saved set and then calls hide('action'). The auto-hide timer pauses while the snackbar is hovered or focused (WCAG 2.2.1), so slow readers keep their Undo window.
  • Each permission row is one tab stop. Rows are role="option" inside an aria-multiselectable listbox — Space or Enter toggles — and the inner checkbox is inert + aria-hidden. The four mover buttons take their accessible names from the move-*-label props set in the markup.
  • Safety-first editing: add single-step-only and show-select-all="false" to the transfer list — no bulk movers, no select-all, every grant is deliberate.
  • Save-per-move instead of a Save button: persist on each mdChange (e.detail is the fresh target array) and show the Undo snackbar per move.
  • Inside a real <form>: the transfer list is not form-associated — mirror mdChange into hidden <input name="permissions"> elements, as in the pattern in the md-transfer-list manual.
  • Narrow viewports: three columns don’t fit below ~600px — collapse to an md-multi-select there and keep the same confirmation dialog.
  • Recipe: Two-factor verification — another confirm-the-risky-step flow, built around md-otp-field and md-dialog.
  • Component manuals: md-transfer-list — the item/selection API, move semantics, and theming; md-select — option sources, filtering, and form participation.