Mode2Test/AuthAndToken: assume tokens always fresh with background refresh; fix jobs API to use correct collection; align Home UI to Mode1 (exact fields, styling, strict Active dot); add full-field IndexedDB cache w/ pagination; remove progress bar; dev TLS bypass; rebuild bundles
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { getTokens, saveTokens, clearTokens, hasValidPbToken } from './token-service';
|
||||
import { tokenService } from './token-service';
|
||||
|
||||
// Authentication API routes
|
||||
// Endpoints for frontend to check auth status, get tokens, logout
|
||||
// Depends on: tokenService singleton (centralized token management)
|
||||
|
||||
export function createAuthRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
@@ -11,11 +12,13 @@ export function createAuthRoutes(): Hono {
|
||||
// PERMANENT: Check if user is authenticated
|
||||
// Returns current auth status and which tokens are available
|
||||
router.get('/status', async (c: Context) => {
|
||||
const hasValid = await hasValidPbToken();
|
||||
const tokens = await tokenService.getTokens();
|
||||
const hasToken = !!tokens?.pbToken;
|
||||
return c.json({
|
||||
authenticated: hasValid,
|
||||
pbTokenValid: hasValid,
|
||||
message: hasValid ? 'User authenticated' : 'User not authenticated'
|
||||
authenticated: hasToken,
|
||||
pbTokenValid: hasToken,
|
||||
assumedFresh: hasToken,
|
||||
message: hasToken ? 'Assuming tokens are valid (auto-refresh running)' : 'No PB token yet; login once'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,17 +26,14 @@ export function createAuthRoutes(): Hono {
|
||||
// Frontend/backend can call this to get fresh token state
|
||||
// Only returns tokens if PB token is valid (auth required)
|
||||
router.get('/tokens', async (c: Context) => {
|
||||
const hasValid = await hasValidPbToken();
|
||||
if (!hasValid) {
|
||||
return c.json({ error: 'Not authenticated' }, 401);
|
||||
}
|
||||
|
||||
const tokens = await getTokens();
|
||||
const tokens = await tokenService.getTokens();
|
||||
return c.json({
|
||||
pbToken: tokens?.pbToken || null,
|
||||
graphToken: tokens?.graphToken || null,
|
||||
pbTokenExpiry: tokens?.pbTokenExpiry || null,
|
||||
graphTokenExpiry: tokens?.graphTokenExpiry || null
|
||||
graphTokenExpiry: tokens?.graphTokenExpiry || null,
|
||||
lastRefresh: tokens?.lastRefresh || null,
|
||||
assumedFresh: !!tokens?.pbToken
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export function createAuthRoutes(): Hono {
|
||||
return c.json({ error: 'Missing pbToken' }, 400);
|
||||
}
|
||||
|
||||
await saveTokens(pbToken, graphToken);
|
||||
await tokenService.saveTokens(pbToken, graphToken);
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
@@ -66,7 +66,7 @@ export function createAuthRoutes(): Hono {
|
||||
// Called when user clicks logout
|
||||
router.post('/logout', async (c: Context) => {
|
||||
try {
|
||||
await clearTokens();
|
||||
await tokenService.clearTokens();
|
||||
return c.json({
|
||||
success: true,
|
||||
message: 'Logged out',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { tokenService } from './token-service';
|
||||
|
||||
// PERMANENT: Jobs API routes
|
||||
// Provides access to job data from PocketBase
|
||||
// Requires authentication (valid PB token)
|
||||
//
|
||||
// Dependencies:
|
||||
// - tokenService for PB token retrieval
|
||||
// - process.env.POCKETBASE_URL for PB API endpoint
|
||||
const JOBS_COLLECTION = 'pbc_1407612689';
|
||||
//
|
||||
// Endpoints:
|
||||
// - GET /api/jobs - List jobs with pagination, filtering, field selection
|
||||
|
||||
export function createJobsRoutes(): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /api/jobs - Fetch jobs from PocketBase
|
||||
// Query params:
|
||||
// - page: Page number (default 1)
|
||||
// - perPage: Items per page (default 50, max 1000)
|
||||
// - fields: Comma-separated field names to return (optional, for cache optimization)
|
||||
// - filter: PocketBase filter syntax (optional)
|
||||
// - sort: Sort field (default -Job_Number)
|
||||
router.get('/', async (c: Context) => {
|
||||
try {
|
||||
// Get token (assume present after login)
|
||||
const tokens = await tokenService.getTokens();
|
||||
const pbToken = tokens?.pbToken;
|
||||
|
||||
// Parse query params
|
||||
const page = parseInt(c.req.query('page') || '1');
|
||||
const perPage = Math.min(parseInt(c.req.query('perPage') || '50'), 1000);
|
||||
const fields = c.req.query('fields'); // Optional: comma-separated field names
|
||||
const filter = c.req.query('filter'); // Optional: PocketBase filter
|
||||
const sort = c.req.query('sort') || '-Job_Number';
|
||||
|
||||
// Build PocketBase API URL
|
||||
const pbUrl = new URL(`${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records`);
|
||||
pbUrl.searchParams.set('page', page.toString());
|
||||
pbUrl.searchParams.set('perPage', perPage.toString());
|
||||
pbUrl.searchParams.set('sort', sort);
|
||||
|
||||
if (fields) {
|
||||
pbUrl.searchParams.set('fields', fields);
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
pbUrl.searchParams.set('filter', filter);
|
||||
}
|
||||
|
||||
// Fetch from PocketBase
|
||||
// temporary: dev-only SSL bypass; global rejectUnauthorized already set, keep here as explicit
|
||||
const response = await fetch(pbUrl.toString(), {
|
||||
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {},
|
||||
// @ts-ignore Bun TLS option
|
||||
tls: { rejectUnauthorized: false }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('PocketBase jobs fetch failed:', response.status, errorText);
|
||||
return c.json({ error: 'Failed to fetch jobs from PocketBase' }, response.status);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return c.json(data);
|
||||
} catch (err) {
|
||||
console.error('Jobs API error:', (err as Error)?.message);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/jobs/:id - Fetch single job by ID
|
||||
router.get('/:id', async (c: Context) => {
|
||||
try {
|
||||
// Check authentication
|
||||
const tokens = await tokenService.getTokens();
|
||||
const pbToken = tokens?.pbToken;
|
||||
|
||||
const jobId = c.req.param('id');
|
||||
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/${JOBS_COLLECTION}/records/${jobId}`;
|
||||
|
||||
// Fetch from PocketBase
|
||||
const response = await fetch(pbUrl, {
|
||||
headers: pbToken ? { 'Authorization': `Bearer ${pbToken}` } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ error: 'Job not found' }, 404);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return c.json(data);
|
||||
} catch (err) {
|
||||
console.error('Job fetch error:', (err as Error)?.message);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Hono } from 'hono';
|
||||
import { serveStatic } from 'hono/bun';
|
||||
import { join } from 'path';
|
||||
import { initializeTokenService, startBackgroundRefresh } from './token-service';
|
||||
import { tokenService } from './token-service';
|
||||
import { createAuthRoutes } from './auth-routes';
|
||||
import { createJobsRoutes } from './jobs-routes';
|
||||
|
||||
// temporary: dev-only SSL bypass for upstream PocketBase cert issues
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
// PERMANENT: Load environment variables
|
||||
// Check if POCKETBASE_URL is set, use default if not
|
||||
if (!process.env.POCKETBASE_URL) {
|
||||
process.env.POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||
console.log('[Mode2Test] Using default POCKETBASE_URL:', process.env.POCKETBASE_URL);
|
||||
}
|
||||
|
||||
// PERMANENT: Initialize token service and start background refresh
|
||||
// Loads existing tokens from file if available
|
||||
// Starts 5-minute background refresh cycle
|
||||
initializeTokenService();
|
||||
startBackgroundRefresh();
|
||||
tokenService.initialize();
|
||||
tokenService.startBackgroundRefresh();
|
||||
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
@@ -16,6 +27,9 @@ const app = new Hono();
|
||||
// Mount auth routes
|
||||
app.route('/api/auth', createAuthRoutes());
|
||||
|
||||
// Mount jobs routes
|
||||
app.route('/api/jobs', createJobsRoutes());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (c) => {
|
||||
return c.text('OK');
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { join } from 'path';
|
||||
|
||||
// Token storage and refresh management
|
||||
// Stores both PocketBase and Graph tokens locally (tokens.json)
|
||||
// Background refresh keeps both tokens fresh without user intervention
|
||||
// PERMANENT: Token Service Module
|
||||
// Singleton module for centralized token acquisition, storage, and refresh
|
||||
// Available to any part of the application via: import { tokenService } from './token-service.ts'
|
||||
//
|
||||
// Token Lifecycle:
|
||||
// - Tokens stored in tokens.json (persistent across restarts)
|
||||
// - Never delete old token before new one acquired
|
||||
// - Background refresh every 5 minutes
|
||||
// - Startup: if PB token exists, resume refresh; else require login
|
||||
//
|
||||
// Usage:
|
||||
// const tokens = await tokenService.getTokens();
|
||||
// await tokenService.saveTokens(pbToken, graphToken);
|
||||
// await tokenService.initialize();
|
||||
// tokenService.startBackgroundRefresh();
|
||||
|
||||
interface TokenData {
|
||||
pbToken: string;
|
||||
@@ -14,131 +26,151 @@ interface TokenData {
|
||||
|
||||
const tokensFilePath = join(import.meta.dir, '../../tokens.json');
|
||||
|
||||
// PERMANENT: Token lifecycle management
|
||||
// - Tokens stored in tokens.json (persistent across restarts)
|
||||
// - Never delete old token before new one acquired
|
||||
// - Background refresh every 5 minutes
|
||||
// - Startup: if PB token exists, resume refresh; else require login
|
||||
class TokenService {
|
||||
private backgroundStarted = false;
|
||||
|
||||
export async function initializeTokenService(): Promise<void> {
|
||||
// Load tokens from file if they exist
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (await file.exists()) {
|
||||
const content = await file.text();
|
||||
const data = JSON.parse(content) as TokenData;
|
||||
console.log('[AuthAndToken] Tokens loaded from file');
|
||||
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
|
||||
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
|
||||
} else {
|
||||
console.log('[AuthAndToken] No tokens.json found - login required on startup');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackgroundRefresh(): void {
|
||||
// PERMANENT: Background token refresh
|
||||
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
|
||||
// Safe: never deletes old token before new one is confirmed
|
||||
|
||||
setInterval(async () => {
|
||||
async initialize(): Promise<void> {
|
||||
// Load tokens from file if they exist
|
||||
try {
|
||||
await refreshTokens();
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
console.log('[AuthAndToken] Background refresh started (every 5 minutes)');
|
||||
}
|
||||
|
||||
export async function saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
||||
// PERMANENT: Token persistence
|
||||
// Called after login or successful refresh
|
||||
// Stores both tokens with expiry times
|
||||
|
||||
const data: TokenData = {
|
||||
pbToken,
|
||||
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
|
||||
graphToken,
|
||||
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
|
||||
lastRefresh: Date.now()
|
||||
};
|
||||
|
||||
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
|
||||
console.log('[AuthAndToken] Tokens saved');
|
||||
}
|
||||
|
||||
export async function getTokens(): Promise<TokenData | null> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (!(await file.exists())) return null;
|
||||
|
||||
const content = await file.text();
|
||||
return JSON.parse(content) as TokenData;
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearTokens(): Promise<void> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (await file.exists()) {
|
||||
await Bun.write(tokensFilePath, '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTokens(): Promise<void> {
|
||||
// PERMANENT: Auto-refresh logic for both tokens
|
||||
// Called every 5 minutes by background job
|
||||
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
|
||||
// Only updates file after new tokens confirmed valid
|
||||
|
||||
const tokens = await getTokens();
|
||||
if (!tokens || !tokens.pbToken) {
|
||||
console.log('[AuthAndToken] No active tokens to refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Refresh via PocketBase endpoint
|
||||
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
|
||||
const response = await fetch(pbUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokens.pbToken}`
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (await file.exists()) {
|
||||
const content = await file.text();
|
||||
const data = JSON.parse(content) as TokenData;
|
||||
console.log('[AuthAndToken] Tokens loaded from file');
|
||||
console.log('[AuthAndToken] PB token valid:', data.pbToken ? 'yes' : 'no');
|
||||
console.log('[AuthAndToken] Graph token valid:', data.graphToken ? 'yes' : 'no');
|
||||
} else {
|
||||
console.log('[AuthAndToken] No tokens.json found - login required on startup');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Token file init error (will create on first login):', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
startBackgroundRefresh(): void {
|
||||
if (this.backgroundStarted) return;
|
||||
this.backgroundStarted = true;
|
||||
|
||||
// PERMANENT: Background token refresh
|
||||
// Runs every 5 minutes, attempts to refresh both PB and Graph tokens
|
||||
// Safe: never deletes old token before new one is confirmed
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await this.refreshTokens();
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Background refresh error:', (err as Error)?.message);
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
// Kick off an immediate refresh attempt so we start from a fresh token without waiting
|
||||
this.refreshTokens().catch((err) => {
|
||||
console.log('[AuthAndToken] Immediate refresh error:', (err as Error)?.message);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('[AuthAndToken] Refresh failed:', response.status);
|
||||
console.log('[AuthAndToken] Background refresh started (every 5 minutes + immediate)');
|
||||
}
|
||||
|
||||
async saveTokens(pbToken: string, graphToken?: string): Promise<void> {
|
||||
// PERMANENT: Token persistence
|
||||
// Called after login or successful refresh
|
||||
// Stores both tokens with expiry times
|
||||
|
||||
const data: TokenData = {
|
||||
pbToken,
|
||||
pbTokenExpiry: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days (PB default)
|
||||
graphToken,
|
||||
graphTokenExpiry: graphToken ? Date.now() + 60 * 60 * 1000 : undefined, // 1 hour (Graph default)
|
||||
lastRefresh: Date.now()
|
||||
};
|
||||
|
||||
await Bun.write(tokensFilePath, JSON.stringify(data, null, 2));
|
||||
console.log('[AuthAndToken] Tokens saved');
|
||||
}
|
||||
|
||||
async getTokens(): Promise<TokenData | null> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (!(await file.exists())) return null;
|
||||
|
||||
const content = await file.text();
|
||||
return JSON.parse(content) as TokenData;
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error reading tokens:', (err as Error)?.message);
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.unlink(tokensFilePath);
|
||||
console.log('[AuthAndToken] Corrupt tokens file removed');
|
||||
} catch (innerErr) {
|
||||
console.log('[AuthAndToken] Failed to remove corrupt tokens file:', (innerErr as Error)?.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
const file = Bun.file(tokensFilePath);
|
||||
if (await file.exists()) {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.unlink(tokensFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Error clearing tokens:', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
async hasValidPbToken(): Promise<boolean> {
|
||||
// Simplified: treat presence of PB token as valid; no expiry enforcement
|
||||
const tokens = await this.getTokens();
|
||||
return !!tokens?.pbToken;
|
||||
}
|
||||
|
||||
private async refreshTokens(): Promise<void> {
|
||||
// PERMANENT: Auto-refresh logic for both tokens
|
||||
// Called every 5 minutes by background job
|
||||
// Fetches fresh tokens from PocketBase, attempts Graph token refresh
|
||||
// Only updates file after new tokens confirmed valid
|
||||
|
||||
const tokens = await this.getTokens();
|
||||
if (!tokens || !tokens.pbToken) {
|
||||
console.log('[AuthAndToken] No active tokens to refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const newPbToken = data?.token;
|
||||
try {
|
||||
// Refresh via PocketBase endpoint
|
||||
const pbUrl = `${process.env.POCKETBASE_URL}/api/collections/users/auth-refresh`;
|
||||
const response = await fetch(pbUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokens.pbToken}`
|
||||
},
|
||||
// @ts-ignore Bun TLS option
|
||||
tls: { rejectUnauthorized: false }
|
||||
});
|
||||
|
||||
if (!newPbToken) {
|
||||
console.log('[AuthAndToken] No new PB token in response');
|
||||
return;
|
||||
if (!response.ok) {
|
||||
console.log('[AuthAndToken] Refresh failed:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
const newPbToken = data?.token;
|
||||
|
||||
if (!newPbToken) {
|
||||
console.log('[AuthAndToken] No new PB token in response');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tokens with new PB token
|
||||
await this.saveTokens(newPbToken, tokens.graphToken);
|
||||
console.log('[AuthAndToken] Tokens refreshed successfully');
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
|
||||
}
|
||||
|
||||
// Update tokens with new PB token
|
||||
await saveTokens(newPbToken, tokens.graphToken);
|
||||
console.log('[AuthAndToken] Tokens refreshed successfully');
|
||||
} catch (err) {
|
||||
console.log('[AuthAndToken] Refresh error:', (err as Error)?.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasValidPbToken(): Promise<boolean> {
|
||||
const tokens = await getTokens();
|
||||
return !!tokens?.pbToken && tokens.pbTokenExpiry > Date.now();
|
||||
}
|
||||
// PERMANENT: Singleton export
|
||||
// Import as: import { tokenService } from './token-service.ts'
|
||||
export const tokenService = new TokenService();
|
||||
|
||||
Reference in New Issue
Block a user