diff --git a/package-lock.json b/package-lock.json index e2fcaee..345a87c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2709,9 +2709,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2726,9 +2723,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2743,9 +2737,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2760,9 +2751,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2777,9 +2765,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2794,9 +2779,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2811,9 +2793,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2828,9 +2807,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2845,9 +2821,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2862,9 +2835,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2879,9 +2849,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2896,9 +2863,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2913,9 +2877,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/api/googleDrive.js b/src/api/googleDrive.js new file mode 100644 index 0000000..8c6747c --- /dev/null +++ b/src/api/googleDrive.js @@ -0,0 +1,141 @@ +import api from './axios' +import { buildGoogleDocUrl, buildGoogleDriveFolderUrl } from '../utils/googleDrive' + +const normalizeBaseResume = (resume, index) => { + const documentId = resume?.documentId ?? resume?.googleDocId ?? resume?.googleFileId ?? '' + + return { + id: resume?.id ?? null, + name: resume?.name ?? resume?.label ?? resume?.documentName ?? `Resume ${index + 1}`, + documentId, + documentUrl: resume?.documentUrl ?? resume?.googleDocUrl ?? resume?.webViewLink ?? buildGoogleDocUrl(documentId), + isDefault: Boolean(resume?.isDefault), + } +} + +export const normalizeGoogleDriveSettings = (data = {}) => { + const baseFolderId = data.baseFolderId ?? data.baseDriveFolderId ?? data.folderId ?? data.rootFolderId ?? '' + const connection = data.connection ?? {} + + return { + connected: Boolean( + data.connected ?? + data.isConnected ?? + connection.connected ?? + connection.isConnected + ), + accountEmail: data.accountEmail ?? data.googleEmail ?? connection.accountEmail ?? connection.email ?? '', + accountDisplayName: data.accountDisplayName ?? data.googleDisplayName ?? connection.accountDisplayName ?? connection.displayName ?? '', + baseFolderId, + baseFolderUrl: data.baseFolderUrl ?? data.folderUrl ?? buildGoogleDriveFolderUrl(baseFolderId), + baseResumes: (data.baseResumes ?? data.resumes ?? []).map(normalizeBaseResume), + } +} + +export const getGoogleDriveSettings = async () => { + const response = await api.get('/google-drive/status') + return { + ...response, + data: normalizeGoogleDriveSettings(response.data), + } +} + +export const updateGoogleDriveSettings = async (payload) => { + await api.put('/google-drive/root-folder', { + folderIdOrUrl: payload.baseFolderId ?? payload.baseFolderInput ?? '', + }) + + const currentStatusResponse = await api.get('/google-drive/status') + const currentSettings = normalizeGoogleDriveSettings(currentStatusResponse.data) + const desiredResumes = (payload.baseResumes ?? []).map(normalizeBaseResume) + + const existingById = new Map( + currentSettings.baseResumes + .filter((resume) => resume.id) + .map((resume) => [resume.id, resume]) + ) + const existingByDocumentId = new Map( + currentSettings.baseResumes + .filter((resume) => resume.documentId) + .map((resume) => [resume.documentId, resume]) + ) + const desiredIds = new Set( + desiredResumes.map((resume) => resume.id).filter(Boolean) + ) + const desiredDocumentIds = new Set( + desiredResumes.map((resume) => resume.documentId).filter(Boolean) + ) + + await Promise.all( + currentSettings.baseResumes + .filter( + (resume) => + resume.id && + !desiredIds.has(resume.id) && !desiredDocumentIds.has(resume.documentId) + ) + .map((resume) => api.delete(`/google-drive/base-resumes/${resume.id}`)) + ) + + for (const resume of desiredResumes) { + const existingResume = + (resume.id ? existingById.get(resume.id) : undefined) ?? existingByDocumentId.get(resume.documentId) + + if (!existingResume) { + await api.post('/google-drive/base-resumes', { + documentIdOrUrl: resume.documentId, + name: resume.name, + isDefault: Boolean(resume.isDefault), + }) + continue + } + + const hasChanged = + existingResume.documentId !== resume.documentId || + existingResume.name !== resume.name || + existingResume.isDefault !== Boolean(resume.isDefault) + + if (hasChanged && existingResume.id) { + await api.put(`/google-drive/base-resumes/${existingResume.id}`, { + documentIdOrUrl: resume.documentId, + name: resume.name, + isDefault: Boolean(resume.isDefault), + }) + } + } + + const response = await api.get('/google-drive/status') + return { + ...response, + data: normalizeGoogleDriveSettings(response.data), + } +} + +export const startGoogleDriveConnection = async () => { + const response = await api.post('/google-drive/oauth/start', {}) + return { + ...response, + data: { + ...response.data, + authorizationUrl: response.data?.authorizationUrl ?? response.data?.url ?? '', + }, + } +} + +export const disconnectGoogleDriveConnection = () => + api.delete('/google-drive/connection') + +export const createGoogleDriveResume = async (payload) => { + const response = await api.post( + `/google-drive/applications/${payload.applicationId}/resume-copies`, + { + baseResumeId: payload.baseResumeId, + } + ) + return { + ...response, + data: { + ...response.data, + googleDocUrl: response.data?.googleDocUrl ?? response.data?.documentWebViewLink ?? response.data?.documentUrl ?? response.data?.url ?? '', + }, + } +} diff --git a/src/pages/account/AccountSettings.jsx b/src/pages/account/AccountSettings.jsx index 1dc1705..258ebe7 100644 --- a/src/pages/account/AccountSettings.jsx +++ b/src/pages/account/AccountSettings.jsx @@ -1,15 +1,77 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { InputText } from 'primereact/inputtext' import { Password } from 'primereact/password' import { Button } from 'primereact/button' import { Toast } from 'primereact/toast' +import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog' import useAuthStore from '../../store/authStore' import { changePassword as changePasswordApi, sendTestEmail as sendTestEmailApi, updateProfile as updateProfileApi, } from '../../api/auth' +import { + disconnectGoogleDriveConnection, + getGoogleDriveSettings, + startGoogleDriveConnection, + updateGoogleDriveSettings, +} from '../../api/googleDrive' import { usePageTitle } from '../../hooks/usePageTitle' +import { + buildGoogleDocUrl, + buildGoogleDriveFolderUrl, + extractGoogleDocId, + extractGoogleDriveFolderId, +} from '../../utils/googleDrive' +import { openExternalUrl } from '../../utils/externalLinks' + +const createResumeRowId = () => + `resume-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + +const createResumeRow = (resume = {}) => ({ + clientId: createResumeRowId(), + id: resume.id ?? null, + name: resume.name ?? '', + documentInput: resume.documentUrl ?? buildGoogleDocUrl(resume.documentId), + isDefault: Boolean(resume.isDefault), +}) + +const createEmptyGoogleDriveForm = () => ({ + connected: false, + accountEmail: '', + accountDisplayName: '', + baseFolderInput: '', + resumes: [createResumeRow({ isDefault: true })], +}) + +const mapGoogleDriveSettingsToForm = (settings = {}) => { + const resumes = (settings.baseResumes ?? []).map(createResumeRow) + + if (!resumes.length) { + resumes.push(createResumeRow({ isDefault: true })) + } else if (!resumes.some((resume) => resume.isDefault)) { + resumes[0].isDefault = true + } + + return { + connected: Boolean(settings.connected), + accountEmail: settings.accountEmail ?? '', + accountDisplayName: settings.accountDisplayName ?? '', + baseFolderInput: settings.baseFolderUrl ?? buildGoogleDriveFolderUrl(settings.baseFolderId), + resumes, + } +} + +const serializeGoogleDriveForm = (form) => + JSON.stringify({ + baseFolderInput: form.baseFolderInput.trim(), + resumes: form.resumes.map((resume) => ({ + id: resume.id ?? null, + name: resume.name.trim(), + documentInput: resume.documentInput.trim(), + isDefault: Boolean(resume.isDefault), + })), + }) const AccountSettings = () => { usePageTitle('Configurações') @@ -29,28 +91,71 @@ const AccountSettings = () => { confirmPassword: '', }) const [savingPassword, setSavingPassword] = useState(false) - - // Track initial values for dirty form detection - const initialValuesRef = useRef({ + const [initialProfile, setInitialProfile] = useState({ name: user?.name || '', reminderTime: (user?.reminderTime || '19:00:00').slice(0, 5), - passwordForm: { - currentPassword: '', - newPassword: '', - confirmPassword: '', - }, }) - // Check if profile form is dirty - const isProfileDirty = name !== initialValuesRef.current.name || reminderTime !== initialValuesRef.current.reminderTime + const [googleDriveForm, setGoogleDriveForm] = useState(() => createEmptyGoogleDriveForm()) + const [loadingGoogleDrive, setLoadingGoogleDrive] = useState(true) + const [savingGoogleDrive, setSavingGoogleDrive] = useState(false) + const [connectingGoogleDrive, setConnectingGoogleDrive] = useState(false) + const [disconnectingGoogleDrive, setDisconnectingGoogleDrive] = useState(false) + const [initialGoogleDriveSnapshot, setInitialGoogleDriveSnapshot] = useState( + serializeGoogleDriveForm(createEmptyGoogleDriveForm()) + ) - // Check if password form has any input + const isProfileDirty = name !== initialProfile.name || reminderTime !== initialProfile.reminderTime const isPasswordDirty = passwordForm.currentPassword !== '' || passwordForm.newPassword !== '' || passwordForm.confirmPassword !== '' + const isGoogleDriveDirty = serializeGoogleDriveForm(googleDriveForm) !== initialGoogleDriveSnapshot + + const applyGoogleDriveSettings = useCallback((settings) => { + const nextForm = mapGoogleDriveSettingsToForm(settings) + setGoogleDriveForm(nextForm) + setInitialGoogleDriveSnapshot(serializeGoogleDriveForm(nextForm)) + }, []) + + const loadGoogleDriveSettings = useCallback(async (showSuccessMessage = false, showLoadingSpinner = true) => { + if (showLoadingSpinner) { + setLoadingGoogleDrive(true) + } + + try { + const response = await getGoogleDriveSettings() + applyGoogleDriveSettings(response.data) + + if (showSuccessMessage) { + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive status refreshed.', + }) + } + } catch (err) { + applyGoogleDriveSettings(createEmptyGoogleDriveForm()) + + if (![404, 501].includes(err.response?.status)) { + const detail = err.response?.data?.message || 'Could not load your Google Drive settings right now.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } + } finally { + setLoadingGoogleDrive(false) + } + }, [applyGoogleDriveSettings]) + + useEffect(() => { + const loadTimer = window.setTimeout(() => { + loadGoogleDriveSettings(false, false).catch(() => null) + }, 0) + + return () => { + window.clearTimeout(loadTimer) + } + }, [loadGoogleDriveSettings]) - // Track form dirty state and warn on page leave useEffect(() => { - const isDirty = isProfileDirty || isPasswordDirty + const isDirty = isProfileDirty || isPasswordDirty || isGoogleDriveDirty const handleBeforeUnload = (e) => { if (isDirty) { @@ -64,7 +169,7 @@ const AccountSettings = () => { return () => { window.removeEventListener('beforeunload', handleBeforeUnload) } - }, [isProfileDirty, isPasswordDirty]) + }, [isProfileDirty, isPasswordDirty, isGoogleDriveDirty]) const handleProfileSubmit = async (e) => { e.preventDefault() @@ -82,6 +187,10 @@ const AccountSettings = () => { setUser(res.data) setName(res.data.name) setReminderTime((res.data.reminderTime || '19:00:00').slice(0, 5)) + setInitialProfile({ + name: res.data.name, + reminderTime: (res.data.reminderTime || '19:00:00').slice(0, 5), + }) toast.current.show({ severity: 'success', summary: 'Success', detail: 'Profile updated successfully.' }) } catch (err) { const detail = err.response?.data?.message || 'Could not update your profile. Please check your information and try again.' @@ -152,13 +261,212 @@ const AccountSettings = () => { } } + const setGoogleDriveField = (key, value) => { + setGoogleDriveForm((current) => ({ ...current, [key]: value })) + } + + const updateResumeRow = (clientId, key, value) => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: current.resumes.map((resume) => + resume.clientId === clientId ? { ...resume, [key]: value } : resume + ), + })) + } + + const addResumeRow = () => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: [...current.resumes, createResumeRow()], + })) + } + + const removeResumeRow = (clientId) => { + setGoogleDriveForm((current) => { + if (current.resumes.length === 1) { + return current + } + + const resumes = current.resumes.filter((resume) => resume.clientId !== clientId) + + if (!resumes.some((resume) => resume.isDefault) && resumes[0]) { + resumes[0] = { ...resumes[0], isDefault: true } + } + + return { + ...current, + resumes, + } + }) + } + + const setDefaultResume = (clientId) => { + setGoogleDriveForm((current) => ({ + ...current, + resumes: current.resumes.map((resume) => ({ + ...resume, + isDefault: resume.clientId === clientId, + })), + })) + } + + const handleConnectGoogleDrive = async () => { + setConnectingGoogleDrive(true) + + try { + const response = await startGoogleDriveConnection() + const authorizationUrl = response.data?.authorizationUrl + + if (!authorizationUrl) { + throw new Error('No authorization URL was returned.') + } + + openExternalUrl(authorizationUrl) + toast.current?.show({ + severity: 'info', + summary: 'Continue in Google', + detail: 'Finish the Google authorization flow in the new tab, then refresh the status here.', + }) + } catch (err) { + const detail = err.response?.data?.message || err.message || 'Could not start the Google Drive connection.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setConnectingGoogleDrive(false) + } + } + + const handleDisconnectGoogleDrive = () => { + confirmDialog({ + message: 'Disconnect your Google Drive account from Job Tracker?', + header: 'Disconnect Google Drive', + icon: 'pi pi-exclamation-triangle', + acceptClassName: 'p-button-danger', + accept: async () => { + setDisconnectingGoogleDrive(true) + + try { + await disconnectGoogleDriveConnection() + setGoogleDriveForm((current) => ({ + ...current, + connected: false, + accountEmail: '', + accountDisplayName: '', + })) + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive disconnected successfully.', + }) + await loadGoogleDriveSettings().catch(() => null) + } catch (err) { + const detail = err.response?.data?.message || 'Could not disconnect your Google Drive account.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setDisconnectingGoogleDrive(false) + } + }, + }) + } + + const handleGoogleDriveSubmit = async (e) => { + e.preventDefault() + + const baseFolderInput = googleDriveForm.baseFolderInput.trim() + const baseFolderId = extractGoogleDriveFolderId(baseFolderInput) + + if (!baseFolderInput || !baseFolderId) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Please provide a valid Google Drive folder URL or folder ID.', + }) + return + } + + const enteredResumes = googleDriveForm.resumes.filter( + (resume) => resume.name.trim() || resume.documentInput.trim() + ) + + if (!enteredResumes.length) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Add at least one base resume before saving.', + }) + return + } + + const normalizedResumes = [] + + for (const resume of enteredResumes) { + const name = resume.name.trim() + const documentInput = resume.documentInput.trim() + const documentId = extractGoogleDocId(documentInput) + + if (!name || !documentInput) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'Each base resume needs a name and a Google Docs URL or document ID.', + }) + return + } + + if (!documentId) { + toast.current?.show({ + severity: 'warn', + summary: 'Validation', + detail: 'One of the base resumes has an invalid Google Docs URL or document ID.', + }) + return + } + + normalizedResumes.push({ + id: resume.id ?? undefined, + name, + documentId, + isDefault: resume.isDefault, + }) + } + + const defaultIndex = normalizedResumes.findIndex((resume) => resume.isDefault) + const selectedDefaultIndex = defaultIndex >= 0 ? defaultIndex : 0 + const payload = { + baseFolderId, + baseResumes: normalizedResumes.map((resume, index) => ({ + ...(resume.id ? { id: resume.id } : {}), + name: resume.name, + documentId: resume.documentId, + isDefault: index === selectedDefaultIndex, + })), + } + + setSavingGoogleDrive(true) + + try { + const response = await updateGoogleDriveSettings(payload) + applyGoogleDriveSettings(response.data) + toast.current?.show({ + severity: 'success', + summary: 'Success', + detail: 'Google Drive settings saved successfully.', + }) + } catch (err) { + const detail = err.response?.data?.message || 'Could not save your Google Drive settings.' + toast.current?.show({ severity: 'error', summary: 'Error', detail }) + } finally { + setSavingGoogleDrive(false) + } + } + return (
Manage your personal information and account password
+Manage your personal information, Google Drive resumes and account password
+ Connect Google Drive, set your base folder and manage one or more base Google Docs resumes. +
+Connection status
++ {loadingGoogleDrive + ? 'Checking your Google Drive connection...' + : googleDriveForm.connected + ? `${googleDriveForm.accountDisplayName || 'Google account'}${googleDriveForm.accountEmail ? ` • ${googleDriveForm.accountEmail}` : ''}` + : 'No Google Drive account connected yet.'} +
+