@@ -194,12 +197,31 @@
const authed = await ensureAuth();
if (!authed) return;
+ // Make sure a Graph token is available before proceeding; this may refresh silently
+ try {
+ await ensureGraphToken();
+ } catch (err) {
+ console.warn('Graph token missing after refresh; continuing. Preview may prompt for auth.', err?.message || err);
+ }
+
// --- Utility ---
const fetchNoCache = (url, options={}) => {
const headers = { ...(options.headers||{}), ...authHeaders() };
return fetch(url + `&_ts=${Date.now()}`, { ...options, headers });
};
const GRAPH_TOKEN_KEY = 'graphAccessToken';
+ const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
+ const extractGraphToken = (metaObj={}) => (
+ metaObj.graphAccessToken ||
+ metaObj.graph_token ||
+ metaObj.graphToken ||
+ metaObj.accessToken ||
+ metaObj.access_token ||
+ metaObj.token ||
+ metaObj.rawToken ||
+ metaObj?.authData?.access_token ||
+ ''
+ );
const getGraphToken = () => {
// Prefer runtime token already present in PB model/meta, then localStorage
const model = pb?.authStore?.model || {};
@@ -240,6 +262,7 @@
const filtersPanel = document.getElementById('filtersPanel');
const filterToggle = document.getElementById('filterToggle');
const userEmailEl = document.getElementById('userEmail');
+ const signOutBtn = document.getElementById('signOutBtn');
const detailModal = document.getElementById('detailModal');
const detailTable = document.getElementById('detailTable');
@@ -263,6 +286,53 @@
const currentUserEmail = getEmail();
if(userEmailEl && currentUserEmail) userEmailEl.textContent = currentUserEmail;
+
+ // Ensure Graph token is present; refresh if missing; redirect to signin if still absent
+ async function refreshGraphToken(){
+ try {
+ const authData = await pb.collection('users').authRefresh();
+ const meta = authData?.meta || pb?.authStore?.model?.meta || {};
+ const token = extractGraphToken(meta);
+ if (token) localStorage.setItem(GRAPH_TOKEN_KEY, token);
+ return token || '';
+ } catch(err){
+ console.warn('Graph token refresh failed:', err?.message||err);
+ return '';
+ }
+ }
+
+ async function ensureGraphToken(){
+ let token = getGraphToken();
+ if(token) return token;
+ // If PB session is valid, try to refresh; if still missing, redirect to reauth once
+ if (pb?.authStore?.isValid) {
+ token = await refreshGraphToken();
+ if (token) return token;
+ const attempted = localStorage.getItem(GRAPH_REAUTH_FLAG) === '1';
+ if (!attempted) {
+ localStorage.setItem(GRAPH_REAUTH_FLAG, '1');
+ console.warn('Graph token unavailable after refresh; redirecting once to sign-in to obtain a new token.');
+ window.location.href = 'signin.html?reauth=1';
+ throw new Error('GRAPH_TOKEN missing');
+ }
+ console.warn('Graph token unavailable after refresh; reauth already attempted. Proceeding without token.');
+ return '';
+ }
+ // PB session invalid: redirect to signin
+ window.location.href = 'signin.html';
+ throw new Error('GRAPH_TOKEN missing');
+ }
+
+ if (signOutBtn) {
+ signOutBtn.addEventListener('click', () => {
+ try { pb?.authStore?.clear?.(); } catch {}
+ try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
+ try { localStorage.removeItem('pocketbase_auth'); } catch {}
+ try { sessionStorage.clear(); } catch {}
+ location.reload();
+ });
+ }
+
// Load version from backend /version endpoint so UI matches package.json
async function loadVersion(){
if(!versionLabel) return;
@@ -543,6 +613,25 @@
// --- Modal & Notes ---
function openDetail(job){
currentJob = job;
+
+ // Show modal immediately
+ detailModal.classList.remove('hidden');
+ document.getElementById('detailBox').scrollTop = 0;
+ switchTab('info');
+
+ // Show loading state
+ detailTable.innerHTML = '
Loading...
';
+
+ // Preload files in background
+ if (job.Job_Folder_Link) {
+ preloadJobFiles(job).catch(err => console.log('Background preload error:', err));
+ }
+
+ // Populate details asynchronously
+ setTimeout(() => populateDetailContent(job), 0);
+ }
+
+ function populateDetailContent(job){
detailTable.innerHTML = '';
const fields = ["Job_Full_Name","Job_Number","Company_Client","Manager","Contact_Person","Phone_Number","Job_Address","Start_Date","Tax_Exempt","Job_Status","Job_Type"];
let i = 0;
@@ -724,11 +813,6 @@
jobNotes.innerHTML = job.Notes || '';
newNote.innerHTML = '';
tbButtons.forEach(b=>b.classList.remove('bg-blue-100', 'text-gray-900', 'border-blue-600'));
- detailModal.classList.remove('hidden');
- document.getElementById('detailBox').scrollTop = 0;
-
- // Reset to INFO tab when opening
- switchTab('info');
}
const closeModal = ()=>{ detailModal.classList.add('hidden'); renderCards(visibleJobs); };
document.getElementById('btnClose').addEventListener('click',closeModal);
@@ -894,6 +978,49 @@
return 'other';
};
+ // File cache for preloading
+ const fileCache = new Map();
+
+ async function preloadJobFiles(job) {
+ const link = job.Job_Folder_Link;
+ if (!link || fileCache.has(link)) return; // Skip if no link or already cached
+
+ try {
+ const graphToken = await ensureGraphToken();
+ const headers = graphToken ? { 'x-graph-token': graphToken } : {};
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15000);
+ let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
+ clearTimeout(timeout);
+
+ // If unauthorized, try to refresh Graph token once, then retry
+ if (resp.status === 401) {
+ const refreshed = await refreshGraphToken();
+ if (refreshed) {
+ headers['x-graph-token'] = refreshed;
+ const retryCtl = new AbortController();
+ const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
+ resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
+ clearTimeout(retryTimeout);
+ }
+ }
+
+ if (resp.ok) {
+ const data = await resp.json();
+ const rootDriveId = data.driveId || '';
+ const items = (data.items || []).map(f => ({
+ ...f,
+ driveId: f.driveId || rootDriveId
+ }));
+ fileCache.set(link, { items, driveId: rootDriveId });
+ console.log(`Preloaded ${items.length} files for job ${job.Job_Number}`);
+ }
+ } catch (err) {
+ console.log('Preload failed (non-critical):', err);
+ }
+ }
+
const formatSize = (bytes) => {
if (!bytes && bytes !== 0) return '';
if (bytes < 1024) return `${bytes} B`;
@@ -905,14 +1032,29 @@
const getFileIcon = (name, contentType) => {
const ext = (name || '').toLowerCase().split('.').pop();
const type = (contentType || '').toLowerCase();
- if (ext === 'pdf' || type.includes('pdf')) return '📄';
- if (['doc', 'docx'].includes(ext) || type.includes('word')) return '📝';
- if (['xls', 'xlsx'].includes(ext) || type.includes('excel') || type.includes('spreadsheet')) return '📊';
- if (['ppt', 'pptx'].includes(ext) || type.includes('powerpoint') || type.includes('presentation')) return '📽️';
- if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext) || type.includes('image')) return '🖼️';
- if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext) || type.includes('zip') || type.includes('compressed')) return '📦';
- if (['txt', 'csv', 'log'].includes(ext) || type.includes('text')) return '📃';
- return '📎';
+
+ if (ext === 'pdf' || type.includes('pdf'))
+ return '
';
+
+ if (['doc', 'docx'].includes(ext) || type.includes('word'))
+ return '
';
+
+ if (['xls', 'xlsx'].includes(ext) || type.includes('excel') || type.includes('spreadsheet'))
+ return '
';
+
+ if (['ppt', 'pptx'].includes(ext) || type.includes('powerpoint') || type.includes('presentation'))
+ return '
';
+
+ if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'].includes(ext) || type.includes('image'))
+ return '
';
+
+ if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext) || type.includes('zip') || type.includes('compressed'))
+ return '
';
+
+ if (['txt', 'csv', 'log'].includes(ext) || type.includes('text'))
+ return '
';
+
+ return '
';
};
const canPreviewInline = (name, contentType) => {
@@ -1191,7 +1333,7 @@
return `
-
${icon}
+
${icon}
${f.name}
@@ -1224,11 +1366,11 @@
let groupsHost = fileListContainer.querySelector('.file-groups');
if (!groupsHost) {
fileListContainer.innerHTML = `
-
+
-
+
`;
groupsHost = fileListContainer.querySelector('.file-groups');
const searchInput = document.getElementById('fileSearchInput');
@@ -1263,15 +1405,22 @@
fileListState.filter = '';
fileListState.jobNumber = job.Job_Number || '';
- const graphToken = getGraphToken();
- const headers = graphToken ? { 'x-graph-token': graphToken } : {};
- if (!graphToken) console.warn('Graph token not found locally; relying on backend default token if configured.');
-
// Show modal in list mode
iframeViewerModal.classList.remove('hidden');
iframeTitle.textContent = job.Job_Number ? `Job# ${job.Job_Number}` : 'Job Files';
fileListContainer.classList.remove('hidden');
iframeViewer.classList.add('hidden');
+
+ // Check if we have cached files
+ const cached = fileCache.get(link);
+ if (cached) {
+ console.log('Using cached files for job', job.Job_Number);
+ fileListState.items = cached.items;
+ renderFileGroups(link);
+ return;
+ }
+
+ // If not cached, show loading and fetch
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
@@ -1280,27 +1429,73 @@
`;
+ const graphToken = await ensureGraphToken();
+ const headers = graphToken ? { 'x-graph-token': graphToken } : {};
+
try {
- const resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 15000);
+ let resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: controller.signal });
+ clearTimeout(timeout);
+
+ // If unauthorized, try to refresh Graph token once, then retry
+ if (resp.status === 401) {
+ const refreshed = await refreshGraphToken();
+ if (refreshed) {
+ headers['x-graph-token'] = refreshed;
+ const retryCtl = new AbortController();
+ const retryTimeout = setTimeout(() => retryCtl.abort(), 15000);
+ resp = await fetch(`/api/job-files?link=${encodeURIComponent(link)}`, { headers, signal: retryCtl.signal });
+ clearTimeout(retryTimeout);
+ }
+ }
+
+ if (resp.status === 401) throw new Error('AUTH_EXPIRED');
+ if (resp.status === 500) throw new Error('GRAPH_TOKEN missing or expired');
if (!resp.ok) throw new Error(`File list failed: ${resp.status}`);
const data = await resp.json();
const rootDriveId = data.driveId || '';
- fileListState.items = (data.items || []).map(f => ({
+ const items = (data.items || []).map(f => ({
...f,
driveId: f.driveId || rootDriveId
}));
+
+ // Cache the results
+ fileCache.set(link, { items, driveId: rootDriveId });
+ fileListState.items = items;
+
iframeLoader.classList.add('hidden');
renderFileGroups(link);
} catch (err) {
console.error('job-files error', err);
+ const authExpired = err?.message === 'AUTH_EXPIRED' || String(err || '').includes('401');
+ const timedOut = err?.name === 'AbortError';
+ const friendly = authExpired
+ ? 'Your Microsoft Graph session expired. Click Re-authenticate to sign in again.'
+ : timedOut
+ ? 'Request timed out. If this keeps happening, re-authenticate and retry.'
+ : (err?.message || 'Unknown error');
iframeLoader.classList.remove('hidden');
iframeLoader.innerHTML = `
-
-
Unable to load files
-
${err?.message || 'Unknown error'}
-
Open folder instead
+
+
Unable to load files
+
${friendly}
+
`;
+ const reauthBtn = document.getElementById('reauthBtn');
+ if (reauthBtn) {
+ reauthBtn.addEventListener('click', () => {
+ try { pb?.authStore?.clear?.(); } catch {}
+ try { localStorage.removeItem(GRAPH_TOKEN_KEY); } catch {}
+ try { localStorage.removeItem('pocketbase_auth'); } catch {}
+ try { sessionStorage.clear(); } catch {}
+ window.location.href = 'signin.html';
+ });
+ }
}
}
diff --git a/frontend/signin.html b/frontend/signin.html
index 8f29a5d..a0c3792 100644
--- a/frontend/signin.html
+++ b/frontend/signin.html
@@ -49,6 +49,7 @@
const errorEl = document.getElementById('error');
const userInfo = document.getElementById('userInfo');
const continueBtn = document.getElementById('continueBtn');
+ const GRAPH_REAUTH_FLAG = 'graphReauthAttempted';
function goToIndex() {
window.location.href = 'index.html';
@@ -100,15 +101,25 @@
const email = authData?.meta?.rawUser?.email || authData?.record?.email || pb.authStore.model?.email || 'Not available';
const graphToken = extractGraphToken(authData);
console.log('Extracted Graph token:', graphToken ? graphToken.substring(0, 30) + '...' : 'NOT FOUND');
- if (graphToken) localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
+ if (graphToken) {
+ localStorage.setItem(GRAPH_TOKEN_KEY, graphToken);
+ localStorage.removeItem(GRAPH_REAUTH_FLAG);
+ }
showAuthedUI(displayName, email);
}
(async () => {
+ const params = new URLSearchParams(window.location.search);
+ const reauth = params.get('reauth') === '1';
if (pb.authStore.isValid) {
try {
const authData = await pb.collection('users').authRefresh();
displayUserInfo(authData);
+ // If we came from a reauth flow and now have a token, go back automatically
+ const hasToken = localStorage.getItem(GRAPH_TOKEN_KEY);
+ if (reauth && hasToken) {
+ window.location.href = 'index.html';
+ }
} catch (err) {
pb.authStore.clear();
logout();