development #25

Merged
erman merged 4 commits from development into main 2025-09-02 19:37:17 +02:00
8 changed files with 161 additions and 251 deletions

View File

@@ -6,12 +6,14 @@
let { let {
onSuccess, onSuccess,
onError, onError,
onDisconnect,
disabled = false, disabled = false,
size = 'default', size = 'default',
variant = 'primary' variant = 'primary'
} = $props<{ } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary'; variant?: 'primary' | 'secondary';
@@ -21,8 +23,8 @@
let authState = $state(createGoogleAuthState()); let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState); let authManager = new GoogleAuthManager(authState);
onMount(() => { onMount(async () => {
authManager.checkConnection(); await authManager.checkConnection();
}); });
async function handleConnect() { async function handleConnect() {
@@ -41,6 +43,7 @@
async function handleDisconnect() { async function handleDisconnect() {
await authManager.disconnectGoogle(); await authManager.disconnectGoogle();
onDisconnect?.();
} }
// Size classes // Size classes
@@ -57,7 +60,14 @@
}; };
</script> </script>
{#if authState.isConnected} {#if authState.checking}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 border border-gray-300 whitespace-nowrap">
<div class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent text-gray-600"></div>
<span class="text-sm font-medium text-gray-800">Checking connection...</span>
</div>
</div>
{:else if authState.isConnected}
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap"> <div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -30,7 +30,7 @@ export class GoogleAuthManager {
this.state = state; this.state = state;
} }
checkConnection(): void { async checkConnection(): Promise<void> {
this.state.checking = true; this.state.checking = true;
this.state.error = null; this.state.error = null;
@@ -38,12 +38,39 @@ export class GoogleAuthManager {
const token = localStorage.getItem('google_refresh_token'); const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email'); const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token; if (!token) {
this.state.token = token; this.state.isConnected = false;
this.state.userEmail = email; this.state.token = null;
this.state.userEmail = null;
return;
}
// Verify the token by calling our backend endpoint
const response = await fetch('/private/api/google/auth/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: token })
});
if (response.ok) {
this.state.isConnected = true;
this.state.token = token;
this.state.userEmail = email;
} else {
// Token is invalid or expired
await this.disconnectGoogle();
if (response.status === 401) {
this.state.error = 'Google session expired. Please reconnect.';
} else {
this.state.error = 'Failed to verify connection.';
}
}
} catch (error) { } catch (error) {
console.error('Error checking connection:', error); console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status'; this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} finally { } finally {
this.state.checking = false; this.state.checking = false;
} }

View File

@@ -51,25 +51,48 @@ export async function getRecentSpreadsheets(
* Get data from a Google Sheet * Get data from a Google Sheet
* @param refreshToken - Google refresh token * @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet * @param spreadsheetId - ID of the spreadsheet
* @param range - Cell range to retrieve (default: A1:Z10) * @param range - Optional cell range. If not provided, it will fetch the entire first sheet.
* @returns Sheet data as a 2D array * @returns Sheet data as a 2D array
*/ */
export async function getSpreadsheetData( export async function getSpreadsheetData(
refreshToken: string, refreshToken: string,
spreadsheetId: string, spreadsheetId: string,
range: string = 'A1:Z10' range?: string
): Promise<SheetData> { ): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken); const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth }); const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.values.get({ let effectiveRange = range;
spreadsheetId,
range
});
return { // If no range is provided, get the name of the first sheet and use that as the range
values: response.data.values || [] // to fetch all its content.
}; if (!effectiveRange) {
try {
const info = await getSpreadsheetInfo(refreshToken, spreadsheetId);
const firstSheetName = info.sheets?.[0]?.properties?.title;
if (firstSheetName) {
// To use a sheet name as a range, it must be quoted if it contains spaces or special characters.
effectiveRange = `'${firstSheetName}'`;
} else {
// Fallback if sheet name can't be determined.
effectiveRange = 'A1:Z1000'; // A sensible default for a large preview
}
} catch (error) {
console.error(`Failed to get sheet info for spreadsheet ${spreadsheetId}`, error);
// Fallback if the info call fails
effectiveRange = 'A1:Z1000';
}
}
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range: effectiveRange
});
return {
values: response.data.values || []
};
} }
/** /**

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { getAuthenticatedClient } from '$lib/google/auth/server';
/**
* @description Verify the validity of a Google refresh token
* @method POST
* @param {Request} request
* @returns {Response}
*/
export async function POST({ request }: { request: Request }): Promise<Response> {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
// Get an authenticated client. This will attempt to get a new access token,
// which effectively validates the refresh token.
const oauth2Client = getAuthenticatedClient(refreshToken);
// Attempt to get a new access token
await oauth2Client.getAccessToken();
// If no error is thrown, the token is valid
return json({ success: true });
} catch (error) {
console.error('Failed to verify Google refresh token:', error);
// The token is likely invalid or revoked
return json({ error: 'Invalid or expired refresh token' }, { status: 401 });
}
}

View File

@@ -2,17 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js'; import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request, url }) => {
try { try {
const { sheetId } = params; const { sheetId } = params;
const authHeader = request.headers.get('authorization'); const authHeader = request.headers.get('authorization');
const range = url.searchParams.get('range') || undefined;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
} }
const refreshToken = authHeader.slice(7); const refreshToken = authHeader.slice(7);
const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, range);
return json(sheetData); return json(sheetData);
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts'; import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
@@ -16,19 +15,11 @@
let { data } = $props(); let { data } = $props();
// Step management // Step management
let currentStep = $state(0); // Start at step 0 for Google auth check let currentStep = $state(0);
const totalSteps = 4; // Increased to include auth step const totalSteps = 4;
// Step 0: Google Auth // Step 0: Google Auth
let authData = $state({ let isGoogleConnected = $state(false);
isConnected: false,
checking: true,
connecting: false,
showCancelOption: false,
token: null as string | null,
error: null as string | null,
userEmail: null as string | null
});
// Step 1: Event Details // Step 1: Event Details
let eventData = $state({ let eventData = $state({
@@ -42,13 +33,13 @@
selectedSheet: null as GoogleSheet | null, selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][], sheetData: [] as string[][],
columnMapping: { columnMapping: {
name: 0, // Initialize to 0 (no column selected) name: 0,
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
}, },
loading: false, loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion expandedSheetList: true
}); });
// Step 3: Email // Step 3: Email
@@ -62,189 +53,11 @@
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
onMount(async () => { onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) { if (currentStep === 2) {
await loadRecentSheets(); await loadRecentSheets();
} }
}); });
// Google Auth functions
async function checkGoogleAuth() {
authData.checking = true;
try {
const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) {
// Check if token is still valid
const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid;
authData.token = accessToken;
if (isValid) {
// Fetch user info
await fetchUserInfo(accessToken);
}
} else {
authData.isConnected = false;
authData.userEmail = null;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
authData.userEmail = null;
} finally {
authData.checking = false;
}
}
async function connectToGoogle() {
authData.error = '';
authData.connecting = true;
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) +
',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
authData.error = 'Failed to open popup window. Please allow popups for this site.';
authData.connecting = false;
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
let cancelTimeout: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state
setTimeout(checkGoogleAuth, 100);
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
// Only check if auth isn't already completed
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
setTimeout(checkGoogleAuth, 100);
}
}, 30 * 1000) as unknown as number; // Reduced from 60s to 30s
// Show cancel option sooner
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 10 * 1000) as unknown as number; // Reduced from 20s to 10s
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
authData.connecting = false;
}
}
function cancelGoogleAuth() {
authData.connecting = false;
authData.showCancelOption = false;
}
async function fetchUserInfo(accessToken: string) {
try {
// Use the new getUserInfo function from our lib
const userData = await getUserInfo(accessToken);
if (userData) {
authData.userEmail = userData.email;
} else {
authData.userEmail = null;
}
} catch (error) {
console.error('Error fetching user info:', error);
authData.userEmail = null;
}
}
async function disconnectGoogle() {
try {
// First revoke the token at Google using our API
const accessToken = localStorage.getItem('google_access_token');
if (accessToken) {
await revokeToken(accessToken);
}
// Remove tokens from local storage
localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token');
// Update auth state
authData.isConnected = false;
authData.token = null;
authData.userEmail = null;
// Clear any selected sheets data
sheetsData.availableSheets = [];
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
} catch (error) {
console.error('Error disconnecting from Google:', error);
authData.error = 'Failed to disconnect from Google';
}
}
// Step navigation // Step navigation
function nextStep() { function nextStep() {
if (validateCurrentStep()) { if (validateCurrentStep()) {
@@ -265,7 +78,7 @@
let isValid = true; let isValid = true;
if (currentStep === 0) { if (currentStep === 0) {
if (!authData.isConnected) { if (!isGoogleConnected) {
toast.error('Please connect your Google account to continue'); toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue'; errors.auth = 'Please connect your Google account to continue';
return false; return false;
@@ -359,8 +172,8 @@
} }
try { try {
// Use the new unified API endpoint // Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
@@ -437,13 +250,13 @@
// Computed values // Computed values
let canProceed = $derived(() => { let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected; if (currentStep === 0) return isGoogleConnected;
if (currentStep === 1) return eventData.name && eventData.date; if (currentStep === 1) return !!(eventData.name && eventData.date);
if (currentStep === 2) { if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping; const { name, surname, email, confirmation } = sheetsData.columnMapping;
return sheetsData.selectedSheet && name && surname && email && confirmation; return !!(sheetsData.selectedSheet && name && surname && email && confirmation);
} }
if (currentStep === 3) return emailData.subject && emailData.body; if (currentStep === 3) return !!(emailData.subject && emailData.body);
return false; return false;
}); });
</script> </script>
@@ -455,16 +268,9 @@
<div class="mb-4 rounded border border-gray-300 bg-white p-6"> <div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0} {#if currentStep === 0}
<GoogleAuthStep <GoogleAuthStep
onSuccess={(token) => { onSuccess={() => (isGoogleConnected = true)}
authData.error = null; onDisconnect={() => (isGoogleConnected = false)}
authData.token = token; onError={(err) => toast.error(err)}
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
/> />
{:else if currentStep === 1} {:else if currentStep === 1}
<EventDetailsStep bind:eventData /> <EventDetailsStep bind:eventData />
@@ -485,7 +291,7 @@
<StepNavigation <StepNavigation
{currentStep} {currentStep}
{totalSteps} {totalSteps}
{canProceed} canProceed={canProceed()}
{loading} {loading}
{prevStep} {prevStep}
{nextStep} {nextStep}

View File

@@ -2,9 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { onSuccess, onError } = $props<{ let { onSuccess, onError, onDisconnect } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
}>(); }>();
</script> </script>
@@ -15,11 +16,12 @@
To create events and import participants from Google Sheets, you need to connect your Google account. To create events and import participants from Google Sheets, you need to connect your Google account.
</p> </p>
<GoogleAuthButton <GoogleAuthButton
size="large" size="large"
variant="primary" variant="primary"
onSuccess={onSuccess} {onSuccess}
onError={onError} {onError}
/> {onDisconnect}
/>
</div> </div>
</div> </div>

View File

@@ -115,6 +115,8 @@
} }
syncingParticipants = true; syncingParticipants = true;
const previousCount = participants.length; // Capture count before sync
try { try {
// Fetch sheet data // Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, { const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -177,16 +179,23 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with count of synced participants // Show success message with accurate count of changes
const previousCount = participants.length; const newCount = participants.length;
const newCount = names.length; const diff = newCount - previousCount;
const addedCount = Math.max(0, participants.length - previousCount); const processedCount = names.length;
toast.success( let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000 if (diff > 0) {
); message += ` ${diff} new participants added.`;
} else if (diff < 0) {
message += ` ${-diff} participants removed.`;
} else {
message += ` No changes to the participant list.`;
}
toast.success(message, 6000);
} catch (err) { } catch (err) {
console.error('Error syncing participants:', err); console.error('Error syncing participants:', err);
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`); toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);