feat: Complete Mode2Svelte with file viewer and token management
- Implemented Graph token lifecycle management with 60s safety buffer - Added automatic token refresh scheduled 5min before expiry - Fixed token expiration handling with env variable fallback - Resolved SharePoint link parsing for both URL formats (RootFolder param and direct pathname) - Handled URL-encoded spaces in file paths with proper decoding - Implemented internal file viewer modal staying within app context - Added file categorization system (Manager Info, Contracts, Plans, Submittals, Other) - Integrated SVG icons for file types (PDF, Word, Excel, Presentation, Images) - Added search/filter capability for files in folder view - Enhanced auth module with improved button click debugging - Fixed mobile responsiveness for file listing Changes: - Mode2Svelte/src/routes/+page.svelte: Complete component rewrite with token management - Mode2Svelte/src/lib/auth/frontend.ts: Added comprehensive logging for auth flow - Mode2Svelte/src/routes/api/job-files/+server.ts: Created file access endpoint - Mode2Svelte/src/routes/api/jobs/list/+server.ts: Added jobs list endpoint Status: Files now load and display in grouped categories with internal viewer Next: Fix mobile app authentication issue and further refinements
This commit is contained in:
@@ -55,45 +55,77 @@ export class PocketBaseAuth {
|
||||
* Setup login button click handler
|
||||
*/
|
||||
private setupLoginButton(): void {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId);
|
||||
if (!loginBtn) {
|
||||
console.warn(`Login button with id "${this.config.loginBtnId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
loginBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.handleLogin();
|
||||
});
|
||||
console.log('[Auth] Setting up login button with id:', this.config.loginBtnId);
|
||||
|
||||
// Retry finding button in case component hasn't rendered yet
|
||||
let attempts = 0;
|
||||
const setupInterval = setInterval(() => {
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId);
|
||||
console.log(`[Auth] Attempt ${attempts + 1}: Looking for button, found:`, !!loginBtn);
|
||||
|
||||
if (loginBtn) {
|
||||
clearInterval(setupInterval);
|
||||
// Remove any existing listeners to avoid duplicates
|
||||
const newBtn = loginBtn.cloneNode(true) as HTMLElement;
|
||||
loginBtn.parentNode?.replaceChild(newBtn, loginBtn);
|
||||
|
||||
newBtn.addEventListener('click', async (e) => {
|
||||
console.log('[Auth] Login button clicked!');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.handleLogin();
|
||||
}, false);
|
||||
console.log(`✓ Login button listener attached (attempt ${attempts + 1})`);
|
||||
} else if (attempts++ > 100) {
|
||||
// Give up after 10 seconds (100 * 100ms)
|
||||
clearInterval(setupInterval);
|
||||
console.error(`Login button with id "${this.config.loginBtnId}" not found after 10 seconds. All elements:`,
|
||||
Array.from(document.querySelectorAll('[id*="login"]')).map(e => e.id));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login button click
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
console.log('[Auth] handleLogin called');
|
||||
const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement;
|
||||
const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement;
|
||||
|
||||
if (!loginBtn || !loginError) return;
|
||||
if (!loginBtn) {
|
||||
console.error('[Auth] Login button not found in handleLogin!');
|
||||
return;
|
||||
}
|
||||
if (!loginError) {
|
||||
console.error('[Auth] Login error element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Auth] Starting login flow...');
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Checking session...';
|
||||
loginError.classList.add('hidden');
|
||||
|
||||
try {
|
||||
// Try to use existing token first
|
||||
console.log('[Auth] Checking for existing token...');
|
||||
const hadToken = await this.ensureUserLogged();
|
||||
if (hadToken) {
|
||||
console.log('[Auth] Found valid existing token, using it');
|
||||
this.resetLoginButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise perform OAuth
|
||||
console.log('[Auth] No valid token, starting OAuth flow...');
|
||||
loginBtn.textContent = 'Logging in...';
|
||||
console.log('[Auth] Calling authWithOAuth2 with provider:', this.config.provider);
|
||||
const authData = await this.pb.collection(this.config.collection).authWithOAuth2({
|
||||
provider: this.config.provider,
|
||||
});
|
||||
|
||||
console.log('[Auth] OAuth successful, updating state');
|
||||
this.updateState(authData);
|
||||
this.updateAuthUI();
|
||||
this.callbacks.onAuthSuccess?.(this.state);
|
||||
@@ -101,7 +133,7 @@ export class PocketBaseAuth {
|
||||
console.log('✓ Logged in with', this.config.provider);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Login failed:', message);
|
||||
console.error('[Auth] Login failed:', message, error);
|
||||
loginError.textContent = `Login failed: ${message}`;
|
||||
loginError.classList.remove('hidden');
|
||||
this.callbacks.onAuthFailure?.(error);
|
||||
|
||||
+904
-169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
/**
|
||||
* Helper to encode SharePoint links for Graph API /shares endpoint
|
||||
*/
|
||||
function b64Url(input: string) {
|
||||
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fetch JSON from Graph API
|
||||
*/
|
||||
async function fetchJson(url: string, token: string) {
|
||||
console.log('📁 Fetching:', url);
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const err: any = new Error(`Graph ${res.status}: ${text}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a SharePoint folder link to driveId and itemId
|
||||
* Handles both short sharing links and direct document library URLs
|
||||
* Works with both RootFolder parameter format and direct path format
|
||||
*/
|
||||
const resolveShareLink = async (link: string, token: string) => {
|
||||
// Decode the link in case it comes pre-encoded from the database
|
||||
const decodedLink = decodeURIComponent(link);
|
||||
console.log('[resolveShareLink] Decoded link:', decodedLink.substring(0, 150));
|
||||
|
||||
// Check if this is a short sharing link (/:f:/ or /:b:/)
|
||||
if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) {
|
||||
// Short sharing link - use shares API
|
||||
const encoded = `u!${b64Url(decodedLink)}`;
|
||||
console.log('[resolveShareLink] Using shares API, encoded:', encoded);
|
||||
const data = await fetchJson(`${GRAPH_BASE}/shares/${encoded}/driveItem`, token);
|
||||
return { driveId: data.parentReference?.driveId as string, itemId: data.id as string };
|
||||
} else {
|
||||
// Direct document library URL - parse it and use drive API
|
||||
const url = new URL(decodedLink);
|
||||
const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/);
|
||||
if (!pathMatch) throw new Error('Invalid SharePoint URL');
|
||||
|
||||
const hostname = pathMatch[1]; // e.g., 'czflex'
|
||||
|
||||
// Decode pathname to handle spaces properly
|
||||
const decodedPathname = decodeURIComponent(url.pathname);
|
||||
console.log('[resolveShareLink] Decoded pathname:', decodedPathname);
|
||||
|
||||
const sitePath = decodedPathname.split('/Shared Documents')[0]; // e.g., /sites/Team
|
||||
let folderPath: string | null = null;
|
||||
|
||||
// Try RootFolder parameter first (AllItems.aspx format)
|
||||
const rootFolderParam = url.searchParams.get('RootFolder');
|
||||
if (rootFolderParam) {
|
||||
console.log('[resolveShareLink] Found RootFolder parameter');
|
||||
// RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/...
|
||||
// We need: General/Operations [Server]/...
|
||||
folderPath = rootFolderParam.split('/Shared Documents/')[1];
|
||||
if (!folderPath) throw new Error('Could not parse folder path from RootFolder');
|
||||
} else {
|
||||
// No RootFolder - extract from pathname directly
|
||||
// Format: /sites/Team/Shared Documents/General/Operations [Server]/1 Job Pages/...
|
||||
console.log('[resolveShareLink] No RootFolder, parsing from pathname');
|
||||
const sharedDocsIndex = decodedPathname.indexOf('/Shared Documents/');
|
||||
if (sharedDocsIndex !== -1) {
|
||||
const pathAfterSharedDocs = decodedPathname.substring(sharedDocsIndex + '/Shared Documents/'.length);
|
||||
folderPath = pathAfterSharedDocs;
|
||||
console.log('[resolveShareLink] Extracted folderPath from pathname:', folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
throw new Error('Could not extract folder path from URL');
|
||||
}
|
||||
|
||||
console.log('[resolveShareLink] Hostname:', hostname);
|
||||
console.log('[resolveShareLink] Site path:', sitePath);
|
||||
console.log('[resolveShareLink] Folder path:', folderPath);
|
||||
|
||||
// Get the site ID first using hostname:sitePath format
|
||||
const siteData = await fetchJson(`${GRAPH_BASE}/sites/${hostname}.sharepoint.com:${sitePath}`, token);
|
||||
const siteId = siteData.id;
|
||||
console.log('[resolveShareLink] Site ID:', siteId);
|
||||
|
||||
// Get the default document library drive
|
||||
const driveData = await fetchJson(`${GRAPH_BASE}/sites/${siteId}/drive`, token);
|
||||
const driveId = driveData.id;
|
||||
console.log('[resolveShareLink] Drive ID:', driveId);
|
||||
|
||||
// Get the folder item by path - need to properly encode the path
|
||||
const encodedPath = folderPath
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
console.log('[resolveShareLink] Encoded path for Graph API:', encodedPath);
|
||||
|
||||
const itemData = await fetchJson(
|
||||
`${GRAPH_BASE}/drives/${driveId}/root:/${encodedPath}`,
|
||||
token
|
||||
);
|
||||
|
||||
console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id);
|
||||
return { driveId, itemId: itemData.id };
|
||||
}
|
||||
};
|
||||
|
||||
async function listChildrenRecursive(
|
||||
driveId: string,
|
||||
itemId: string,
|
||||
depth = 0,
|
||||
maxDepth = 5,
|
||||
token: string,
|
||||
): Promise<any[]> {
|
||||
const out: any[] = [];
|
||||
const data = await fetchJson(`${GRAPH_BASE}/drives/${driveId}/items/${itemId}/children`, token);
|
||||
for (const child of data.value || []) {
|
||||
out.push(child);
|
||||
if (child.folder && depth < maxDepth) {
|
||||
const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token);
|
||||
out.push(...nested);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByCategory(items: any[], category?: string, pdfOnly: boolean = true) {
|
||||
// First filter: show PDF files and Word documents (which will be converted to PDF)
|
||||
let filtered = items;
|
||||
if (pdfOnly) {
|
||||
filtered = items.filter((i) => {
|
||||
if (i.folder) return false;
|
||||
const name = i.name.toLowerCase();
|
||||
const mimeType = i.file?.mimeType?.toLowerCase() || '';
|
||||
// Include PDFs, Word documents, and images
|
||||
return name.endsWith('.pdf') || mimeType.includes('pdf') ||
|
||||
name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') ||
|
||||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') ||
|
||||
name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') ||
|
||||
name.endsWith('.webp') || mimeType.includes('image');
|
||||
});
|
||||
}
|
||||
|
||||
// Second filter: category filtering (if specified)
|
||||
if (!category) return filtered;
|
||||
const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w));
|
||||
const lc = (s: string) => (s || '').toLowerCase();
|
||||
const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'];
|
||||
const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'];
|
||||
return filtered.filter((i) => {
|
||||
const n = lc(i.name);
|
||||
if (category === 'contracts') return nameHas(n, contractWords);
|
||||
if (category === 'plans') return nameHas(n, planWords);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ url, request }) => {
|
||||
try {
|
||||
const link = url.searchParams.get('link');
|
||||
const category = url.searchParams.get('category');
|
||||
|
||||
if (!link) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'link required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get Graph token - try header first, then fall back to environment variable
|
||||
const headerToken = request.headers.get('x-graph-token') || '';
|
||||
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||
const token = headerToken || envToken;
|
||||
|
||||
console.log('[job-files] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE');
|
||||
console.log('[job-files] Env token:', envToken ? 'SET' : 'NOT SET');
|
||||
console.log('[job-files] Using token:', token ? token.substring(0, 20) + '...' : 'NONE');
|
||||
|
||||
if (!token) {
|
||||
console.log('[job-files] No token found');
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'GRAPH_TOKEN missing' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[job-files] Link:', link);
|
||||
const { driveId, itemId } = await resolveShareLink(link, token);
|
||||
|
||||
// Walk subfolders up to depth 5
|
||||
let items: any[] = [];
|
||||
items = await listChildrenRecursive(driveId, itemId, 0, 5, token);
|
||||
|
||||
// Filter to show only PDFs by default (can be disabled with pdfOnly=false query param)
|
||||
const pdfOnly = url.searchParams.get('pdfOnly') !== 'false';
|
||||
const filtered = filterByCategory(items, category, pdfOnly);
|
||||
console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`);
|
||||
|
||||
const mapped = filtered.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
url: i.webUrl,
|
||||
driveId: i.parentReference?.driveId || driveId,
|
||||
size: i.size,
|
||||
modified: i.lastModifiedDateTime,
|
||||
contentType: i.file?.mimeType,
|
||||
isFolder: Boolean(i.folder),
|
||||
path: i.parentReference?.path || '',
|
||||
}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ items: mapped, total: mapped.length, source: 'walk', driveId }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (err) {
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const status = (err as any)?.status || 500;
|
||||
console.error('[job-files] ERROR:', message);
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: status, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
const record = await pb.collection('pbc_1407612689').getOne(params.id!);
|
||||
|
||||
return new Response(JSON.stringify(record), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Job not found',
|
||||
}),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
const data = await request.json();
|
||||
|
||||
const updated = await pb.collection('pbc_1407612689').update(params.id!, data);
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to update job',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const pb = new PocketBase(PB_DB);
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const perPage = Number(url.searchParams.get('perPage')) || 30;
|
||||
const sort = url.searchParams.get('sort') || '-Job_Number';
|
||||
|
||||
// Build filter query from parameters
|
||||
const filters: string[] = [];
|
||||
const division = url.searchParams.get('division');
|
||||
const active = url.searchParams.get('active');
|
||||
const estimator = url.searchParams.get('estimator');
|
||||
const status = url.searchParams.get('status');
|
||||
const search = url.searchParams.get('search');
|
||||
|
||||
if (division) {
|
||||
filters.push(`Job_Division = "${division}"`);
|
||||
}
|
||||
if (active) {
|
||||
filters.push(`Active = ${active === 'Yes' ? 'true' : 'false'}`);
|
||||
}
|
||||
if (estimator) {
|
||||
filters.push(`Estimator = "${estimator}"`);
|
||||
}
|
||||
if (status) {
|
||||
filters.push(`Job_Status = "${status}"`);
|
||||
}
|
||||
if (search) {
|
||||
filters.push(`(Job_Number ~ "${search}" || Job_Name ~ "${search}" || Company_Client ~ "${search}")`);
|
||||
}
|
||||
|
||||
const filterQuery = filters.length > 0 ? filters.join(' && ') : '';
|
||||
|
||||
const records = await pb.collection('pbc_1407612689').getList(page, perPage, {
|
||||
sort,
|
||||
filter: filterQuery,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(records), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch jobs',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user