508 lines
21 KiB
HTML
508 lines
21 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Connection - QBO Excel Sync{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="connection-page">
|
|
<div class="page-header">
|
|
<h2>QuickBooks Connection</h2>
|
|
<p>Connect to your QuickBooks Online account to start importing data.</p>
|
|
</div>
|
|
|
|
<div class="connection-layout">
|
|
<!-- Credentials Section -->
|
|
<section class="card collapsible">
|
|
<div class="card-header" onclick="toggleSection(this)">
|
|
<h3>API Credentials</h3>
|
|
<span class="collapse-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
<div class="card-body collapsible-content">
|
|
<form id="credentials-form" class="form">
|
|
<div class="form-group">
|
|
<label for="client_id">Client ID</label>
|
|
<input type="text" id="client_id" name="client_id"
|
|
value="{{ credentials.client_id }}"
|
|
placeholder="Enter your Intuit Developer Client ID">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="client_secret">Client Secret</label>
|
|
<input type="password" id="client_secret" name="client_secret"
|
|
value="{{ credentials.client_secret }}"
|
|
placeholder="Enter your Client Secret">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="redirect_uri">Redirect URI</label>
|
|
<input type="text" id="redirect_uri" name="redirect_uri"
|
|
value="{{ credentials.redirect_uri or 'http://localhost:5000/callback' }}"
|
|
placeholder="http://localhost:5000/callback">
|
|
<small class="form-help">This must match exactly in your Intuit Developer app settings.</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="environment">Environment</label>
|
|
<select id="environment" name="environment">
|
|
<option value="sandbox" {% if environment == 'sandbox' %}selected{% endif %}>Sandbox (Testing)</option>
|
|
<option value="production" {% if environment == 'production' %}selected{% endif %}>Production (Live)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|
<polyline points="7 3 7 8 15 8"/>
|
|
</svg>
|
|
Save Credentials
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Connection Status Section -->
|
|
<section class="card collapsible">
|
|
<div class="card-header" onclick="toggleSection(this)">
|
|
<h3>Connection Status</h3>
|
|
<span class="collapse-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
<div class="card-body collapsible-content">
|
|
<div class="connection-status-display {% if is_connected %}connected{% else %}disconnected{% endif %}">
|
|
<div class="status-icon">
|
|
{% if is_connected %}
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
</svg>
|
|
{% else %}
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
</svg>
|
|
{% endif %}
|
|
</div>
|
|
<div class="status-details">
|
|
<h4>{% if is_connected %}Connected{% else %}Not Connected{% endif %}</h4>
|
|
{% if is_connected and company_name %}
|
|
<p class="company-name">{{ company_name }}</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="connection-actions">
|
|
{% if is_connected %}
|
|
<button type="button" id="test-connection" class="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="23 4 23 10 17 10"/>
|
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
</svg>
|
|
Test Connection
|
|
</button>
|
|
<button type="button" id="disconnect" class="btn btn-danger">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
|
|
<line x1="12" y1="2" x2="12" y2="12"/>
|
|
</svg>
|
|
Disconnect
|
|
</button>
|
|
{% else %}
|
|
<button type="button" id="connect-oauth" class="btn btn-primary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
|
</svg>
|
|
Connect to QuickBooks
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Manual Token Entry -->
|
|
<section class="card collapsible collapsed">
|
|
<div class="card-header" onclick="toggleSection(this)">
|
|
<h3>Alternative: Manual Token Entry</h3>
|
|
<div class="header-right">
|
|
<span class="card-badge">Advanced</span>
|
|
<span class="collapse-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-body collapsible-content">
|
|
<p class="card-description">
|
|
If OAuth redirect isn't working, you can manually enter tokens from Intuit's OAuth 2.0 Playground.
|
|
</p>
|
|
|
|
<form id="manual-token-form" class="form">
|
|
<div class="form-group">
|
|
<label for="access_token">Access Token</label>
|
|
<textarea id="access_token" name="access_token" rows="3"
|
|
placeholder="Paste access token from OAuth Playground"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="refresh_token">Refresh Token</label>
|
|
<textarea id="refresh_token" name="refresh_token" rows="2"
|
|
placeholder="Paste refresh token (optional but recommended)"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="realm_id">Realm ID (Company ID)</label>
|
|
<input type="text" id="realm_id" name="realm_id"
|
|
placeholder="Enter the Realm ID / Company ID">
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-secondary">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|
<polyline points="7 3 7 8 15 8"/>
|
|
</svg>
|
|
Save Tokens & Connect
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Help Section -->
|
|
<section class="card help-card collapsible collapsed">
|
|
<div class="card-header" onclick="toggleSection(this)">
|
|
<h3>Setup Instructions</h3>
|
|
<span class="collapse-icon">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
<div class="card-body collapsible-content">
|
|
<div class="help-section">
|
|
<h4>Option 1: OAuth Flow (Recommended)</h4>
|
|
<ol>
|
|
<li>Go to <a href="https://developer.intuit.com" target="_blank">developer.intuit.com</a></li>
|
|
<li>Create or select your app → "Keys & OAuth" section</li>
|
|
<li>Add redirect URI: <code>http://localhost:5000/callback</code></li>
|
|
<li>Copy your Client ID and Client Secret</li>
|
|
<li>Save credentials above, then click "Connect to QuickBooks"</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<h4>Option 2: Manual Token Entry</h4>
|
|
<ol>
|
|
<li>In the developer portal, click "OAuth 2.0 Playground"</li>
|
|
<li>Authorize your sandbox/production company</li>
|
|
<li>Copy the Access Token, Refresh Token, and Realm ID</li>
|
|
<li>Paste them in the "Manual Token Entry" section above</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="help-note">
|
|
<strong>Note:</strong> Access tokens expire after 1 hour. The app will automatically refresh using the refresh token.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Collapsible Sections */
|
|
.card.collapsible .card-header {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.card.collapsible .card-header:hover {
|
|
background: var(--bg);
|
|
}
|
|
|
|
.header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.collapse-icon {
|
|
transition: transform 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.card.collapsible.collapsed .collapse-icon {
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.collapsible-content {
|
|
max-height: 2000px;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
|
opacity: 1;
|
|
}
|
|
|
|
.card.collapsible.collapsed .collapsible-content {
|
|
max-height: 0;
|
|
padding-top: 0;
|
|
padding-bottom: 0;
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Ensure card-body padding transitions smoothly */
|
|
.card.collapsible .card-body {
|
|
transition: padding 0.3s ease;
|
|
}
|
|
|
|
.card.collapsible.collapsed .card-body {
|
|
padding: 0 20px;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// Toggle collapsible section
|
|
function toggleSection(header) {
|
|
const card = header.closest('.card.collapsible');
|
|
card.classList.toggle('collapsed');
|
|
|
|
// Save state to localStorage
|
|
const sectionId = header.querySelector('h3').textContent.trim();
|
|
const isCollapsed = card.classList.contains('collapsed');
|
|
localStorage.setItem('section_' + sectionId, isCollapsed ? 'collapsed' : 'expanded');
|
|
}
|
|
|
|
// Global variable to track OAuth polling
|
|
let oauthPollTimer = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Check if we need to handle OAuth completion on page load
|
|
const oauthComplete = localStorage.getItem('oauth_complete');
|
|
if (oauthComplete) {
|
|
localStorage.removeItem('oauth_complete');
|
|
// Page was just reloaded after OAuth - show success message
|
|
showToast('Successfully connected to QuickBooks!', 'success');
|
|
}
|
|
|
|
// Restore section states from localStorage
|
|
document.querySelectorAll('.card.collapsible').forEach(card => {
|
|
const sectionId = card.querySelector('.card-header h3').textContent.trim();
|
|
const savedState = localStorage.getItem('section_' + sectionId);
|
|
|
|
if (savedState === 'collapsed') {
|
|
card.classList.add('collapsed');
|
|
} else if (savedState === 'expanded') {
|
|
card.classList.remove('collapsed');
|
|
}
|
|
});
|
|
|
|
// Save credentials form
|
|
document.getElementById('credentials-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
client_id: document.getElementById('client_id').value,
|
|
client_secret: document.getElementById('client_secret').value,
|
|
redirect_uri: document.getElementById('redirect_uri').value,
|
|
environment: document.getElementById('environment').value
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/api/credentials', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast('Credentials saved successfully', 'success');
|
|
} else {
|
|
showToast(result.error || 'Failed to save credentials', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error saving credentials: ' + error.message, 'error');
|
|
}
|
|
});
|
|
|
|
// Connect via OAuth
|
|
const connectBtn = document.getElementById('connect-oauth');
|
|
if (connectBtn) {
|
|
connectBtn.addEventListener('click', async function() {
|
|
try {
|
|
const response = await fetch('/api/oauth/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.auth_url) {
|
|
// Clear any previous OAuth flag
|
|
localStorage.removeItem('oauth_complete');
|
|
|
|
// Open OAuth in new window
|
|
const oauthPopup = window.open(result.auth_url, 'QBO_OAuth', 'width=600,height=700');
|
|
|
|
// Start polling for OAuth completion
|
|
startOAuthPolling(oauthPopup);
|
|
} else {
|
|
showToast(result.error || 'Failed to start OAuth flow', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error starting OAuth: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Test connection
|
|
const testBtn = document.getElementById('test-connection');
|
|
if (testBtn) {
|
|
testBtn.addEventListener('click', async function() {
|
|
try {
|
|
const response = await fetch('/api/connection/test');
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(`Connected to ${result.company_name}. Found ${result.customers_count} customers, ${result.vendors_count} vendors, ${result.accounts_count} accounts.`, 'success');
|
|
} else {
|
|
showToast(result.error || 'Connection test failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error testing connection: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Disconnect
|
|
const disconnectBtn = document.getElementById('disconnect');
|
|
if (disconnectBtn) {
|
|
disconnectBtn.addEventListener('click', async function() {
|
|
if (!confirm('Are you sure you want to disconnect from QuickBooks?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/disconnect', {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast('Disconnected successfully', 'success');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showToast(result.error || 'Failed to disconnect', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error disconnecting: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Manual token form
|
|
document.getElementById('manual-token-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
access_token: document.getElementById('access_token').value,
|
|
refresh_token: document.getElementById('refresh_token').value,
|
|
realm_id: document.getElementById('realm_id').value
|
|
};
|
|
|
|
if (!formData.access_token || !formData.realm_id) {
|
|
showToast('Please enter at least the Access Token and Realm ID', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/oauth/manual', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showToast(`Connected to ${result.company_name}`, 'success');
|
|
setTimeout(() => location.reload(), 1000);
|
|
} else {
|
|
showToast(result.error || 'Failed to connect with tokens', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error connecting: ' + error.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Function to poll for OAuth completion
|
|
function startOAuthPolling(popup) {
|
|
// Clear any existing timer
|
|
if (oauthPollTimer) {
|
|
clearInterval(oauthPollTimer);
|
|
}
|
|
|
|
let checkCount = 0;
|
|
const maxChecks = 300; // 5 minutes max (300 * 1000ms)
|
|
|
|
oauthPollTimer = setInterval(async function() {
|
|
checkCount++;
|
|
|
|
// Check localStorage for OAuth completion signal
|
|
const oauthComplete = localStorage.getItem('oauth_complete');
|
|
if (oauthComplete) {
|
|
clearInterval(oauthPollTimer);
|
|
localStorage.removeItem('oauth_complete');
|
|
showToast('Successfully connected to QuickBooks!', 'success');
|
|
location.reload();
|
|
return;
|
|
}
|
|
|
|
// Check if popup is closed
|
|
if (popup && popup.closed) {
|
|
clearInterval(oauthPollTimer);
|
|
|
|
// Wait a moment then check connection status
|
|
setTimeout(async function() {
|
|
try {
|
|
const response = await fetch('/api/connection/test');
|
|
const result = await response.json();
|
|
|
|
if (result.connected === true) {
|
|
showToast('Successfully connected to QuickBooks!', 'success');
|
|
location.reload();
|
|
} else {
|
|
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
|
|
}
|
|
} catch (err) {
|
|
showToast('Popup closed. Click "Connect to QuickBooks" to try again.', 'warning');
|
|
}
|
|
}, 1500);
|
|
return;
|
|
}
|
|
|
|
// Stop polling after max time
|
|
if (checkCount >= maxChecks) {
|
|
clearInterval(oauthPollTimer);
|
|
showToast('OAuth timeout. Please try again.', 'warning');
|
|
}
|
|
}, 1000);
|
|
}
|
|
</script>
|
|
{% endblock %} |