Files
Job-Form/frontend/teams-messages.html
aewing 611be70362 docs: explain Teams API limitation for message deletion
- Microsoft Teams Graph API does not support DELETE on channel messages
- This is a platform limitation, not an application error
- Updated UI to show clear warning about Teams API limitation
- Updated error messages to guide users on alternatives:
  - Delete via Teams client UI
  - Use delegated token with ChatMessage.ReadWrite.All permission
- HTTP 405 (Method Not Allowed) is the expected response from Graph API
2025-12-20 20:07:02 +00:00

350 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Teams Channel Messages</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: { extend: { colors: { primary: '#4c51bf', secondary: '#5b21b6' } } }
}
</script>
</head>
<body class="min-h-screen bg-gray-50">
<div class="max-w-5xl mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-800">Teams Channel Messages</h1>
<a href="/index.html" class="text-sm text-blue-600 underline">Back to Form</a>
</div>
<!-- Info about Teams API Limitation -->
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
<strong>⚠️ Note:</strong> Microsoft Teams API does not support deletion of channel messages via REST API.
To delete messages, either:
<ul class="ml-4 mt-2 space-y-1">
<li>• Delete directly in Teams (right-click message → Delete)</li>
<li>• Provide a delegated user token with <code class="bg-yellow-100 px-1 rounded">ChatMessage.ReadWrite.All</code> permission</li>
</ul>
</div>
<!-- Controls -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Team ID</label>
<input id="teamId" type="text" placeholder="e.g. e45f..." class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Channel ID</label>
<input id="channelId" type="text" placeholder="e.g. 19:...@thread.tacv2" class="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bearer Token (optional)</label>
<input id="bearerToken" type="text" placeholder="Paste delegated token if needed" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Results</label>
<input id="top" type="number" min="1" max="50" value="50" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div class="flex items-end">
<button id="loadBtn" class="w-full py-2 bg-primary text-white rounded-lg hover:opacity-95">Load Messages</button>
</div>
</div>
<div id="status" class="mt-3 text-sm text-gray-600"></div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 mb-3">
<button id="selectAllBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Select All</button>
<button id="clearSelectionBtn" class="px-3 py-2 bg-gray-200 rounded-lg text-sm">Clear</button>
<button id="deleteSelectedBtn" class="px-3 py-2 bg-red-600 text-white rounded-lg text-sm">Delete Selected</button>
<span id="deleteSummary" class="ml-2 text-sm text-gray-600"></span>
<div class="ml-auto flex items-center gap-2">
<label class="text-sm text-gray-700">
<input id="toggleRaw" type="checkbox" class="mr-1 align-middle" /> Show raw message JSON
</label>
</div>
</div>
<!-- Messages List -->
<div id="messagesContainer" class="bg-white rounded-xl shadow divide-y"></div>
</div>
<script>
const teamIdEl = document.getElementById('teamId');
const channelIdEl = document.getElementById('channelId');
const bearerEl = document.getElementById('bearerToken');
const topEl = document.getElementById('top');
const loadBtn = document.getElementById('loadBtn');
const statusEl = document.getElementById('status');
const messagesContainer = document.getElementById('messagesContainer');
const selectAllBtn = document.getElementById('selectAllBtn');
const clearSelectionBtn = document.getElementById('clearSelectionBtn');
const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
const deleteSummaryEl = document.getElementById('deleteSummary');
// Helper: URL params
function getParams() {
const u = new URL(window.location.href);
const p = Object.fromEntries(u.searchParams.entries());
return p;
}
// Pre-fill from last-used IDs if available (from localStorage)
const LS_KEYS = { team: 'teamsMsg.teamId', channel: 'teamsMsg.channelId', top: 'teamsMsg.top' };
const params = getParams();
teamIdEl.value = params.teamId || localStorage.getItem(LS_KEYS.team) || '';
channelIdEl.value = params.channelId || localStorage.getItem(LS_KEYS.channel) || '';
topEl.value = params.top || localStorage.getItem(LS_KEYS.top) || '50';
function savePrefs() {
localStorage.setItem(LS_KEYS.team, teamIdEl.value.trim());
localStorage.setItem(LS_KEYS.channel, channelIdEl.value.trim());
localStorage.setItem(LS_KEYS.top, topEl.value);
}
function renderMessages(messages) {
messagesContainer.innerHTML = '';
if (!messages || !messages.length) {
messagesContainer.innerHTML = '<div class="p-4 text-sm text-gray-600">No messages found.</div>';
return;
}
messages.forEach((m) => {
const row = document.createElement('div');
row.className = 'p-4 flex gap-3 items-start';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'mt-1';
checkbox.dataset.messageId = m.id;
const info = document.createElement('div');
info.className = 'flex-1';
const header = document.createElement('div');
header.className = 'flex flex-wrap items-center gap-2 text-sm text-gray-700';
const idEl = document.createElement('span');
idEl.className = 'font-mono text-xs bg-gray-100 px-1 rounded';
idEl.textContent = m.id;
const timeEl = document.createElement('span');
const dt = m.createdDateTime ? new Date(m.createdDateTime) : null;
timeEl.textContent = dt ? dt.toLocaleString() : '';
const fromEl = document.createElement('span');
fromEl.textContent = m.from ? `by ${m.from}` : '';
header.append(idEl, timeEl, fromEl);
const subjectEl = document.createElement('div');
subjectEl.className = 'text-sm font-semibold';
subjectEl.textContent = m.subject || m.summary || '';
// Extract and display Adaptive Card data prominently
const cardWrap = document.createElement('div');
if (Array.isArray(m.attachments) && m.attachments.length) {
m.attachments.forEach(a => {
if (a.contentType === 'application/vnd.microsoft.card.adaptive' && a.content) {
try {
const card = typeof a.content === 'string' ? JSON.parse(a.content) : a.content;
const title = document.createElement('div');
title.className = 'mt-2 text-xs font-semibold text-gray-700';
title.textContent = 'Job Card Details';
cardWrap.appendChild(title);
// Extract key text blocks from card body
if (card.body && Array.isArray(card.body)) {
card.body.forEach((section) => {
if (section.type === 'Container' && Array.isArray(section.items)) {
section.items.forEach((item) => {
if (item.type === 'TextBlock' && item.text) {
const textEl = document.createElement('div');
textEl.className = 'text-sm text-gray-800 mt-1';
textEl.textContent = item.text;
cardWrap.appendChild(textEl);
}
});
}
});
}
// Show full card JSON in a collapsed details element
const cardDetails = document.createElement('details');
cardDetails.className = 'mt-2';
const cardSummary = document.createElement('summary');
cardSummary.className = 'text-xs text-blue-700 cursor-pointer';
cardSummary.textContent = 'Show full Adaptive Card JSON';
const cardJsonPre = document.createElement('pre');
cardJsonPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
cardJsonPre.textContent = JSON.stringify(card, null, 2);
cardDetails.append(cardSummary, cardJsonPre);
cardWrap.appendChild(cardDetails);
} catch (e) {
// If not valid JSON, show raw content
const att = document.createElement('div');
att.className = 'mt-2 text-xs text-gray-700';
att.textContent = `${a.contentType}`;
cardWrap.appendChild(att);
const pre = document.createElement('pre');
pre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-green-50 p-2 rounded border border-green-200';
pre.textContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content, null, 2);
cardWrap.appendChild(pre);
}
}
});
}
// Body content (if present)
const bodyWrap = document.createElement('div');
if (m.bodyContent) {
const bodyType = document.createElement('div');
bodyType.className = 'text-[11px] text-gray-500 mt-2';
bodyType.textContent = `Body Text (${m.bodyContentType || 'unknown'})`;
const bodyText = document.createElement('div');
bodyText.className = 'text-xs text-gray-800 whitespace-pre-wrap';
// Derive plain text from HTML if needed
const tmpDiv = document.createElement('div');
tmpDiv.innerHTML = m.bodyContent || '';
const plain = (m.bodyContentType === 'html') ? (tmpDiv.textContent || '') : (m.bodyContent || '');
bodyText.textContent = plain;
const bodyRaw = document.createElement('pre');
bodyRaw.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-50 p-2 rounded';
bodyRaw.textContent = m.bodyContent || '';
bodyWrap.append(bodyType, bodyText, bodyRaw);
}
// Raw JSON toggle for full fidelity (closed by default)
const details = document.createElement('details');
details.className = 'mt-2';
const summary = document.createElement('summary');
summary.className = 'text-xs text-blue-700 cursor-pointer';
summary.textContent = 'Show raw message JSON';
const rawPre = document.createElement('pre');
rawPre.className = 'mt-1 text-[10px] whitespace-pre-wrap bg-gray-100 p-2 rounded';
rawPre.textContent = JSON.stringify(m.raw || m, null, 2);
details.append(summary, rawPre);
// Keep raw JSON closed by default
details.open = false;
info.append(header, subjectEl, cardWrap, bodyWrap, details);
row.append(checkbox, info);
messagesContainer.append(row);
});
// Auto-select loaded messages if requested
if (params.autoSelect === '1') {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
}
// If ids param provided, select matching ones (comma or newline separated)
if (params.ids) {
const ids = params.ids.split(/[,\n\r\s]+/).filter(Boolean);
const set = new Set(ids);
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (set.has(cb.dataset.messageId)) cb.checked = true;
});
}
}
async function loadMessages() {
savePrefs();
const teamId = teamIdEl.value.trim();
const channelId = channelIdEl.value.trim();
const top = Math.min(parseInt(topEl.value || '50', 10) || 50, 50);
if (!teamId || !channelId) {
statusEl.textContent = 'Enter Team ID and Channel ID.';
return;
}
statusEl.textContent = 'Loading messages...';
try {
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages?top=${top}`;
const headers = {};
const bearer = bearerEl.value.trim();
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { headers });
const data = await resp.json();
if (!data.success) throw new Error(data.message || 'Failed to fetch messages');
renderMessages(data.messages);
statusEl.textContent = `Loaded ${data.count} messages.`;
} catch (err) {
statusEl.textContent = `Error: ${err.message}`;
}
}
async function deleteSelected() {
const teamId = teamIdEl.value.trim();
const channelId = channelIdEl.value.trim();
const checkboxes = messagesContainer.querySelectorAll('input[type="checkbox"]:checked');
const ids = Array.from(checkboxes).map(cb => cb.dataset.messageId);
if (!ids.length) {
deleteSummaryEl.textContent = 'No messages selected.';
return;
}
deleteSummaryEl.textContent = `Deleting ${ids.length}...`;
let ok = 0, fail = 0, teamsLimitationHit = false;
for (const id of ids) {
try {
const url = `/api/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(id)}`;
const headers = {};
const bearer = bearerEl.value.trim();
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { method: 'DELETE', headers });
const data = await resp.json();
if (!data.success) {
if (data.isTeamsLimitation) {
teamsLimitationHit = true;
}
throw new Error(data.message || 'Delete failed');
}
ok++;
} catch (e) {
fail++;
}
}
let summary = `Deleted ${ok}, failed ${fail}.`;
if (teamsLimitationHit) {
summary += ` ⚠️ Note: Microsoft Teams API does not support deletion of channel messages via REST API. `;
summary += `Please delete messages directly from Teams (right-click message → Delete). `;
summary += `Alternatively, use a delegated user token with ChatMessage.ReadWrite.All permission.`;
}
deleteSummaryEl.textContent = summary;
// Only refresh if any deletions succeeded
if (ok > 0) {
await loadMessages();
}
}
loadBtn.addEventListener('click', loadMessages);
selectAllBtn.addEventListener('click', () => {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true);
});
clearSelectionBtn.addEventListener('click', () => {
messagesContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
});
deleteSelectedBtn.addEventListener('click', deleteSelected);
// Global raw JSON toggle
document.getElementById('toggleRaw').addEventListener('change', (e) => {
const open = e.target.checked;
messagesContainer.querySelectorAll('details').forEach(d => {
// Only toggle the raw message JSON details (the last details in each row), not card details
if (d.querySelector('summary').textContent.includes('raw message JSON')) {
d.open = open;
}
});
});
// Auto-load when params present
if (params.teamId && params.channelId) {
// If top is present, ensure numeric cap 50
const t = Math.min(parseInt(params.top || '50', 10) || 50, 50);
topEl.value = String(t);
loadMessages();
}
</script>
</body>
</html>