Implemented sync functionality with sheets and email sending

This commit is contained in:
Roman Krček
2025-07-08 15:30:37 +02:00
parent 39bd172798
commit 5bd642b947
10 changed files with 1208 additions and 65 deletions

View File

@@ -98,6 +98,122 @@ export async function getUserInfo(accessToken: string): Promise<{ email: string;
}
}
/**
* Authenticate with Google using OAuth popup flow
* @returns Authentication result with success status and tokens
*/
export async function authenticateWithGoogle(): Promise<{
success: boolean;
refreshToken?: string;
userEmail?: string;
error?: string;
}> {
if (!browser) {
return { success: false, error: 'Not in browser environment' };
}
return new Promise((resolve) => {
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) {
resolve({ success: false, error: 'Failed to open popup window. Please allow popups for this site.' });
return;
}
let authCompleted = false;
let popupTimer: 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;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
// Get tokens from localStorage
const refreshToken = localStorage.getItem('google_refresh_token');
const userEmail = localStorage.getItem('google_user_email');
if (refreshToken) {
resolve({
success: true,
refreshToken,
userEmail: userEmail ?? undefined
});
} else {
resolve({ success: false, error: 'No refresh token found after authentication' });
}
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
const refreshToken = localStorage.getItem('google_refresh_token');
const userEmail = localStorage.getItem('google_user_email');
if (refreshToken) {
resolve({
success: true,
refreshToken,
userEmail: userEmail ?? undefined
});
} else {
resolve({ success: false, error: 'Authentication timeout or cancelled' });
}
}
}, 30 * 1000) as unknown as number;
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
resolve({ success: false, error: 'Authentication timeout' });
}
}, 60 * 1000);
} catch (error) {
console.error('Error connecting to Google:', error);
resolve({ success: false, error: error instanceof Error ? error.message : 'Unknown error' });
}
});
}
/**
* Revoke a Google access token
* @param accessToken - Google access token to revoke

View File

@@ -0,0 +1,120 @@
import { authenticateWithGoogle } from '$lib/google/auth/client.js';
export interface GoogleAuthState {
isConnected: boolean;
checking: boolean;
connecting: boolean;
showCancelOption: boolean;
token: string | null;
error: string | null;
userEmail: string | null;
}
export function createGoogleAuthState(): GoogleAuthState {
return {
isConnected: false,
checking: false,
connecting: false,
showCancelOption: false,
token: null,
error: null,
userEmail: null
};
}
export class GoogleAuthManager {
private readonly state: GoogleAuthState;
private cancelTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(state: GoogleAuthState) {
this.state = state;
}
checkConnection(): void {
this.state.checking = true;
this.state.error = null;
try {
const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token;
this.state.token = token;
this.state.userEmail = email;
} catch (error) {
console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status';
} finally {
this.state.checking = false;
}
}
async connectToGoogle(): Promise<void> {
if (this.state.connecting) return;
this.state.connecting = true;
this.state.error = null;
this.state.showCancelOption = false;
// Show cancel option after 5 seconds
this.cancelTimeout = setTimeout(() => {
this.state.showCancelOption = true;
}, 5000);
try {
const result = await authenticateWithGoogle();
if (result.success && result.refreshToken) {
// Store tokens
localStorage.setItem('google_refresh_token', result.refreshToken);
if (result.userEmail) {
localStorage.setItem('google_user_email', result.userEmail);
}
// Update state
this.state.isConnected = true;
this.state.token = result.refreshToken;
this.state.userEmail = result.userEmail;
} else {
throw new Error(result.error ?? 'Authentication failed');
}
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Failed to connect to Google';
} finally {
this.state.connecting = false;
this.state.showCancelOption = false;
if (this.cancelTimeout) {
clearTimeout(this.cancelTimeout);
this.cancelTimeout = null;
}
}
}
cancelGoogleAuth(): void {
this.state.connecting = false;
this.state.showCancelOption = false;
this.state.error = null;
if (this.cancelTimeout) {
clearTimeout(this.cancelTimeout);
this.cancelTimeout = null;
}
}
async disconnectGoogle(): Promise<void> {
try {
// Clear local storage
localStorage.removeItem('google_refresh_token');
localStorage.removeItem('google_user_email');
// Reset state
this.state.isConnected = false;
this.state.token = null;
this.state.userEmail = null;
this.state.error = null;
} catch (error) {
console.error('Error disconnecting:', error);
this.state.error = 'Failed to disconnect';
}
}
}

View File

@@ -1,14 +1,14 @@
import { google } from 'googleapis';
import quotedPrintable from 'quoted-printable';
import { getAuthenticatedClient } from '../auth/server.js';
import { getOAuthClient } from '../auth/server.js';
/**
* Create an HTML email template
* Create an HTML email template with ScanWave branding
* @param text - Email body text
* @returns HTML email template
*/
export function createEmailTemplate(text: string): string {
return `<!DOCTYPE html>
return `<!DOCTYPE html>
<html lang="en">
<head>
<style>
@@ -16,73 +16,79 @@ export function createEmailTemplate(text: string): string {
</style>
</head>
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;">
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px;">
<p style="white-space: pre-line; font-size: 16px; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; height: auto;" />
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of ScanWave</p>
</div>
</div>
</body>
</html>`;
}
/**
* Send an email through Gmail
* Send an email through Gmail with QR code
* @param refreshToken - Google refresh token
* @param params - Email parameters (to, subject, text, qr_code)
*/
export async function sendGmail(
refreshToken: string,
{
to,
subject,
text,
qr_code
}: {
to: string;
subject: string;
text: string;
qr_code: string;
}
refreshToken: string,
{ to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
) {
const oauth = getAuthenticatedClient(refreshToken);
const gmail = google.gmail({ version: 'v1', auth: oauth });
const message_html = createEmailTemplate(text);
const boundary = 'BOUNDARY';
const nl = '\r\n';
const oauth = getOAuthClient();
oauth.setCredentials({ refresh_token: refreshToken });
const htmlBuffer = Buffer.from(message_html, 'utf8');
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const gmail = google.gmail({ version: 'v1', auth: oauth });
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'--' + boundary,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
'--' + boundary,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
'--' + boundary + '--',
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
const message_html = createEmailTemplate(text);
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw }
});
const boundary = 'BOUNDARY';
const nl = '\r\n'; // RFC-5322 line ending
// Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode
const htmlBuffer = Buffer.from(message_html, 'utf8');
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [
'MIME-Version: 1.0',
`To: ${to}`,
`Subject: ${subject}`,
`Content-Type: multipart/related; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: quoted-printable',
'',
htmlQP,
'',
`--${boundary}`,
'Content-Type: image/png',
'Content-Transfer-Encoding: base64',
'Content-ID: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
`--${boundary}--`,
''
];
const rawMessage = rawParts.join(nl);
const raw = Buffer.from(rawMessage).toString('base64url');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw }
});
}