0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「あ、本番だった」を防げ!コピペで動く AWS Console色分け拡張機能を作った話

0
Posted at

みなさん、SSOしてますか?.png

1. はじめに

みなさん、SSOしていますか?

AWS Identity Center を使って複数アカウント/ロールを切り替える運用で、誤ったアカウントで操作するミスを減らすために、コンソール上部ナビバーをアカウント/ロールごとに色分けする Chrome/Edge(Chromium系)拡張機能 「AWS access portal Colorizer」 を作成しました🖥️

コードをそのままコピーして chrome://extensions/ からパッケージ化されていない拡張機能として読み込むだけで動きます▶️

コードを全公開しておりますので、良ければ参考にしてください🌞

2. 機能説明

アカウントIDとロールの組み合わせで、AWS Consoleのナビバーに好きなカラーを付けることができます。

カラー設定前のConsole画面

image.png

カラー設定後のConsole画面

image.png

  • 機能の詳細説明
    • アカウントID+ロール名+カラーのマッピングを保存
    • ワイルドカード保存: ロール名を * にするとアカウント全体に色を適用
    • 保存済みマッピングの一覧表示・編集・削除・有効/無効トグル(ON/OFF)
    • 開いたConsoleページで自動的にバーのカラーを適用
    • 保存済みカラー設定ファイルをエクスポート・インポート可能

3. 動作環境

  • Chromium 系ブラウザ(Chrome / Edge / Brave 等)
  • 対象ホスト
    • *.console.aws.amazon.com
    • *.awsapps.com
    • *.signin.aws.amazon.com

各ホストの具体的な役割は以下です。

  • *.console.aws.amazon.com:AWS マネジメントコンソール本体のホスト
  • *.awsapps.com:IAM ユーザー用のサインイン専用ホスト
  • *.signin.aws.amazon.com:AWS IAM Identity Center アクセスポータル用ホスト

4. コード一式

GitHubにコードを置いていますのでダウンロードください。

または、以下からコピーも可能です。

manifest.json
{
  "manifest_version": 3,
  "name": "AWS access portal Colorizer",
  "version": "1.0.0",
  "description": "Identity Center (SSO) 環境で AWS access portal の上部バーの色をアカウント/ロールごとに変更します。",
  "permissions": ["storage", "activeTab"],
  "host_permissions": [
    "*://*.console.aws.amazon.com/*",
    "*://*.awsapps.com/*",
    "*://*.signin.aws.amazon.com/*"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "AWS access portal Colorizer"
  },
  "content_scripts": [
    {
      "matches": [
        "*://*.console.aws.amazon.com/*",
        "*://*.awsapps.com/*",
        "*://*.signin.aws.amazon.com/*"
      ],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js"
  }
}
content.js
(function () {
  'use strict';
  const STORAGE_PREFIX = 'awscc_';
  const DISABLED_PREFIX = 'awsccd_';
  const STYLE_ID = 'awscc-injected-style';
  const DATA_ATTR = 'data-awscc-colored';
  const KNOWN_SELECTORS = [
    '#awsc-nav-bar',
    '#nav-bar',
    '[data-testid="awsc-nav-bar"]',
    '[data-analytics-id="nav-bar"]',
  ];

  function findNavBar() {
    for (const sel of KNOWN_SELECTORS) {
      const el = document.querySelector(sel);
      if (el) return el;
    }

    const probeY = 24;
    const probeXList = [
      Math.round(window.innerWidth * 0.1),
      Math.round(window.innerWidth * 0.5),
      Math.round(window.innerWidth * 0.9),
    ];

    for (const px of probeXList) {
      let el = document.elementFromPoint(px, probeY);
      while (el && el !== document.documentElement) {
        const rect = el.getBoundingClientRect();
        if (
          rect.width >= window.innerWidth * 0.7 &&
          rect.top <= 10 &&
          rect.height > 20 &&
          rect.height < 130
        ) {
          return el;
        }
        el = el.parentElement;
      }
    }

    return null;
  }

  let _currentColor = null;

  function applyColor(color) {
    _currentColor = color || null;

    let styleEl = document.getElementById(STYLE_ID);

    if (!color) {
      if (styleEl) styleEl.remove();
      document.querySelectorAll('[' + DATA_ATTR + ']').forEach(function (el) {
        el.removeAttribute(DATA_ATTR);
        el.style.removeProperty('background-color');
      });
      return;
    }

    if (!styleEl) {
      styleEl = document.createElement('style');
      styleEl.id = STYLE_ID;
      document.head.appendChild(styleEl);
    }
    styleEl.textContent = '[' + DATA_ATTR + '] { background-color: ' + color + ' !important; }';

    const nav = findNavBar();
    if (nav) {
      nav.setAttribute(DATA_ATTR, 'true');
      nav.style.setProperty('background-color', color, 'important');
      if (nav.firstElementChild) {
        nav.firstElementChild.style.setProperty('background-color', color, 'important');
      }
    }
  }

  var _reapplyObserver = null;

  function startReapplyObserver() {
    if (_reapplyObserver) return;
    _reapplyObserver = new MutationObserver(function () {
      if (!_currentColor) return;
      var nav = findNavBar();
      if (nav && !nav.hasAttribute(DATA_ATTR)) {
        applyColor(_currentColor);
      }
    });
    _reapplyObserver.observe(document.documentElement, {
      attributes: true,
      childList: true,
      subtree: true,
      attributeFilter: [DATA_ATTR, 'style', 'class'],
    });
  }

  function extractAccountInfo() {
    var html = document.documentElement.innerHTML;
    var text = document.body ? document.body.innerText : '';

    var arnPattern =
      /arn:aws:(?:sts|iam)::(\d{12}):(?:assumed-role|role)\/(AWSReservedSSO_[A-Za-z0-9_]+(?:\/[A-Za-z0-9._@-]+)?)/;
    var arnMatch = html.match(arnPattern) || text.match(arnPattern);
    if (arnMatch) {
      return { accountId: arnMatch[1], roleName: arnMatch[2] };
    }

    var rolePattern = /AWSReservedSSO_[A-Za-z0-9_]+(?:\/[A-Za-z0-9._@-]+)?/;
    var roleMatch = text.match(rolePattern) || html.match(rolePattern);

    var accountPattern = /\b(\d{12})\b/;
    var accountMatch = text.match(accountPattern) || html.match(accountPattern);

    return {
      accountId: accountMatch ? accountMatch[1] : null,
      roleName: roleMatch ? roleMatch[0] : null,
    };
  }

  function makeStorageKey(accountId, roleName) {
    return STORAGE_PREFIX + accountId + '_' + roleName;
  }

  function loadAndApplyColor() {
    var info = extractAccountInfo();
    if (!info.accountId) return;

    var exactColorKey = info.roleName ? makeStorageKey(info.accountId, info.roleName) : null;
    var wildcardColorKey = makeStorageKey(info.accountId, '*');
    var exactDisabledKey = exactColorKey ? DISABLED_PREFIX + info.accountId + '_' + info.roleName : null;
    var wildcardDisabledKey = DISABLED_PREFIX + info.accountId + '_*';

    var keysToFetch = [wildcardColorKey, wildcardDisabledKey];
    if (exactColorKey) keysToFetch.push(exactColorKey, exactDisabledKey);

    chrome.storage.sync.get(keysToFetch, function (result) {
      var colorToApply = null;
      var disabledKeyToCheck = null;

      if (exactColorKey && result[exactColorKey]) {
        colorToApply = result[exactColorKey];
        disabledKeyToCheck = exactDisabledKey;
      } else if (result[wildcardColorKey]) {
        colorToApply = result[wildcardColorKey];
        disabledKeyToCheck = wildcardDisabledKey;
      }

      if (!colorToApply) return;

      if (disabledKeyToCheck && result[disabledKeyToCheck]) {
        applyColor('');
      } else {
        applyColor(colorToApply);
        startReapplyObserver();
      }
    });
  }

  chrome.runtime.onMessage.addListener(function (message, _sender, sendResponse) {
    if (message.action === 'getAccountInfo') {
      sendResponse(extractAccountInfo());
    } else if (message.action === 'applyColor') {
      applyColor(message.color);
      if (message.color) startReapplyObserver();
      sendResponse({ success: true });
    }
  });

  function init() {
    function tryApply() {
      if (findNavBar()) {
        loadAndApplyColor();
        return true;
      }
      return false;
    }

    if (!tryApply()) {
      var waitObserver = new MutationObserver(function () {
        if (tryApply()) {
          waitObserver.disconnect();
        }
      });
      waitObserver.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(function () { waitObserver.disconnect(); }, 15000);
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();
background.js
chrome.runtime.onInstalled.addListener(() => {
  console.log('AWS access portal Colorizer がインストールされました。');
});
popup.js
'use strict';

const STORAGE_PREFIX = 'awscc_';
const DISABLED_PREFIX = 'awsccd_';
const PRESET_COLORS = [
  { color: '#37475a', label: 'AWS Dark' },
  { color: '#2e3a6e', label: 'Navy' },
  { color: '#1a5ca8', label: 'Blue' },
  { color: '#2d6a4f', label: 'Green' },
  { color: '#a63a20', label: 'Red' },
  { color: '#6a2fbd', label: 'Purple' },
  { color: '#b85c00', label: 'Orange' },
  { color: '#8e2e7a', label: 'Magenta' },
  { color: '#455a64', label: 'Gray' },
];

let detectedAccountId = null;
let detectedRoleName = null;
let currentColor = '#232f3e';

function makeStorageKey(accountId, roleName) {
  return STORAGE_PREFIX + accountId + '_' + roleName;
}

function parseStorageKey(key) {
  if (!key.startsWith(STORAGE_PREFIX)) return null;
  const body = key.slice(STORAGE_PREFIX.length);
  const sepIdx = body.indexOf('_');
  if (sepIdx === -1) return null;
  return {
    accountId: body.slice(0, sepIdx),
    roleName: body.slice(sepIdx + 1),
  };
}

function showStatus(message, isError = false) {
  const el = document.getElementById('status-message');
  el.textContent = message;
  el.className = isError ? 'error' : '';
  setTimeout(() => {
    el.textContent = '';
    el.className = '';
  }, 3000);
}

function isValidHex(str) {
  return /^#[0-9a-fA-F]{6}$/.test(str);
}


function setColorUI(color) {
  currentColor = color;
  document.getElementById('color-picker').value = color;
  document.getElementById('color-hex').value = color;
  document.getElementById('color-hex').classList.remove('invalid');
  document.getElementById('nav-preview').style.backgroundColor = color;
  document.querySelectorAll('.preset-color').forEach((el) => {
    el.classList.toggle('selected', el.dataset.color === color);
  });
}

function buildPresets() {
  const container = document.getElementById('preset-colors');
  PRESET_COLORS.forEach(({ color, label }) => {
    const btn = document.createElement('button');
    btn.className = 'preset-color';
    btn.dataset.color = color;
    btn.title = label + ' ' + color;
    btn.style.backgroundColor = color;
    btn.addEventListener('click', () => setColorUI(color));
    container.appendChild(btn);
  });
}

function updateAccountInfoUI(accountId, roleName) {
  const accountEl = document.getElementById('detected-account');
  const roleEl = document.getElementById('detected-role');
  const saveBtn = document.getElementById('btn-save');

  if (accountId) {
    accountEl.textContent = accountId;
    accountEl.classList.remove('not-detected');
  } else {
    accountEl.textContent = '未検出';
    accountEl.classList.add('not-detected');
  }

  if (roleName) {
    roleEl.textContent = roleName;
    roleEl.classList.remove('not-detected');
  } else {
    roleEl.textContent = '未検出';
    roleEl.classList.add('not-detected');
  }

  saveBtn.disabled = !(accountId && roleName);
}


function renderMappings(items, disabledSet = new Set()) {
  const list = document.getElementById('mapping-list');
  list.innerHTML = '';

  if (items.length === 0) {
    const empty = document.createElement('div');
    empty.className = 'empty-message';
    empty.textContent = '保存されたカラーはありません';
    list.appendChild(empty);
    return;
  }

  items.forEach(({ key, accountId, roleName, color }) => {
    const disabled = disabledSet.has(key.slice(STORAGE_PREFIX.length));
    const item = document.createElement('div');
    item.className = disabled ? 'mapping-item mapping-item--disabled' : 'mapping-item';

    const swatch = document.createElement('div');
    swatch.className = 'mapping-swatch';
    swatch.style.backgroundColor = color;

    const text = document.createElement('div');
    text.className = 'mapping-text';

    const accountSpan = document.createElement('div');
    accountSpan.className = 'mapping-account';
    accountSpan.textContent = accountId;

    const roleSpan = document.createElement('div');
    roleSpan.className = 'mapping-role';
    roleSpan.title = roleName;
    roleSpan.textContent = roleName;

    text.appendChild(accountSpan);
    text.appendChild(roleSpan);

    const editBtn = document.createElement('button');
    editBtn.className = 'mapping-edit';
    editBtn.textContent = '';
    editBtn.title = '編集';
    editBtn.addEventListener('click', () => {
      item.innerHTML = '';
      item.classList.add('mapping-item--editing');

      const form = document.createElement('div');
      form.className = 'mapping-edit-form';

      const accountInput = document.createElement('input');
      accountInput.type = 'text';
      accountInput.className = 'mapping-edit-input';
      accountInput.value = accountId;
      accountInput.placeholder = 'アカウント ID';

      const roleInput = document.createElement('input');
      roleInput.type = 'text';
      roleInput.className = 'mapping-edit-input';
      roleInput.value = roleName;
      roleInput.placeholder = 'ロール名';

      const colorRow = document.createElement('div');
      colorRow.className = 'mapping-edit-color-row';

      const colorInput = document.createElement('input');
      colorInput.type = 'color';
      colorInput.className = 'mapping-edit-color';
      colorInput.value = color;

      const hexInput = document.createElement('input');
      hexInput.type = 'text';
      hexInput.className = 'mapping-edit-input';
      hexInput.value = color;
      hexInput.placeholder = '#rrggbb';
      hexInput.maxLength = 7;

      colorInput.addEventListener('input', () => { hexInput.value = colorInput.value; });
      hexInput.addEventListener('input', () => {
        if (isValidHex(hexInput.value)) colorInput.value = hexInput.value;
      });

      colorRow.appendChild(colorInput);
      colorRow.appendChild(hexInput);

      const actions = document.createElement('div');
      actions.className = 'mapping-edit-actions';

      const saveBtn = document.createElement('button');
      saveBtn.className = 'mapping-edit-save';
      saveBtn.textContent = '保存';
      saveBtn.addEventListener('click', () => {
        const newAccountId = accountInput.value.trim();
        const newRoleName = roleInput.value.trim();
        const newColor = isValidHex(hexInput.value) ? hexInput.value : colorInput.value;
        if (!newAccountId || !newRoleName) {
          showStatus('アカウント ID とロール名は必須です', true);
          return;
        }
        const newKey = makeStorageKey(newAccountId, newRoleName);
        const oldDisabledKey = DISABLED_PREFIX + key.slice(STORAGE_PREFIX.length);
        chrome.storage.sync.remove([key, oldDisabledKey], () => {
          chrome.storage.sync.set({ [newKey]: newColor }, () => {
            loadMappings();
            showStatus('更新しました');
          });
        });
      });

      const cancelBtn = document.createElement('button');
      cancelBtn.className = 'mapping-edit-cancel';
      cancelBtn.textContent = 'キャンセル';
      cancelBtn.addEventListener('click', () => { loadMappings(); });

      actions.appendChild(saveBtn);
      actions.appendChild(cancelBtn);

      form.appendChild(accountInput);
      form.appendChild(roleInput);
      form.appendChild(colorRow);
      form.appendChild(actions);
      item.appendChild(form);
    });

    const deleteBtn = document.createElement('button');
    deleteBtn.className = 'mapping-delete';
    deleteBtn.textContent = '×';
    deleteBtn.title = '削除';
    deleteBtn.addEventListener('click', () => {
      const disabledKey = DISABLED_PREFIX + key.slice(STORAGE_PREFIX.length);
      chrome.storage.sync.remove([key, disabledKey], () => {
        loadMappings();
        showStatus('マッピングを削除しました');
      });
    });

    const toggleBtn = document.createElement('button');
    toggleBtn.className = 'mapping-toggle ' + (disabled ? 'off' : 'on');
    toggleBtn.textContent = disabled ? 'OFF' : 'ON';
    toggleBtn.title = disabled ? 'クリックして有効にする' : 'クリックして無効にする';
    toggleBtn.addEventListener('click', () => {
      const body = key.slice(STORAGE_PREFIX.length);
      const disabledKey = DISABLED_PREFIX + body;
      if (disabled) {
        chrome.storage.sync.remove([disabledKey], () => loadMappings());
      } else {
        chrome.storage.sync.set({ [disabledKey]: true }, () => loadMappings());
      }
    });

    item.appendChild(swatch);
    item.appendChild(text);
    item.appendChild(toggleBtn);
    item.appendChild(editBtn);
    item.appendChild(deleteBtn);
    list.appendChild(item);
  });
}


function loadMappings() {
  chrome.storage.sync.get(null, (allItems) => {
    const mappings = [];
    const disabledSet = new Set();
    Object.keys(allItems).forEach((key) => {
      if (key.startsWith(DISABLED_PREFIX)) {
        disabledSet.add(key.slice(DISABLED_PREFIX.length));
        return;
      }
      if (!key.startsWith(STORAGE_PREFIX)) return;
      const parsed = parseStorageKey(key);
      if (!parsed) return;
      mappings.push({
        key,
        accountId: parsed.accountId,
        roleName: parsed.roleName,
        color: allItems[key],
      });
    });
    renderMappings(mappings, disabledSet);
  });
}

function exportMappings() {
  chrome.storage.sync.get(null, (allItems) => {
    const data = [];
    Object.keys(allItems).forEach((key) => {
      if (!key.startsWith(STORAGE_PREFIX)) return;
      const parsed = parseStorageKey(key);
      if (!parsed) return;
      data.push({
        accountId: parsed.accountId,
        roleName: parsed.roleName,
        color: allItems[key],
      });
    });
    if (data.length === 0) {
      showStatus('エクスポートするデータがありません', true);
      return;
    }
    const json = JSON.stringify(data, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'awscc-colors.json';
    a.click();
    URL.revokeObjectURL(url);
    showStatus(`${data.length} 件をエクスポートしました`);
  });
}

async function getActiveTab() {
  return new Promise((resolve) => {
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
      resolve(tabs[0] || null);
    });
  });
}


async function sendToContentScript(message) {
  const tab = await getActiveTab();
  if (!tab) return null;
  return new Promise((resolve) => {
    chrome.tabs.sendMessage(tab.id, message, (response) => {
      if (chrome.runtime.lastError) {
        resolve(null);
      } else {
        resolve(response);
      }
    });
  });
}


async function init() {
  buildPresets();
  setColorUI(currentColor);

  const info = await sendToContentScript({ action: 'getAccountInfo' });
  if (info) {
    detectedAccountId = info.accountId;
    detectedRoleName = info.roleName;
    updateAccountInfoUI(info.accountId, info.roleName);

    if (info.accountId) {
      const exactKey = info.roleName ? makeStorageKey(info.accountId, info.roleName) : null;
      const wildcardKey = makeStorageKey(info.accountId, '*');
      const keysToFetch = [...(exactKey ? [exactKey] : []), wildcardKey];
      chrome.storage.sync.get(keysToFetch, (result) => {
        if (exactKey && result[exactKey]) {
          setColorUI(result[exactKey]);
        } else if (result[wildcardKey]) {
          setColorUI(result[wildcardKey]);
        }
      });
    }
  } else {
    updateAccountInfoUI(null, null);
    showStatus('このページでは情報を検出できませんでした', true);
  }

  loadMappings();

  document.getElementById('color-picker').addEventListener('input', (e) => {
    setColorUI(e.target.value);
  });

  document.getElementById('color-hex').addEventListener('input', (e) => {
    const val = e.target.value.trim();
    const hexEl = document.getElementById('color-hex');
    if (isValidHex(val)) {
      hexEl.classList.remove('invalid');
      currentColor = val;
      document.getElementById('color-picker').value = val;
      document.getElementById('nav-preview').style.backgroundColor = val;
      document.querySelectorAll('.preset-color').forEach((el) => {
        el.classList.toggle('selected', el.dataset.color === val);
      });
    } else {
      hexEl.classList.add('invalid');
    }
  });

  document.getElementById('btn-apply').addEventListener('click', async () => {
    const resp = await sendToContentScript({ action: 'applyColor', color: currentColor });
    if (resp && resp.success) {
      showStatus('色を適用しました');
    } else {
      showStatus('適用に失敗しました。AWS コンソールのページで試してください。', true);
    }
  });

  document.getElementById('btn-save').addEventListener('click', () => {
    if (!detectedAccountId || !detectedRoleName) {
      showStatus('アカウント情報が検出できていません', true);
      return;
    }
    const key = makeStorageKey(detectedAccountId, detectedRoleName);
    chrome.storage.sync.set({ [key]: currentColor }, () => {
      showStatus('保存しました!次回ログイン時から自動適用されます');
      loadMappings();
    });
  });

  document.getElementById('btn-export').addEventListener('click', exportMappings);

  document.getElementById('import-file-input').addEventListener('change', (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (evt) => {
      try {
        const data = JSON.parse(evt.target.result);
        if (!Array.isArray(data)) throw new Error('invalid format');

        const validEntries = [];
        let skipped = 0;
        data.forEach((entry) => {
          const { accountId, roleName, color } = entry;
          if (
            typeof accountId === 'string' && accountId.length > 0 &&
            typeof roleName === 'string' && roleName.length > 0 &&
            isValidHex(color)
          ) {
            validEntries.push({ key: makeStorageKey(accountId, roleName), accountId, roleName, color });
          } else {
            skipped++;
          }
        });

        if (validEntries.length === 0) {
          showStatus('有効なデータが見つかりませんでした', true);
          e.target.value = '';
          return;
        }

        const allKeys = validEntries.map((v) => v.key);
        chrome.storage.sync.get(allKeys, (existing) => {
          const overwriteEntries = validEntries.filter((v) => v.key in existing);

          const doImport = () => {
            const toStore = {};
            validEntries.forEach((v) => { toStore[v.key] = v.color; });
            chrome.storage.sync.set(toStore, () => {
              loadMappings();
              const total = validEntries.length;
              const msg = skipped > 0
                ? `${total} 件をインポートしました(${skipped} 件スキップ)`
                : `${total} 件をインポートしました`;
              showStatus(msg);
            });
          };

          if (overwriteEntries.length === 0) {
            doImport();
          } else {
            const lines = overwriteEntries.map((v) => `・${v.accountId} / ${v.roleName}`).join('\n');
            const confirmed = window.confirm(
              `以下 ${overwriteEntries.length} 件は既存の保存済みカラーを上書きします。よろしいですか?\n\n${lines}`
            );
            if (confirmed) {
              doImport();
            } else {
              showStatus('インポートをキャンセルしました');
            }
          }
        });
      } catch (_err) {
        showStatus('インポートに失敗しました(不正な形式)', true);
      }
      e.target.value = '';
    };
    reader.readAsText(file);
  });
}

document.addEventListener('DOMContentLoaded', init);
popup.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AWS access portal Colorizer</title>
  <style>
    * {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      font-size: 13px;
      width: 320px;
      background: #1a1a2e;
      color: #e0e0e0;
    }

    header {
      background: #16213e;
      padding: 12px 16px;
      border-bottom: 2px solid #e47911;
      display: flex;
      align-items: center;
      gap: 8px;
    }

    header h1 {
      font-size: 14px;
      font-weight: 600;
      color: #e47911;
    }

    .aws-icon {
      width: 20px;
      height: 20px;
      background: #e47911;
      border-radius: 3px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 10px;
      font-weight: bold;
      color: #fff;
    }

    .section {
      padding: 12px 16px;
      border-bottom: 1px solid #2a2a4a;
    }

    .section-title {
      font-size: 11px;
      font-weight: 600;
      color: #888;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      margin-bottom: 8px;
    }

    .account-info {
      background: #0f3460;
      border-radius: 6px;
      padding: 10px 12px;
    }

    .info-row {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      margin-bottom: 4px;
    }

    .info-row:last-child {
      margin-bottom: 0;
    }

    .info-label {
      color: #888;
      font-size: 11px;
      white-space: nowrap;
      margin-right: 8px;
      padding-top: 1px;
    }

    .info-value {
      color: #e0e0e0;
      font-size: 12px;
      font-family: monospace;
      word-break: break-all;
      text-align: right;
    }

    .info-value.not-detected {
      color: #666;
      font-style: italic;
      font-family: inherit;
    }

    .nav-preview {
      height: 36px;
      border-radius: 6px;
      margin-bottom: 10px;
      display: flex;
      align-items: center;
      padding: 0 12px;
      gap: 8px;
      transition: background-color 0.2s;
      border: 1px solid #333;
      font-size: 11px;
      color: rgba(255,255,255,0.7);
    }

    .nav-preview-dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: rgba(255,255,255,0.4);
    }

    .color-picker-row {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }

    .color-picker-wrap {
      position: relative;
      width: 48px;
      height: 48px;
      flex-shrink: 0;
    }

    input[type="color"] {
      width: 48px;
      height: 48px;
      border: 2px solid #555;
      border-radius: 8px;
      cursor: pointer;
      padding: 2px;
      background: #0f3460;
      flex-shrink: 0;
    }

    input[type="color"]:hover {
      border-color: #e47911;
    }

    .color-picker-wrap:hover input[type="color"] {
      border-color: #e47911;
    }

    .eyedropper-icon {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      width: 22px;
      height: 22px;
      pointer-events: none;
      filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
      opacity: 0.9;
    }

    .color-hex-wrap {
      flex: 1;
      display: flex;
      flex-direction: column;
      gap: 4px;
    }

    .color-hex-label {
      font-size: 10px;
      color: #888;
      text-transform: uppercase;
      letter-spacing: 0.05em;
    }

    .color-hex-input {
      width: 100%;
      background: #0f3460;
      border: 1px solid #444;
      border-radius: 4px;
      padding: 8px 10px;
      color: #e0e0e0;
      font-family: monospace;
      font-size: 15px;
      letter-spacing: 0.05em;
    }

    .color-hex-input:focus {
      outline: none;
      border-color: #e47911;
    }

    .color-hex-input.invalid {
      border-color: #e05555;
      color: #e05555;
    }

    .preset-colors {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
      justify-content: center;
      align-items: center;
    }

    .preset-color {
      width: 24px;
      height: 24px;
      border-radius: 4px;
      border: 2px solid transparent;
      cursor: pointer;
      transition: border-color 0.2s, transform 0.1s;
    }

    .preset-color:hover {
      border-color: #fff;
      transform: scale(1.1);
    }

    .preset-color.selected {
      border-color: #e47911;
    }

    .btn {
      display: block;
      width: 100%;
      padding: 9px 12px;
      border: none;
      border-radius: 5px;
      font-size: 13px;
      font-weight: 500;
      cursor: pointer;
      transition: opacity 0.2s, transform 0.1s;
      margin-bottom: 8px;
    }

    .btn:last-child {
      margin-bottom: 0;
    }

    .btn:hover {
      opacity: 0.9;
    }

    .btn:active {
      transform: scale(0.98);
    }

    .btn-apply {
      background: #16213e;
      color: #e0e0e0;
      border: 1px solid #444;
    }

    .btn-save {
      background: #e47911;
      color: #fff;
    }

    .btn-save:disabled {
      background: #555;
      cursor: not-allowed;
      opacity: 0.6;
    }

    .mapping-list {
      max-height: 240px;
      overflow-y: auto;
    }

    .mapping-list::-webkit-scrollbar {
      width: 4px;
    }

    .mapping-list::-webkit-scrollbar-thumb {
      background: #444;
      border-radius: 2px;
    }

    .mapping-item {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 6px 8px;
      border-radius: 5px;
      margin-bottom: 4px;
      background: #0f3460;
    }

    .mapping-swatch {
      width: 16px;
      height: 16px;
      border-radius: 3px;
      flex-shrink: 0;
      border: 1px solid #444;
    }

    .mapping-text {
      flex: 1;
      overflow: hidden;
    }

    .mapping-account {
      font-family: monospace;
      font-size: 12px;
      color: #e0e0e0;
    }

    .mapping-role {
      font-size: 11px;
      color: #888;
      word-break: break-all;
    }

    .mapping-delete {
      background: none;
      border: none;
      color: #666;
      cursor: pointer;
      font-size: 14px;
      line-height: 1;
      padding: 2px 4px;
      border-radius: 3px;
    }

    .mapping-delete:hover {
      color: #e05555;
      background: #2a1a1a;
    }

    .empty-message {
      color: #555;
      font-style: italic;
      font-size: 12px;
      text-align: center;
      padding: 8px 0;
    }

    .mapping-edit {
      background: none;
      border: none;
      color: #666;
      cursor: pointer;
      font-size: 14px;
      line-height: 1;
      padding: 2px 4px;
      border-radius: 3px;
    }

    .mapping-edit:hover {
      color: #e47911;
      background: #2a2010;
    }

    .mapping-toggle {
      background: none;
      border: 1px solid #444;
      border-radius: 10px;
      cursor: pointer;
      font-size: 10px;
      font-weight: 700;
      padding: 2px 6px;
      line-height: 1.4;
      min-width: 34px;
      text-align: center;
      flex-shrink: 0;
      transition: color 0.2s, border-color 0.2s;
    }

    .mapping-toggle.on {
      color: #4caf50;
      border-color: #4caf50;
    }

    .mapping-toggle.off {
      color: #555;
      border-color: #444;
    }

    .mapping-toggle:hover {
      opacity: 0.8;
    }

    .mapping-item--disabled .mapping-swatch {
      opacity: 0.35;
    }

    .mapping-item--disabled .mapping-account,
    .mapping-item--disabled .mapping-role {
      opacity: 0.4;
    }

    .mapping-item--editing {
      flex-direction: column;
      align-items: stretch;
    }

    .mapping-edit-form {
      display: flex;
      flex-direction: column;
      gap: 6px;
      width: 100%;
    }

    .mapping-edit-input {
      width: 100%;
      background: #1a1a2e;
      border: 1px solid #444;
      border-radius: 4px;
      padding: 5px 8px;
      color: #e0e0e0;
      font-family: monospace;
      font-size: 12px;
    }

    .mapping-edit-input:focus {
      outline: none;
      border-color: #e47911;
    }

    .mapping-edit-color-row {
      display: flex;
      align-items: center;
      gap: 8px;
    }

    .mapping-edit-color {
      width: 36px;
      height: 30px;
      border: 1px solid #444;
      border-radius: 4px;
      cursor: pointer;
      padding: 1px;
      background: #1a1a2e;
      flex-shrink: 0;
    }

    .mapping-edit-actions {
      display: flex;
      gap: 6px;
    }

    .mapping-edit-save {
      flex: 1;
      background: #e47911;
      color: #fff;
      border: none;
      border-radius: 4px;
      padding: 5px 0;
      cursor: pointer;
      font-size: 12px;
      font-weight: 600;
    }

    .mapping-edit-save:hover {
      background: #f5a623;
    }

    .mapping-edit-cancel {
      flex: 1;
      background: transparent;
      color: #888;
      border: 1px solid #333;
      border-radius: 4px;
      padding: 5px 0;
      cursor: pointer;
      font-size: 12px;
    }

    .mapping-edit-cancel:hover {
      color: #e0e0e0;
      border-color: #555;
    }

    .save-note {
      font-size: 11px;
      color: #7a7a7a;
      margin-top: 6px;
      margin-bottom: 10px;
      text-align: left;
      line-height: 1.3;
    }

    #status-message {
      font-size: 12px;
      padding: 4px 0;
      min-height: 18px;
      color: #4caf50;
      text-align: center;
    }

    #status-message.error {
      color: #e05555;
    }

    .export-import-row {
      display: flex;
      gap: 8px;
    }
    .export-import-row .btn,
    .export-import-row .btn-import-label {
      flex: 1;
      min-width: 0;
      padding: 9px 12px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      margin-bottom: 0;
      width: auto;
      box-sizing: border-box;
    }

    .btn-export {
      background: #16213e;
      color: #e0e0e0;
      border: 1px solid #444;
    }

    .btn-import-label {
      display: inline-flex;
      border: 1px solid #444;
      border-radius: 5px;
      font-size: 13px;
      font-weight: 500;
      text-align: center;
      cursor: pointer;
      background: #16213e;
      color: #e0e0e0;
      transition: opacity 0.2s;
    }

    .btn-import-label:hover {
      opacity: 0.9;
    }
  </style>
</head>
<body>
  <header>
    <div class="aws-icon">AWS</div>
    <h1>AWS access portal Colorizer</h1>
  </header>

  <div class="section">
    <div class="section-title">現在ログイン中のアカウント情報</div>
    <div class="account-info">
      <div class="info-row">
        <span class="info-label">アカウント ID</span>
        <span class="info-value not-detected" id="detected-account">検出中...</span>
      </div>
      <div class="info-row">
        <span class="info-label">ロール名</span>
        <span class="info-value not-detected" id="detected-role">検出中...</span>
      </div>
    </div>
  </div>

  <div class="section">
    <div class="section-title">カラー選択</div>
    <div class="nav-preview" id="nav-preview">
      <div class="nav-preview-dot"></div>
      <div class="nav-preview-dot"></div>
      <div class="nav-preview-dot"></div>
      <span>ナビバープレビュー</span>
    </div>
    <div class="color-picker-row">
      <div class="color-picker-wrap">
        <input type="color" id="color-picker" value="#1a1a2e" title="クリックしてカラーピッカーを開く" />
        <svg class="eyedropper-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
          <path d="M20.71 5.63l-2.34-2.34a1 1 0 0 0-1.41 0l-3.12 3.12-1.41-1.42-1.42 1.42 1.41 1.41-6.6 6.6A2 2 0 0 0 5 15.88V19a1 1 0 0 0 1 1h3.12a2 2 0 0 0 1.42-.59l6.6-6.6 1.41 1.41 1.42-1.42-1.42-1.41 3.12-3.12a1 1 0 0 0 0-1.64z" fill="white"/>
          <circle cx="4" cy="20" r="2" fill="white"/>
        </svg>
      </div>
      <div class="color-hex-wrap">
        <span class="color-hex-label">カラーコード(HEX)</span>
        <input type="text" class="color-hex-input" id="color-hex" value="#1a1a2e" maxlength="7" placeholder="#rrggbb" spellcheck="false" />
      </div>
    </div>
    <div class="preset-colors" id="preset-colors">
    </div>
  </div>

  <div class="section">
    <button class="btn btn-apply" id="btn-apply">現在のタブに適用してみる</button>
    <button class="btn btn-save" id="btn-save" disabled>このアカウント/ロールのカラーを保存</button>
    <div id="status-message"></div>
  </div>

  <div class="section">
    <div class="section-title">保存済みカラー</div>
    <div class="save-note">(注) ロール名を「*」として保存すると、そのアカウントにログインしているどのロールであっても保存したカラーが適用されます。</div>
    <div class="mapping-list" id="mapping-list">
      <div class="empty-message">保存されたカラーはありません</div>
    </div>
  </div>

  <div class="section">
    <div class="section-title">保存済みカラーのエクスポート / インポート</div>
    <div class="export-import-row">
      <button class="btn btn-export" id="btn-export">エクスポート</button>
      <label class="btn-import-label" for="import-file-input">インポート</label>
      <input type="file" id="import-file-input" accept=".json" style="display:none" />
    </div>
  </div>

  <script src="popup.js"></script>
</body>
</html>

5. インストール方法

  1. コードを用意する(リポジトリをダウンロードもしくはコードをコピーし、任意のパスのローカルフォルダに格納する)
  2. ブラウザで chrome://extensions/ を開き、右上で「デベロッパーモード」を有効化
  3. 「パッケージ化されていない拡張機能を読み込む」を選択し、ダウンロードまたはコピーしてきたAWS-access-portal-Colorizer フォルダを指定
  • Manifest V3 を利用しているため、Service Worker(background.js)や host_permissions が必要です。ブラウザ側のポリシーや企業ポリシーでロード不可となる場合があります。
  • プライベート(シークレット)ウィンドウで動かしたい場合は、拡張機能の詳細で 「シークレットモードでの実行を許可」 を有効化してください。

6. 使い方

AWS access portal からメンバーアカウントにログイン後の画面で、該当の拡張アイコンをクリックしてポップアップを開くと、以下のような画面が表示されます。

image
ポップアップ画面 説明
image ポップアップは現在タブの accountIdroleName を検出し表示します。
メンバーアカウントにログインしているにもかかわらず、”未検出”と表示される場合は、ページをリロードして再度ポップアップを表示ください。
image カラーは、カラーピッカー/HEX入力/プリセットから選べます。
image `現在のタブに適用` で一時適用、
`保存` で `chrome.storage.sync` にカラーが保存され、永続化されます。
image 保存済みカラー一覧とアカウントとロール名は、ポップアップ下部に表示されます。

※ カラー表示の優先順位は
「厳密一致(account+role)」>「ワイルドカード(account+*)」です。
image アカウント、ロールごとに、カラーの編集、削除、カラーのON/OFF切替が可能です。
image 保存済みカラーの設定のエクスポートとしてJSONファイルが出力されます。
エクスポートしたJSONファイルを別のブラウザにインポートすることも可能です。

7. さいごに

「AWS access portal Colorizer」が、日々のAWS操作でアカウント切り替えミスを減らし、作業効率向上の一助になれば嬉しいです🐈


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?