Add Mode6Test: copy of Mode5Test running on port 3000
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
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
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
Reference in New Issue
Block a user