diff --git a/.env.example b/.env.example index e3df937..32ce1d1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ PUBLIC_SUPABASE_URL=https://abc.supabase.co -PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s \ No newline at end of file +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s + +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:5173 \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c53c216..084af10 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,4 +85,89 @@ NEVER $: label syntax; use $state(), $derived(), and $effect(). If you want to use supabse client in the browser, it is stored in the data variable obtained from let { data } = $props(); -Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead \ No newline at end of file +Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead + +onsubmit|preventDefault={handleSubmit} is depracated, do not use it! + +Loading session using page.server.ts is not needed as the session is already available in the locals object. + +Do not use import { page } from '$app/stores'; as it is deprecated! Use instead: import { page } from '$app/state'; + +IMPORTANT: Always make sure that the client-side module are not importing secrets +or are running any sensritive code that could expose secrets to the client. +If any requests are needed to check sensitive infomration, create an api route and +fetch data from there instead of directly in the client-side module. + +The database schema in supabase is as follows: +-- WARNING: This schema is for context only and is not meant to be run. +-- Table order and constraints may not be valid for execution. +-- WARNING: This schema is for context only and is not meant to be run. +-- Table order and constraints may not be valid for execution. + +CREATE TABLE public.events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + created_by uuid DEFAULT auth.uid(), + name text, + date date, + section_id uuid, + email_subject text, + email_body text, + sheet_id text, + name_column numeric, + surname_column numeric, + email_column numeric, + confirmation_column numeric, + CONSTRAINT events_pkey PRIMARY KEY (id), + CONSTRAINT events_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id), + CONSTRAINT events_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.events_archived ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + date date, + name text NOT NULL, + total_participants numeric, + scanned_participants numeric, + section_id uuid, + CONSTRAINT events_archived_pkey PRIMARY KEY (id), + CONSTRAINT events_archived_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.participants ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + created_by uuid DEFAULT auth.uid(), + event uuid, + name text, + surname text, + email text, + scanned boolean DEFAULT false, + scanned_at timestamp with time zone, + scanned_by uuid, + section_id uuid, + email_sent boolean DEFAULT false, + CONSTRAINT participants_pkey PRIMARY KEY (id), + CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id), + CONSTRAINT participants_event_fkey FOREIGN KEY (event) REFERENCES public.events(id), + CONSTRAINT participants_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES public.profiles(id), + CONSTRAINT qrcodes_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES auth.users(id), + CONSTRAINT qrcodes_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.profiles ( + id uuid NOT NULL, + display_name text, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + section_id uuid, + section_position USER-DEFINED NOT NULL DEFAULT 'member'::section_posititon, + CONSTRAINT profiles_pkey PRIMARY KEY (id), + CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id), + CONSTRAINT profiles_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.sections ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + name text NOT NULL UNIQUE, + CONSTRAINT sections_pkey PRIMARY KEY (id) +); + diff --git a/package-lock.json b/package-lock.json index 08b4194..914a67e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", + "supabase": "^2.30.4", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", @@ -1515,6 +1516,23 @@ "node": "*" } }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1605,6 +1623,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2149,6 +2177,16 @@ "node": ">= 14" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2643,6 +2681,16 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2888,6 +2936,16 @@ } } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -2932,6 +2990,16 @@ "quoted-printable": "bin/quoted-printable" } }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3136,6 +3204,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-icons": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.3.0.tgz", @@ -3204,6 +3285,26 @@ "node": ">=8" } }, + "node_modules/supabase": { + "version": "2.30.4", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.30.4.tgz", + "integrity": "sha512-AOCyd2vmBBMTXbnahiCU0reRNxKS4n5CrPciUF2tcTrQ8dLzl1HwcLfe5DrG8E0QRcKHPDdzprmh/2+y4Ta5MA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^5.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.4.3" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3265,13 +3366,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-kit": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/svelte-kit/-/svelte-kit-1.2.0.tgz", - "integrity": "sha512-RRaOHBhpDv4g2v9tcq8iNw055Pt0MlLps6JVA7/40f4KAbtztXSI4T6MZYbHRirO708urfAAMx6Qow+tQfCHug==", - "hasInstallScript": true, - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", @@ -3551,6 +3645,20 @@ "node": ">=8" } }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", diff --git a/package.json b/package.json index e904f68..b069470 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", + "supabase": "^2.30.4", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", diff --git a/src/lib/components/GoogleAuthButton.svelte b/src/lib/components/GoogleAuthButton.svelte new file mode 100644 index 0000000..928fcbd --- /dev/null +++ b/src/lib/components/GoogleAuthButton.svelte @@ -0,0 +1,117 @@ + + +{#if authState.isConnected} +
+
+ + + + Connected +
+ + {#if authState.userEmail} +
+ + + + {authState.userEmail} +
+ {/if} + + +
+{:else} +
+ + + {#if authState.error} +
+ {authState.error} +
+ {/if} +
+{/if} diff --git a/src/lib/google/auth/client.ts b/src/lib/google/auth/client.ts new file mode 100644 index 0000000..1a5c535 --- /dev/null +++ b/src/lib/google/auth/client.ts @@ -0,0 +1,237 @@ +import { browser } from '$app/environment'; + +// Client-side only functions +export const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' +]; + +/** + * Initialize Google Auth (placeholder for client-side) + */ +export async function initGoogleAuth(): Promise { + if (!browser) return; + // Google Auth initialization is handled by the OAuth flow + // No initialization needed for our server-side approach +} + +/** + * Get the Google Auth URL + * @returns URL for Google OAuth + */ +export function getAuthUrl(): string { + if (!browser) return ''; + // This should be obtained from the server + return '/auth/google'; +} + +/** + * Check if an access token is valid + * @param accessToken - Google access token to validate + * @returns True if the token is valid + */ +export async function isTokenValid(accessToken: string): Promise { + if (!browser) return false; + + try { + const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`); + const data = await response.json(); + + if (response.ok && data.expires_in && data.expires_in > 0) { + return true; + } + return false; + } catch (error) { + console.error('Error validating token:', error); + return false; + } +} + +/** + * Refresh an access token using the refresh token + * @param refreshToken - Google refresh token + * @returns New access token or null if failed + */ +export async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + return data.accessToken; + } + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } +} + +/** + * Get Google user information + * @param accessToken - Google access token + * @returns User info including email, name, and picture + */ +export async function getUserInfo(accessToken: string): Promise<{ email: string; name: string; picture: string } | null> { + try { + const response = await fetch('/private/api/google/auth/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Error fetching user info:', error); + return null; + } +} + +/** + * 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 + * @returns True if revocation was successful + */ +export async function revokeToken(accessToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ accessToken }) + }); + + return response.ok; + } catch (error) { + console.error('Error revoking token:', error); + return false; + } +} diff --git a/src/lib/google/auth/manager.ts b/src/lib/google/auth/manager.ts new file mode 100644 index 0000000..f87901e --- /dev/null +++ b/src/lib/google/auth/manager.ts @@ -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 | 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 { + 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 { + 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'; + } + } +} diff --git a/src/lib/google/auth/server.ts b/src/lib/google/auth/server.ts new file mode 100644 index 0000000..6e937ae --- /dev/null +++ b/src/lib/google/auth/server.ts @@ -0,0 +1,57 @@ +import { google } from 'googleapis'; +import { env } from '$env/dynamic/private'; + +// Define OAuth scopes for the Google APIs we need to access +export const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' +]; + +/** + * Create a new OAuth2 client instance + * @returns Google OAuth2 client + */ +export function getOAuthClient() { + return new google.auth.OAuth2( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.GOOGLE_REDIRECT_URI + ); +} + +/** + * Create a authentication URL for OAuth flow + * @returns Auth URL for Google OAuth + */ +export function createAuthUrl() { + return getOAuthClient().generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: scopes, + redirect_uri: env.GOOGLE_REDIRECT_URI + }); +} + +/** + * Exchange the authorization code for access and refresh tokens + * @param code - Authorization code from OAuth callback + * @returns Access and refresh tokens + */ +export async function exchangeCodeForTokens(code: string) { + const { tokens } = await getOAuthClient().getToken(code); + if (!tokens.refresh_token) throw new Error('No refresh_token returned'); + return tokens; +} + +/** + * Get an authenticated client using a refresh token + * @param refreshToken - Refresh token for authentication + * @returns Authenticated OAuth2 client + */ +export function getAuthenticatedClient(refreshToken: string) { + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + return oauth; +} diff --git a/src/lib/google/client.ts b/src/lib/google/client.ts new file mode 100644 index 0000000..74da0a6 --- /dev/null +++ b/src/lib/google/client.ts @@ -0,0 +1,13 @@ +/** + * Google API integration module + * + * This module provides utilities for interacting with Google APIs: + * - Authentication (server and client-side) + * - Sheets API + */ + +// Google service modules +export * as googleAuthClient from './auth/client.ts'; + +export * as googleSheetsClient from './sheets/client.ts'; + diff --git a/src/lib/google.ts b/src/lib/google/gmail/server.ts similarity index 77% rename from src/lib/google.ts rename to src/lib/google/gmail/server.ts index 792deb2..9130843 100644 --- a/src/lib/google.ts +++ b/src/lib/google/gmail/server.ts @@ -1,45 +1,14 @@ import { google } from 'googleapis'; -import { env } from '$env/dynamic/private'; -import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package +import quotedPrintable from 'quoted-printable'; +import { getOAuthClient } from '../auth/server.js'; -export const scopes = [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/userinfo.email' -]; - -export function getOAuthClient() { - return new google.auth.OAuth2( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - env.GOOGLE_REDIRECT_URI - ); -} - -export function createAuthUrl() { - return getOAuthClient().generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: scopes - }); -} - -export async function exchangeCodeForTokens(code: string) { - const { tokens } = await getOAuthClient().getToken(code); - if (!tokens.refresh_token) throw new Error('No refresh_token returned'); - return tokens.refresh_token; -} - -export async function sendGmail( - refreshToken: string, - { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } -) { - const oauth = getOAuthClient(); - oauth.setCredentials({ refresh_token: refreshToken }); - - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = - ` +/** + * Create an HTML email template with ScanWave branding + * @param text - Email body text + * @returns HTML email template + */ +export function createEmailTemplate(text: string): string { + return ` + + +
+
✓ Authentication successful!
+
Closing window...
+
+ + +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html' + } + }); + } catch (error) { + console.error('Error handling Google OAuth callback:', error); + throw redirect(302, '/private/events?error=google_auth_failed'); + } +}; diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts index ca0e7d4..eea6c9b 100644 --- a/src/routes/private/api/gmail/+server.ts +++ b/src/routes/private/api/gmail/+server.ts @@ -1,79 +1,154 @@ +import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { json, redirect } from '@sveltejs/kit'; -import { - createAuthUrl, - exchangeCodeForTokens, - sendGmail, - getOAuthClient -} from '$lib/google'; +import { sendGmail } from '$lib/google/gmail/server.js'; +import QRCode from 'qrcode'; -/* ───────────── GET ───────────── */ -export const GET: RequestHandler = async ({ url }) => { - /* 1. /private/api/gmail?action=auth → 302 to Google */ - if (url.searchParams.get('action') === 'auth') { - throw redirect(302, createAuthUrl()); - } +interface Participant { + id: string; + name: string; + surname: string; + email: string; +} - /* 2. Google callback /private/api/gmail?code=XXXX */ - const code = url.searchParams.get('code'); - if (code) { +interface EmailResult { + participant: Participant; + success: boolean; + error?: string; +} + +async function generateQRCode(participantId: string): Promise { + const qrCodeBase64 = await QRCode.toDataURL(participantId, { + type: 'image/png', + margin: 2, + scale: 8 + }); + + // Remove the data URL prefix to get just the base64 string + return qrCodeBase64.replace(/^data:image\/png;base64,/, ''); +} + +async function sendEmailToParticipant( + participant: Participant, + subject: string, + text: string, + eventId: string, + refreshToken: string, + supabase: any +): Promise { + try { + const qrCodeBase64Data = await generateQRCode(participant.id); + + // Send email with QR code + await sendGmail(refreshToken, { + to: participant.email, + subject: subject, + text: text, + qr_code: qrCodeBase64Data + }); + + + // Call the participant_emailed RPC function try { - const refreshToken = await exchangeCodeForTokens(code); - - const html = ` - `; - return new Response(html, { headers: { 'Content-Type': 'text/html' } }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); + await supabase.rpc('participant_emailed', { + p_participant_id: participant.id + }); + } catch (dbError) { + console.error('Failed to call participant_emailed RPC:', dbError); + // Don't fail the entire operation if the RPC call fails } + + return { + participant: participant, + success: true + }; + } catch (error) { + console.error('Failed to send email to participant:', participant.email, error); + return { + participant: participant, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +function validateRequest(participants: unknown, subject: unknown, text: unknown, eventId: unknown, refreshToken: unknown) { + if (!participants || !Array.isArray(participants)) { + return { error: 'Invalid participants array', status: 400 }; } - return new Response('Bad request', { status: 400 }); -}; - -/* ───────────── POST ───────────── */ -export const POST: RequestHandler = async ({ request }) => { - const { action, refreshToken, to, subject, text, qr_code } = await request.json(); - - /* send e-mail */ - if (action === 'send') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await sendGmail(refreshToken, { to, subject, text, qr_code }); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* revoke token */ - if (action === 'revoke') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await getOAuthClient().revokeToken(refreshToken); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* validate token */ - if (action === 'validate') { - if (!refreshToken) { - return json({ valid: false }); - } - try { - const oAuth2Client = getOAuthClient(); - oAuth2Client.setCredentials({ refresh_token: refreshToken }); - await oAuth2Client.getAccessToken(); // This will throw if invalid - return json({ valid: true }); - } catch (err) { - return json({ valid: false, error: (err as Error).message }); - } - } - - return new Response('Bad request', { status: 400 }); + if (!subject || !text) { + return { error: 'Subject and text are required', status: 400 }; + } + + if (!eventId) { + return { error: 'Event ID is required', status: 400 }; + } + + if (!refreshToken || typeof refreshToken !== 'string') { + return { error: 'Refresh token is required', status: 401 }; + } + + return null; +} + +export const POST: RequestHandler = async ({ request, locals }) => { + try { + const { participants, subject, text, eventId, refreshToken } = await request.json(); + + const validationError = validateRequest(participants, subject, text, eventId, refreshToken); + if (validationError) { + return json({ error: validationError.error }, { status: validationError.status }); + } + + const results: EmailResult[] = []; + let successCount = 0; + let errorCount = 0; + + // Send emails to each participant + for (const participant of participants as Participant[]) { + const result = await sendEmailToParticipant( + participant, + subject as string, + text as string, + eventId as string, + refreshToken as string, + locals.supabase + ); + + results.push(result); + + if (result.success) { + successCount++; + } else { + errorCount++; + } + } + + return json({ + success: true, + results, + summary: { + total: participants.length, + success: successCount, + errors: errorCount + } + }); + } catch (error) { + console.error('Email sending error:', error); + + // Handle specific Gmail API errors + if (error instanceof Error) { + if (error.message.includes('Invalid Credentials') || error.message.includes('unauthorized')) { + return json({ error: 'Invalid or expired Google credentials' }, { status: 401 }); + } + if (error.message.includes('quota')) { + return json({ error: 'Gmail API quota exceeded' }, { status: 429 }); + } + if (error.message.includes('rate')) { + return json({ error: 'Rate limit exceeded' }, { status: 429 }); + } + } + + return json({ error: 'Failed to send emails' }, { status: 500 }); + } }; diff --git a/src/routes/private/api/google/README.md b/src/routes/private/api/google/README.md new file mode 100644 index 0000000..6d1f46b --- /dev/null +++ b/src/routes/private/api/google/README.md @@ -0,0 +1,47 @@ +# Google API Integration + +This directory contains unified endpoints for Google API integration, all protected under the `/private` route to ensure only authenticated users can access them. + +## Auth Endpoints + +### `/private/api/google/auth/refresh` + +- **Method**: POST +- **Purpose**: Refreshes an access token using a refresh token +- **Body**: `{ "refreshToken": "your-refresh-token" }` +- **Response**: `{ "accessToken": "new-access-token", "expiresIn": 3600 }` + +### `/private/api/google/auth/userinfo` + +- **Method**: GET +- **Purpose**: Gets information about the authenticated user +- **Headers**: Authorization: Bearer `access_token` +- **Response**: `{ "email": "user@example.com", "name": "User Name", "picture": "profile-pic-url" }` + +### `/private/api/google/auth/revoke` + +- **Method**: POST +- **Purpose**: Revokes an access token +- **Body**: `{ "accessToken": "token-to-revoke" }` +- **Response**: `{ "success": true }` + +## Sheets Endpoints + +### `/private/api/google/sheets/recent` + +- **Method**: GET +- **Purpose**: Gets a list of recent spreadsheets +- **Headers**: Authorization: Bearer `refresh_token` +- **Response**: Array of spreadsheet objects + +### `/private/api/google/sheets/[sheetId]/data` + +- **Method**: GET +- **Purpose**: Gets data from a specific spreadsheet +- **Headers**: Authorization: Bearer `refresh_token` +- **URL Parameters**: sheetId - The ID of the spreadsheet +- **Response**: Spreadsheet data including values array + +## Client Usage + +Use the utility functions in `$lib/google.ts` to interact with these endpoints. diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts new file mode 100644 index 0000000..ca057c8 --- /dev/null +++ b/src/routes/private/api/google/auth/refresh/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { googleAuthServer } from '$lib/google/server.ts'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken) { + return json({ error: 'Refresh token is required' }, { status: 400 }); + } + + const oauth = googleAuthServer.getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + + const { credentials } = await oauth.refreshAccessToken(); + + if (!credentials.access_token) { + return json({ error: 'Failed to refresh token' }, { status: 500 }); + } + + return json({ + accessToken: credentials.access_token, + expiresIn: credentials.expiry_date + }); + } catch (error) { + console.error('Error refreshing access token:', error); + return json({ error: 'Failed to refresh access token' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/auth/revoke/+server.ts b/src/routes/private/api/google/auth/revoke/+server.ts new file mode 100644 index 0000000..b01adee --- /dev/null +++ b/src/routes/private/api/google/auth/revoke/+server.ts @@ -0,0 +1,31 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { accessToken } = await request.json(); + + if (!accessToken) { + return json({ error: 'Access token is required' }, { status: 400 }); + } + + // Call Google's token revocation endpoint + const response = await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${accessToken}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + if (response.ok) { + return json({ success: true }); + } else { + const error = await response.text(); + console.error('Error revoking token:', error); + return json({ error: 'Failed to revoke token' }, { status: 500 }); + } + } catch (error) { + console.error('Error revoking access token:', error); + return json({ error: 'Failed to revoke access token' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts new file mode 100644 index 0000000..2e44e75 --- /dev/null +++ b/src/routes/private/api/google/auth/userinfo/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { googleAuthServer } from '$lib/google/server.ts'; +import { google } from 'googleapis'; + +export const GET: RequestHandler = async ({ request }) => { + try { + const authHeader = request.headers.get('authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const accessToken = authHeader.slice(7); + + // Create OAuth client with the token + const oauth = googleAuthServer.getOAuthClient(); + oauth.setCredentials({ access_token: accessToken }); + + // Call the userinfo endpoint to get user details + const oauth2 = google.oauth2({ version: 'v2', auth: oauth }); + const userInfo = await oauth2.userinfo.get(); + + return json({ + email: userInfo.data.email, + name: userInfo.data.name, + picture: userInfo.data.picture + }); + } catch (error) { + console.error('Error fetching user info:', error); + return json({ error: 'Failed to fetch user info' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts new file mode 100644 index 0000000..e0ca677 --- /dev/null +++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { googleSheetsServer } from '$lib/google/sheets/server.js'; + +export const GET: RequestHandler = async ({ params, request }) => { + try { + const { sheetId } = params; + const authHeader = request.headers.get('authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + + return json(sheetData); + } catch (error) { + console.error('Error fetching spreadsheet data:', error); + return json({ error: 'Failed to fetch spreadsheet data' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts new file mode 100644 index 0000000..304c575 --- /dev/null +++ b/src/routes/private/api/google/sheets/recent/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { googleSheetsServer } from '$lib/google/sheets/server.js'; + +export const GET: RequestHandler = async ({ request }) => { + try { + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const spreadsheets = await googleSheetsServer.getRecentSpreadsheets(refreshToken, 20); + + return json(spreadsheets); + } catch (error) { + console.error('Error fetching recent spreadsheets:', error); + return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/sheets/search/+server.ts b/src/routes/private/api/google/sheets/search/+server.ts new file mode 100644 index 0000000..2c79e6c --- /dev/null +++ b/src/routes/private/api/google/sheets/search/+server.ts @@ -0,0 +1,30 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { googleSheetsServer } from '$lib/google/sheets/server.js'; + +export const GET: RequestHandler = async ({ url, request }) => { + try { + // Get search query from URL + const query = url.searchParams.get('query'); + + if (!query) { + throw error(400, 'Search query is required'); + } + + // Get authorization token from request headers + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw error(401, 'Missing or invalid Authorization header'); + } + const refreshToken = authHeader.substring(7); // Remove "Bearer " prefix + + // Search for sheets using the query + const sheets = await googleSheetsServer.searchSheets(refreshToken, query); + + // Return the search results + return json(sheets); + } catch (err) { + console.error('Error searching Google Sheets:', err); + throw error(500, 'Failed to search Google Sheets'); + } +}; diff --git a/src/routes/private/events/+page.server.ts b/src/routes/private/events/+page.server.ts deleted file mode 100644 index be0d7a9..0000000 --- a/src/routes/private/events/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function load({ locals }) { - const { data: events, error } = await locals.supabase - .from('events') - .select('*') - .order('date', { ascending: false }); - return { events }; -} \ No newline at end of file diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index 47dc1f6..d8d3cfb 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -1,54 +1,237 @@

All Events

+
- {#if loading} - {#each Array(4) as _} -
-
- - -
-
- {/each} - {:else} - {#each events as event} - - {/each} - {#each archived_events as event} - - {/each} - {/if} + {#if loading} + + {#each Array(4) as _} +
+
+
+
+
+
+ {/each} + {:else if error} +
+

{error}

+ +
+ {:else if displayEvents.length === 0} +
+

No events found. Create your first event!

+
+ {:else} + {#each displayEvents as event} + + {/each} + {/if}
- - New Event - \ No newline at end of file + +
+ +
+ +
+ +
+ {#if isSearching} + + + + + {:else} + + + + {/if} +
+ {#if searchTerm} + + {/if} +
+ + + + + + + + New Event + + +
+
+ + +
\ No newline at end of file diff --git a/src/routes/private/events/SingleEvent.svelte b/src/routes/private/events/SingleEvent.svelte index ef08e73..5887730 100644 --- a/src/routes/private/events/SingleEvent.svelte +++ b/src/routes/private/events/SingleEvent.svelte @@ -3,7 +3,7 @@ diff --git a/src/routes/private/events/archived/+page.svelte b/src/routes/private/events/archived/+page.svelte deleted file mode 100644 index 2df8b53..0000000 --- a/src/routes/private/events/archived/+page.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -

Archived Event Overview

- -
-
- {#if loading} -
-
- {:else} - {event_data?.name} - {event_data?.date} - {/if} -
-
- -
-
- - - - {#if loading} -
- {:else} - Total participants ({event_data?.total_participants}) - {/if} -
-
-
- - - - {#if loading} -
- {:else} - Scanned participants ({event_data?.scanned_participants}) - {/if} -
-
diff --git a/src/routes/private/events/creator/+page.svelte b/src/routes/private/events/creator/+page.svelte deleted file mode 100644 index 4a2818f..0000000 --- a/src/routes/private/events/creator/+page.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -{#if isAddingParticipants} -
-

- Adding Participants to "{event.name}" -

-
-{/if} - -{#if step == 0} - -{:else if step == 1} - -{:else if step == 2} - -{:else if step == 3} - -{:else if step == 4} - -{/if} - -
- -
-
- - - Step {step + 1} of {steps.length} - - -
-
diff --git a/src/routes/private/events/creator/emailtest/+page.svelte b/src/routes/private/events/creator/emailtest/+page.svelte deleted file mode 100644 index 3d67361..0000000 --- a/src/routes/private/events/creator/emailtest/+page.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
-

Test Email Sender

- {#if !authorized} -
-

Google not connected.

- -
-
- - - - - -
- {/if} - {#if error} -
{error}
- {/if} - {#if success} -
{success}
- {/if} -
diff --git a/src/routes/private/events/creator/finish/+page.svelte b/src/routes/private/events/creator/finish/+page.svelte deleted file mode 100644 index 93dc831..0000000 --- a/src/routes/private/events/creator/finish/+page.svelte +++ /dev/null @@ -1,252 +0,0 @@ - - - -
-

- {all_data.isAddingParticipants ? 'Using existing event' : 'Creating event'} -

- {#if event_status === StepStatus.Waiting} - Waiting... - {:else if event_status === StepStatus.Loading} - Creating event... - {:else if event_status === StepStatus.Success} - - {all_data.isAddingParticipants ? 'Using existing event.' : 'Event created successfully.'} - - {:else if event_status === StepStatus.Failure} - Failed to create event. - {/if} -
- - -
-

Creating QR codes for participants

- {#if participants_status === StepStatus.Waiting} - Waiting... - {:else if participants_status === StepStatus.Loading} - Creating entries... - {:else if participants_status === StepStatus.Success} - QR codes created successfully. - {:else if participants_status === StepStatus.Failure} - Failed to create QR codes. - {/if} -
- - -
-

Sending emails

-

After pressing send, you must not exit this window until the mail are all sent!

- {#if email_status === StepStatus.Waiting} -
- Waiting... - -
- {:else} -
    - {#each createdParticipants as p, i} -
  • - {#if mailStatuses[i]?.status === 'success'} - - - - {:else if mailStatuses[i]?.status === 'failure'} - - - - - - {:else} - - - - {/if} - {p.name} {p.surname} - {p.email} -
  • - {/each} -
- {#if email_status === StepStatus.Loading} - Sending emails... - {:else if email_status === StepStatus.Success} - Emails sent successfully. - {:else if email_status === StepStatus.Failure} - Failed to send emails. - {/if} - {/if} -
diff --git a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte b/src/routes/private/events/creator/steps/StepConnectGoogle.svelte deleted file mode 100644 index 4d381c9..0000000 --- a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
- {#if loading} -
- - - - - Checking Google connection... -
- {:else} - {#if !authorized} -
-

You haven’t connected your Google account yet.

- -
- {:else} -
- - - - Your connection to Google is good, proceed to next step -
- {/if} - {/if} -
diff --git a/src/routes/private/events/creator/steps/StepCraftEmail.svelte b/src/routes/private/events/creator/steps/StepCraftEmail.svelte deleted file mode 100644 index 085baaf..0000000 --- a/src/routes/private/events/creator/steps/StepCraftEmail.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -{#if showForm} -
-

Craft Email

- - - -
-{:else} - -
-

Email Preview

-
- {email.subject} -
-
- {email.body} -
-
-{/if} \ No newline at end of file diff --git a/src/routes/private/events/creator/steps/StepCreateEvent.svelte b/src/routes/private/events/creator/steps/StepCreateEvent.svelte deleted file mode 100644 index ad0856e..0000000 --- a/src/routes/private/events/creator/steps/StepCreateEvent.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if showForm} -
-

Create Event

- - - -
-{/if} - -{#if !showForm} - {#if !readonly} - - {/if} -
-

Event Preview

- {#if Object.keys(event).length > 0} -
    -
  • {event.name}
  • -
  • {event.date}
  • -
- {:else} - No event created yet... - {/if} -
-{/if} diff --git a/src/routes/private/events/creator/steps/StepOverview.svelte b/src/routes/private/events/creator/steps/StepOverview.svelte deleted file mode 100644 index 8f23aa9..0000000 --- a/src/routes/private/events/creator/steps/StepOverview.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - -
-

Event Overview

-
    -
  • Name: {event.name}
  • -
  • Date: {event.date}
  • -
-
- - -
-

Email Preview

-
Subject: {email.subject}
-
- -
{email.body}
-
-
- - -
-

Participants ({participants.length})

-
    - {#each participants.slice(0, 10) as p} -
  • - {p.name} {p.surname} - - {p.email} -
  • - {/each} -
-

Note: Only the first 10 participants are shown.

-
- - - -
- {#if !stepConditions[0]} -

Please provide an event name before proceeding.

- {/if} - {#if !stepConditions[1]} -

Please add at least one participant before proceeding.

- {/if} - {#if !stepConditions[2]} -

Please provide an email subject before proceeding.

- {/if} - {#if !stepConditions[3]} -

Please provide an email body before proceeding.

- {/if} -
diff --git a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte b/src/routes/private/events/creator/steps/StepUploadParticipants.svelte deleted file mode 100644 index d973d4f..0000000 --- a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -{#if showForm} - {#if errors.length > 0} -
-
    - {#each errors as err} -
  • {err}
  • - {/each} -
-
- {/if} -
-

Upload Participants

- - -
-{:else} - -{/if} - -{#if !showForm} -
-
-

Participants ({participants.length})

- {#if participants.length > 0} - {#if participants.some((p) => !p.email_valid)} - Some emails appear invalid - {:else} - All emails appear valid - {/if} - {/if} -
- -
    - {#each participants as p} -
  • - {#if p.email_valid} - - - - {:else} - - - - - - {/if} - {p.name} {p.surname} - - {p.email} -
  • - {/each} -
-
-{/if} diff --git a/src/routes/private/events/event/+page.svelte b/src/routes/private/events/event/+page.svelte deleted file mode 100644 index fbe63d5..0000000 --- a/src/routes/private/events/event/+page.svelte +++ /dev/null @@ -1,162 +0,0 @@ - - -

Event Overview

- -
- -
-
- - - - {#if loading} -
- {:else} - Scanned ({scannedCount}) - {/if} -
-
-
- - - - - - {#if loading} -
- {:else} - Not scanned ({notScannedCount}) - {/if} -
-
- -
-

- {#if loading} -
- {:else} - Participants ({participants.length}) - {/if} -

-
    - {#if loading} -
  • -
    -
  • - {:else} - {#each participants as p} -
  • - {#if p.scanned} - - - - {:else} - - - - - - {/if} - {p.name} {p.surname} - - {#if p.scanned_by} -
    - - {new Date(p.scanned_at).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - hour12: false - })} - - by {p.scanned_by.display_name} -
    - {/if} -
  • - {/each} - {/if} -
-
diff --git a/src/routes/private/events/event/archived/+page.svelte b/src/routes/private/events/event/archived/+page.svelte new file mode 100644 index 0000000..37cdb56 --- /dev/null +++ b/src/routes/private/events/event/archived/+page.svelte @@ -0,0 +1,88 @@ + + +

Archived Event Overview

+ +
+
+ {#if loading} +
+
+ {:else} + {event_data?.name} + {event_data?.date} + {/if} +
+
+ +
+
+
+ + + + {#if loading} +
+ {:else} + Total participants ({event_data?.total_participants}) + {/if} +
+ +
+ + + + {#if loading} +
+ {:else} + Scanned participants ({event_data?.scanned_participants}) + {/if} +
+
+
diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte new file mode 100644 index 0000000..3d9f809 --- /dev/null +++ b/src/routes/private/events/event/new/+page.svelte @@ -0,0 +1,458 @@ + + +
+ + + + +
+ {#if currentStep === 0} + { + authData.error = null; + authData.token = token; + authData.isConnected = true; + setTimeout(checkGoogleAuth, 100); + }} + onError={(error) => { + authData.error = error; + authData.isConnected = false; + }} + /> + {:else if currentStep === 1} + + {:else if currentStep === 2} + + {:else if currentStep === 3} + + {/if} + + {#if errors.submit} +
+

{errors.submit}

+
+ {/if} +
+ + + +
diff --git a/src/routes/private/events/event/new/components/EmailSettingsStep.svelte b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte new file mode 100644 index 0000000..3daf494 --- /dev/null +++ b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte @@ -0,0 +1,43 @@ + + +
+
+ + + {#if errors.subject} +

{errors.subject}

+ {/if} +
+ +
+ + + {#if errors.body} +

{errors.body}

+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/EventDetailsStep.svelte b/src/routes/private/events/event/new/components/EventDetailsStep.svelte new file mode 100644 index 0000000..f88218b --- /dev/null +++ b/src/routes/private/events/event/new/components/EventDetailsStep.svelte @@ -0,0 +1,44 @@ + + +
+
+

Event details

+ + + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.date} +

{errors.date}

+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/GoogleAuthStep.svelte b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte new file mode 100644 index 0000000..05199b8 --- /dev/null +++ b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte @@ -0,0 +1,32 @@ + + +
+
+

Connect Your Google Account

+

+ To create events and import participants from Google Sheets, you need to connect your Google account. +

+ + + + {#if errors.google} +
+ {errors.google} +
+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte new file mode 100644 index 0000000..799fc0a --- /dev/null +++ b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte @@ -0,0 +1,382 @@ + + +
+
+

Select Google Sheet

+ + {#if sheetsData.loading && sheetsData.availableSheets.length === 0} +
+ {#each Array(5) as _} +
+
+
+
+ {/each} +
+ {:else if sheetsData.availableSheets.length === 0} +
+

No Google Sheets found.

+ +
+ {:else} +
+ {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet} + +
+
+
{sheetsData.selectedSheet.name}
+
+ Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()} +
+
+ +
+ {:else} + +
+

Google Sheets

+ {#if sheetsData.selectedSheet} + + {/if} +
+ + +
+ +
+ + + +
+ {#if searchQuery} + + {/if} +
+ + {#if isSearching} + +
+ {#each Array(3) as _} +
+
+
+
+ {/each} +
+ {:else if searchQuery && searchResults.length === 0 && !searchError} + +
+

No sheets found matching "{searchQuery}"

+
+ {:else if searchError} + +
+

{searchError}

+ +
+ {:else if searchQuery && searchResults.length > 0} + +
+ {#each searchResults as sheet} + + {/each} +
+ {:else} + +
+ {#each sheetsData.availableSheets as sheet} + + {/each} +
+ {#if sheetsData.availableSheets.length === 0 && !sheetsData.loading} +
+

No recent sheets found. Try searching above.

+
+ {/if} + {/if} + {/if} +
+ {/if} + + {#if errors.sheet} +

{errors.sheet}

+ {/if} +
+ + {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} +
+

Column Mapping

+ + +
+

Column Mapping Instructions:

+

+ Select what each column represents by using the dropdown in each column header. + Make sure to assign Name, Surname, Email, and Confirmation columns. +

+
+ +
+ + + + {#each sheetsData.sheetData[0] || [] as header, index} + + {/each} + + + + {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex} + + {#each row as cell, cellIndex} + + {/each} + + {/each} + +
+
+
+ {header || `Empty Column ${index + 1}`} +
+ +
+ {#if sheetsData.columnMapping.name === index + 1} + Name Column + {:else if sheetsData.columnMapping.surname === index + 1} + Surname Column + {:else if sheetsData.columnMapping.email === index + 1} + Email Column + {:else if sheetsData.columnMapping.confirmation === index + 1} + Confirmation Column + {:else} + Not Mapped + {/if} +
+
+
+ + {cell || ''} + +
+
+
+

Showing first 10 rows

+ {#if sheetsData.sheetData[0] && sheetsData.sheetData[0].length > 3} +

+ + + + Scroll horizontally to see all {sheetsData.sheetData[0].length} columns +

+ {/if} +
+
+ {/if} + + {#if sheetsData.loading && sheetsData.selectedSheet} +
+
Loading sheet data...
+
+ {/if} + + {#if errors.sheetData} +

{errors.sheetData}

+ {/if} +
+ + diff --git a/src/routes/private/events/event/new/components/StepNavigation.svelte b/src/routes/private/events/event/new/components/StepNavigation.svelte new file mode 100644 index 0000000..207caba --- /dev/null +++ b/src/routes/private/events/event/new/components/StepNavigation.svelte @@ -0,0 +1,42 @@ + + +
+ + +
+ {#if currentStep < totalSteps - 1} + + {:else} + + {/if} +
+
diff --git a/src/routes/private/events/event/new/components/StepNavigator.svelte b/src/routes/private/events/event/new/components/StepNavigator.svelte new file mode 100644 index 0000000..f26fb87 --- /dev/null +++ b/src/routes/private/events/event/new/components/StepNavigator.svelte @@ -0,0 +1,28 @@ + + +
+
+ {#each Array(totalSteps) as _, index} +
+
+ {index + 1} +
+ {#if index < totalSteps - 1} +
+ {/if} +
+ {/each} +
+
diff --git a/src/routes/private/events/event/view/+page.svelte b/src/routes/private/events/event/view/+page.svelte new file mode 100644 index 0000000..51fd7f2 --- /dev/null +++ b/src/routes/private/events/event/view/+page.svelte @@ -0,0 +1,608 @@ + + + +
+

Event Overview

+
+ + +
+ {#if loading} + +
+
+
+
+
+
+ {:else if event} +
+
+

{event.name}

+
+
+ Date: + {formatDate(event.date)} +
+
+ Created: + {formatDate(event.created_at)} +
+ +
+
+
+

Email Details

+
+
+ Subject: +

{event.email_subject}

+
+
+ Body: +

{event.email_body}

+
+
+
+
+ {:else if error} +
+

{error}

+
+ {/if} +
+ + +
+
+

Google Account

+

Required for syncing participants and sending emails

+
+ { + // Refresh the page or update UI state as needed + error = ''; + }} + onError={(errorMsg) => { + error = errorMsg; + }} + /> +
+ + +
+
+

Participants

+ +
+ + {#if participantsLoading} + +
+ {#each Array(5) as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if participants.length > 0} +
+ + + + + + + + + + + + {#each participants as participant} + + + + + + + + {/each} + +
NameSurnameEmailScannedEmail Sent
{participant.name}{participant.surname}{participant.email} + {#if participant.scanned} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+ {#if participant.email_sent} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+
+ {:else} +
+

+ No participants found. Click "Sync Participants" to load from Google Sheets. +

+
+ {/if} +
+ + +
+
+

Send Emails

+
+ {participants.filter(p => !p.email_sent).length} uncontacted participants +
+
+ + {#if sendingEmails} +
+
+ + + + + Sending {emailProgress.total} emails... Please wait. +
+
+ {:else} +
+ {#if participants.filter(p => !p.email_sent).length > 0} +
+
+ + + + + Warning: Do not close this window while emails are being sent. The process may take several minutes. + +
+
+ +
+ +
+ {:else} +
+
+ + + +
+

All participants have been contacted!

+

No pending emails to send.

+
+ {/if} +
+ {/if} +
+ + +{#if emailResults} +
+
+

Email Results

+
+ {emailResults.summary.success} successful, {emailResults.summary.errors} failed +
+
+ +
+
+
+
+ Sent: {emailResults.summary.success} +
+
+
+ Failed: {emailResults.summary.errors} +
+
+
+ Total: {emailResults.summary.total} +
+
+
+ + {#if emailResults.results.length > 0} +
+ + + + + + + + + + {#each emailResults.results as result} + + + + + + {/each} + +
NameEmailStatus
+ {result.participant.name} {result.participant.surname} + {result.participant.email} + {#if result.success} +
+ + + + Sent +
+ {:else} +
+ + + + Failed + {#if result.error} + ({result.error}) + {/if} +
+ {/if} +
+
+ {/if} +
+{/if} + +{#if error} +
+

{error}

+
+{/if} diff --git a/src/routes/private/scanner/+page.svelte b/src/routes/private/scanner/+page.svelte index 10b236d..8220dab 100644 --- a/src/routes/private/scanner/+page.svelte +++ b/src/routes/private/scanner/+page.svelte @@ -1,36 +1,153 @@ - +
+

Code Scanner

- + +
+

Select Event

+ {#if isLoadingEvents} +
+
+
+ {:else if eventsError} +
+ {eventsError} + +
+ {:else if events.length === 0} +

No events found

+ {:else} + + {/if} +
+ + +
+ +
+ + +

Ticket Information

+ + + + {#if scan_state !== ScanState.scanning} +
+ +
+ {/if} +
diff --git a/src/routes/private/scanner/QRScanner.svelte b/src/routes/private/scanner/QRScanner.svelte index d8a520f..fe8cd90 100644 --- a/src/routes/private/scanner/QRScanner.svelte +++ b/src/routes/private/scanner/QRScanner.svelte @@ -45,7 +45,7 @@ }); -
+