42ff3e8f33
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled
499 lines
14 KiB
Markdown
499 lines
14 KiB
Markdown
/**
|
|
* REFERENCE TOOL: Svelte + SvelteKit Best Practices & Patterns
|
|
*
|
|
* Every Svelte file follows these patterns. No exceptions.
|
|
*/
|
|
|
|
// ============================================================================
|
|
// PATTERN 1: Svelte Store Pattern
|
|
// ============================================================================
|
|
|
|
/**
|
|
* STORE: authStore
|
|
* PURPOSE: Centralized auth state (user, tokens, login status)
|
|
* UPDATES: Called only through explicit update functions
|
|
* SUBSCRIBERS: Any component needing auth state
|
|
*
|
|
* Files reference this store: components that check auth, routes that require auth
|
|
*/
|
|
|
|
// File: src/stores/auth.ts
|
|
import { writable, derived } from 'svelte/store';
|
|
|
|
/**
|
|
* USER STORE
|
|
* Holds current user info from Microsoft
|
|
*/
|
|
export const user = writable<User | null>(null);
|
|
|
|
/**
|
|
* ACCESS TOKEN STORE
|
|
* Holds current access token (short-lived, 1 hour)
|
|
* RULE: Cleared when token expires
|
|
* RULE: Never persisted to localStorage
|
|
*/
|
|
export const accessToken = writable<string | null>(null);
|
|
|
|
/**
|
|
* TOKEN EXPIRY STORE
|
|
* When does current token expire (timestamp in ms)
|
|
*/
|
|
export const tokenExpiry = writable<number | null>(null);
|
|
|
|
/**
|
|
* DERIVED: Is authenticated?
|
|
* Returns true if user exists and token is not expired
|
|
*/
|
|
export const isAuthenticated = derived(
|
|
[user, tokenExpiry],
|
|
([$user, $expiry]) => {
|
|
return !!$user && $expiry && Date.now() < $expiry;
|
|
}
|
|
);
|
|
|
|
/**
|
|
* UPDATE FUNCTION: Set user after login
|
|
*
|
|
* CALLED BY: OAuth callback handler
|
|
* UPDATES: user, accessToken, tokenExpiry
|
|
* SIDE EFFECTS: Stores token in sessionStorage, sets up refresh timer
|
|
*/
|
|
export async function setAuthUser(
|
|
userInfo: User,
|
|
token: string,
|
|
expiresIn: number
|
|
): Promise<void> {
|
|
// Update stores
|
|
user.set(userInfo);
|
|
accessToken.set(token);
|
|
tokenExpiry.set(Date.now() + expiresIn * 1000);
|
|
|
|
// Store in sessionStorage (safe, lost on browser close)
|
|
sessionStorage.setItem('ms_access_token', token);
|
|
sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
|
|
|
|
// Set up automatic refresh timer (5 minutes before expiry)
|
|
const refreshIn = (expiresIn * 1000) - (5 * 60 * 1000);
|
|
setTimeout(() => {
|
|
refreshAccessToken();
|
|
}, refreshIn);
|
|
|
|
console.log('Auth user set:', userInfo.displayName);
|
|
}
|
|
|
|
/**
|
|
* UPDATE FUNCTION: Refresh token via backend
|
|
*
|
|
* CALLED BY: Automatic timer (5 min before expiry), before API calls
|
|
* SIDE EFFECTS: Updates accessToken, tokenExpiry, sessionStorage
|
|
*/
|
|
export async function refreshAccessToken(): Promise<void> {
|
|
try {
|
|
const response = await fetch('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${get(user)?.id}` },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Token refresh failed
|
|
clearAuth(); // Force re-login
|
|
throw new Error('Token refresh failed');
|
|
}
|
|
|
|
const { accessToken: newToken, expiresIn } = await response.json();
|
|
|
|
// Update stores
|
|
accessToken.set(newToken);
|
|
tokenExpiry.set(Date.now() + expiresIn * 1000);
|
|
sessionStorage.setItem('ms_access_token', newToken);
|
|
sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
|
|
|
|
// Reset refresh timer
|
|
setTimeout(() => {
|
|
refreshAccessToken();
|
|
}, (expiresIn * 1000) - (5 * 60 * 1000));
|
|
|
|
console.log('Access token refreshed');
|
|
} catch (error) {
|
|
console.error('Token refresh error:', error);
|
|
clearAuth();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* UPDATE FUNCTION: Logout
|
|
*
|
|
* CALLED BY: Logout button, auth failures
|
|
* SIDE EFFECTS: Clears all auth stores, sessionStorage, redirects
|
|
*/
|
|
export async function clearAuth(): Promise<void> {
|
|
user.set(null);
|
|
accessToken.set(null);
|
|
tokenExpiry.set(null);
|
|
sessionStorage.removeItem('ms_access_token');
|
|
sessionStorage.removeItem('ms_token_expiry');
|
|
|
|
// Call backend logout (optional, clears refresh token from PocketBase)
|
|
try {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
} catch (e) {
|
|
console.error('Logout API error:', e);
|
|
}
|
|
|
|
console.log('Auth cleared');
|
|
}
|
|
|
|
// ============================================================================
|
|
// PATTERN 2: Svelte Component with Auth Requirement
|
|
// ============================================================================
|
|
|
|
/**
|
|
* COMPONENT: JobList.svelte
|
|
* PURPOSE: Display list of jobs from Microsoft Graph
|
|
* REQUIRES: User to be authenticated
|
|
*
|
|
* Reactive: $isAuthenticated, $jobs, searchQuery
|
|
* Events: None (pure presentation)
|
|
* API calls: Loads jobs via GraphService
|
|
*/
|
|
|
|
// File: src/components/JobList.svelte
|
|
<script lang="ts">
|
|
// Imports - organize by type
|
|
import { onMount } from 'svelte';
|
|
import { isAuthenticated, accessToken } from '../stores/auth';
|
|
import { jobs, jobsLoading, searchJobs } from '../stores/jobs';
|
|
import JobCard from './JobCard.svelte';
|
|
import SearchBar from './SearchBar.svelte';
|
|
|
|
// Props (none in this case)
|
|
export let jobCategory: string = 'all';
|
|
|
|
// Reactive state
|
|
let searchQuery = '';
|
|
let filteredJobs: Job[] = [];
|
|
|
|
// Lifecycle: Load jobs on component mount
|
|
onMount(async () => {
|
|
if (!$isAuthenticated) {
|
|
return; // Don't load if not authenticated
|
|
}
|
|
|
|
jobsLoading.set(true);
|
|
try {
|
|
await searchJobs(jobCategory);
|
|
} catch (error) {
|
|
console.error('Failed to load jobs:', error);
|
|
} finally {
|
|
jobsLoading.set(false);
|
|
}
|
|
});
|
|
|
|
// Reactive: Filter jobs based on search query
|
|
$: filteredJobs = $jobs.filter(job =>
|
|
job.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
job.company.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
// Event handler: Search input
|
|
function handleSearch(e: CustomEvent<string>) {
|
|
searchQuery = e.detail;
|
|
}
|
|
</script>
|
|
|
|
{#if !$isAuthenticated}
|
|
<div class="p-4 bg-yellow-50 text-yellow-800 rounded">
|
|
Please sign in to view jobs
|
|
</div>
|
|
{:else if $jobsLoading}
|
|
<div class="p-4">Loading jobs...</div>
|
|
{:else if filteredJobs.length === 0}
|
|
<div class="p-4">No jobs found matching your search</div>
|
|
{:else}
|
|
<div class="space-y-4">
|
|
<SearchBar on:search={handleSearch} />
|
|
<div class="grid grid-cols-1 gap-4">
|
|
{#each filteredJobs as job (job.id)}
|
|
<JobCard {job} />
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* Use TailwindCSS classes only, no custom CSS needed here */
|
|
</style>
|
|
|
|
// ============================================================================
|
|
// PATTERN 3: API Service Layer
|
|
// ============================================================================
|
|
|
|
/**
|
|
* SERVICE: graphService
|
|
* PURPOSE: Handle all Microsoft Graph API calls with proper headers
|
|
* DEPENDENCIES: accessToken store, error handling
|
|
*
|
|
* Exports: Functions for each Graph API operation
|
|
* Error handling: Throws errors that UI catches
|
|
*/
|
|
|
|
// File: src/services/graphService.ts
|
|
import { get } from 'svelte/store';
|
|
import { accessToken, refreshAccessToken, clearAuth } from '../stores/auth';
|
|
|
|
/**
|
|
* Helper: Ensure token is valid before API call
|
|
*
|
|
* Checks if token expired, refreshes if needed
|
|
* Throws if no token available
|
|
*/
|
|
async function ensureValidToken(): Promise<string> {
|
|
const token = get(accessToken);
|
|
if (!token) {
|
|
throw new Error('No access token available - not authenticated');
|
|
}
|
|
|
|
// Check if token might be expired, refresh proactively
|
|
const expiry = Number(sessionStorage.getItem('ms_token_expiry') || 0);
|
|
if (expiry - Date.now() < 5 * 60 * 1000) {
|
|
// Token expires in less than 5 minutes, refresh now
|
|
await refreshAccessToken();
|
|
}
|
|
|
|
return get(accessToken) || token;
|
|
}
|
|
|
|
/**
|
|
* API CALL: Get list of files in OneDrive
|
|
*
|
|
* Endpoint: GET https://graph.microsoft.com/v1.0/me/drive/root/children
|
|
* Scopes required: Files.Read.All
|
|
* Caching: Results cached in IndexedDB via jobsStore
|
|
*
|
|
* Errors:
|
|
* - 401: Token invalid, user redirected to login
|
|
* - 403: Insufficient permissions
|
|
* - 404: Folder not found
|
|
* - 429: Rate limited, should retry with backoff
|
|
*/
|
|
export async function listFiles(folderId: string = 'root'): Promise<File[]> {
|
|
const token = await ensureValidToken();
|
|
|
|
const endpoint = folderId === 'root'
|
|
? 'https://graph.microsoft.com/v1.0/me/drive/root/children'
|
|
: `https://graph.microsoft.com/v1.0/me/drive/items/${folderId}/children`;
|
|
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
// Token invalid, clear auth and redirect
|
|
clearAuth();
|
|
throw new Error('Session expired - please log in again');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
throw new Error(`Graph API error ${response.status}: ${error.message}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.value || [];
|
|
} catch (error) {
|
|
console.error('Graph API error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PATTERN 4: Data Store (Derived from API)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* STORE: jobs
|
|
* PURPOSE: Cached list of jobs loaded from device storage (IndexedDB)
|
|
* UPDATES: Called by services after fetching from Graph API
|
|
* PERSISTENCE: IndexedDB database
|
|
*
|
|
* STRUCTURE:
|
|
* - jobs: Job[] - Current list of jobs
|
|
* - jobsLoading: boolean - Loading state
|
|
* - jobsError: string | null - Last error if any
|
|
*/
|
|
|
|
// File: src/stores/jobs.ts
|
|
import { writable } from 'svelte/store';
|
|
import { loadJobsFromDB, saveJobsToDBt } from '../services/storageService';
|
|
|
|
export const jobs = writable<Job[]>([]);
|
|
export const jobsLoading = writable(false);
|
|
export const jobsError = writable<string | null>(null);
|
|
|
|
/**
|
|
* Load jobs from device storage
|
|
*
|
|
* Called on app init
|
|
* Populates jobs store from IndexedDB
|
|
*/
|
|
export async function loadJobs(): Promise<void> {
|
|
jobsLoading.set(true);
|
|
try {
|
|
const cachedJobs = await loadJobsFromDB();
|
|
jobs.set(cachedJobs);
|
|
jobsError.set(null);
|
|
} catch (error) {
|
|
console.error('Failed to load jobs:', error);
|
|
jobsError.set((error as Error).message);
|
|
} finally {
|
|
jobsLoading.set(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search jobs locally (no API call needed)
|
|
*
|
|
* All job data is already in device storage
|
|
* This filters locally for instant results
|
|
*/
|
|
export async function searchJobs(query: string): Promise<Job[]> {
|
|
// Get current jobs
|
|
const allJobs = get(jobs);
|
|
|
|
// Filter based on query
|
|
const results = allJobs.filter(job =>
|
|
job.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
job.company.toLowerCase().includes(query.toLowerCase()) ||
|
|
job.description.toLowerCase().includes(query.toLowerCase())
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
// ============================================================================
|
|
// PATTERN 5: Layout Component (Root)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* COMPONENT: +layout.svelte
|
|
* PURPOSE: Root layout for all pages
|
|
* FEATURES: Navigation, auth check, error handling
|
|
*
|
|
* Lifecycle: Checks auth on mount, refreshes token
|
|
*/
|
|
|
|
// File: src/routes/+layout.svelte
|
|
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { isAuthenticated, user, clearAuth } from '../stores/auth';
|
|
import { loadJobs } from '../stores/jobs';
|
|
import Navigation from '../components/Navigation.svelte';
|
|
import ErrorBoundary from '../components/ErrorBoundary.svelte';
|
|
|
|
let error: Error | null = null;
|
|
|
|
onMount(async () => {
|
|
// Load cached jobs on app startup
|
|
try {
|
|
await loadJobs();
|
|
} catch (e) {
|
|
console.error('Failed to load initial jobs:', e);
|
|
error = e as Error;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="min-h-screen bg-gray-50">
|
|
<Navigation />
|
|
|
|
{#if error}
|
|
<ErrorBoundary {error} />
|
|
{/if}
|
|
|
|
<main class="container mx-auto p-4">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
|
|
<style>
|
|
/* Global styles as needed */
|
|
</style>
|
|
|
|
// ============================================================================
|
|
// PATTERN 6: Route Page with Auth Guard
|
|
// ============================================================================
|
|
|
|
/**
|
|
* PAGE: +page.svelte (Home)
|
|
* PURPOSE: Job listing homepage
|
|
* AUTH: Redirects to login if not authenticated
|
|
*/
|
|
|
|
// File: src/routes/+page.svelte
|
|
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { onMount } from 'svelte';
|
|
import { isAuthenticated } from '../stores/auth';
|
|
import JobList from '../components/JobList.svelte';
|
|
|
|
onMount(() => {
|
|
if (!$isAuthenticated) {
|
|
goto('/signin');
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if $isAuthenticated}
|
|
<h1 class="text-3xl font-bold mb-6">Job Listings</h1>
|
|
<JobList />
|
|
{/if}
|
|
|
|
// File: src/routes/signin/+page.svelte (OAuth login page)
|
|
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { isAuthenticated } from '../../stores/auth';
|
|
|
|
onMount(() => {
|
|
if ($isAuthenticated) {
|
|
goto('/'); // Already logged in
|
|
}
|
|
});
|
|
|
|
function handleLogin() {
|
|
// Initiate OAuth flow
|
|
initiateOAuthFlow();
|
|
}
|
|
</script>
|
|
|
|
<div class="flex items-center justify-center min-h-screen">
|
|
<button
|
|
on:click={handleLogin}
|
|
class="px-8 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Sign in with Microsoft
|
|
</button>
|
|
</div>
|
|
|
|
// ============================================================================
|
|
// SUMMARY
|
|
// ============================================================================
|
|
|
|
/**
|
|
* SVELTE PATTERNS - GOLDEN RULES:
|
|
*
|
|
* 1. STORES: Centralize state, update via explicit functions
|
|
* 2. COMPONENTS: Keep reactive, let stores handle state
|
|
* 3. SERVICES: Handle API calls with error handling, never expose tokens
|
|
* 4. LIFECYCLE: Use onMount for async operations
|
|
* 5. DERIVED: Use derived stores for computed state (isAuthenticated)
|
|
* 6. REACTIVITY: Use $store syntax to subscribe in templates
|
|
* 7. PROPS: Always document and type check
|
|
* 8. ERROR HANDLING: Catch all async operations, display to user
|
|
* 9. PERSISTENCE: Use IndexedDB for device storage, sessionStorage for tokens
|
|
* 10. TESTING: Every component independently testable
|
|
*/
|