feat(auth): save OAuth credentials per-user to UserIntegration records

- Add 'Authorize' button to integration cards for OAuth types without credentials
- Add isOAuthType() helper and update info box in create/edit modal
- Accept integration_id query param in Dropbox, Google Drive, OneDrive setup views
- Store integration_id in sessionStorage on setup pages
- Add per-user flow in OAuth callbacks: PUT credentials to /api/integrations/{id}
- Preserve existing global flow as fallback when no integration_id is present

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 18:40:25 +00:00
parent ba387b8e8b
commit 73119e0cef
10 changed files with 189 additions and 12 deletions
+3 -2
View File
@@ -2,7 +2,7 @@
Dropbox integration views for setup and OAuth callback. Dropbox integration views for setup and OAuth callback.
""" """
from fastapi import Request from fastapi import Query, Request
from app.views.base import APIRouter, require_login, settings, templates from app.views.base import APIRouter, require_login, settings, templates
@@ -11,7 +11,7 @@ router = APIRouter()
@router.get("/dropbox-setup") @router.get("/dropbox-setup")
@require_login @require_login
async def dropbox_setup_page(request: Request): async def dropbox_setup_page(request: Request, integration_id: int | None = Query(None)):
""" """
Setup page for the Dropbox integration. Setup page for the Dropbox integration.
Shows configuration status and setup instructions. Shows configuration status and setup instructions.
@@ -28,6 +28,7 @@ async def dropbox_setup_page(request: Request):
"app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "", "app_secret_value": settings.dropbox_app_secret if settings.dropbox_app_secret else "",
"refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "", "refresh_token_value": settings.dropbox_refresh_token if settings.dropbox_refresh_token else "",
"folder_path": settings.dropbox_folder or "/Documents/Uploads", # Default folder path "folder_path": settings.dropbox_folder or "/Documents/Uploads", # Default folder path
"integration_id": integration_id,
}, },
) )
+3 -2
View File
@@ -4,7 +4,7 @@ Google Drive integration views for setup and OAuth callback.
import urllib.parse import urllib.parse
from fastapi import Request from fastapi import Query, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from app.views.base import APIRouter, require_login, settings, templates from app.views.base import APIRouter, require_login, settings, templates
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/google-drive-setup") @router.get("/google-drive-setup")
@require_login @require_login
async def google_drive_setup_page(request: Request): async def google_drive_setup_page(request: Request, integration_id: int | None = Query(None)):
""" """
Setup page for the Google Drive integration. Setup page for the Google Drive integration.
Shows configuration status and setup instructions. Shows configuration status and setup instructions.
@@ -55,6 +55,7 @@ async def google_drive_setup_page(request: Request):
"refresh_token_value": settings.google_drive_refresh_token or "", "refresh_token_value": settings.google_drive_refresh_token or "",
"folder_id": settings.google_drive_folder_id or "", "folder_id": settings.google_drive_folder_id or "",
"has_credentials_json": bool(settings.google_drive_credentials_json), "has_credentials_json": bool(settings.google_drive_credentials_json),
"integration_id": integration_id,
}, },
) )
+3 -2
View File
@@ -2,7 +2,7 @@
OneDrive integration views for setup and OAuth callback. OneDrive integration views for setup and OAuth callback.
""" """
from fastapi import Request from fastapi import Query, Request
from app.views.base import APIRouter, require_login, settings, templates from app.views.base import APIRouter, require_login, settings, templates
@@ -11,7 +11,7 @@ router = APIRouter()
@router.get("/onedrive-setup") @router.get("/onedrive-setup")
@require_login @require_login
async def onedrive_setup_page(request: Request): async def onedrive_setup_page(request: Request, integration_id: int | None = Query(None)):
""" """
Setup page for the OneDrive integration. Setup page for the OneDrive integration.
Shows configuration status and setup instructions. Shows configuration status and setup instructions.
@@ -35,6 +35,7 @@ async def onedrive_setup_page(request: Request):
"refresh_token": bool(settings.onedrive_refresh_token), "refresh_token": bool(settings.onedrive_refresh_token),
"refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "", "refresh_token_value": settings.onedrive_refresh_token if settings.onedrive_refresh_token else "",
"folder_path": settings.onedrive_folder_path or "Documents/Uploads", # Default folder path "folder_path": settings.onedrive_folder_path or "Documents/Uploads", # Default folder path
"integration_id": integration_id,
}, },
) )
+6
View File
@@ -216,6 +216,12 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
if (integrationId) {
sessionStorage.setItem('oauth_integration_id', integrationId);
}
// Elements // Elements
const startAuthFlowBtn = document.getElementById('start-auth-flow'); const startAuthFlowBtn = document.getElementById('start-auth-flow');
const testTokenBtn = document.getElementById('test-token'); const testTokenBtn = document.getElementById('test-token');
+45 -1
View File
@@ -97,6 +97,7 @@ document.addEventListener('DOMContentLoaded', function() {
const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}"; const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}"; const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads'; const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
const integrationId = sessionStorage.getItem('oauth_integration_id');
const redirectUri = window.location.origin + "/dropbox-callback"; const redirectUri = window.location.origin + "/dropbox-callback";
@@ -137,7 +138,50 @@ document.addEventListener('DOMContentLoaded', function() {
}) })
.then(data => { .then(data => {
if (data.refresh_token) { if (data.refresh_token) {
// Update settings in memory // Per-user flow: save to the user's integration record
if (integrationId) {
const creds = {
refresh_token: data.refresh_token,
app_key: appKey,
app_secret: appSecret,
};
const cfgUpdate = {};
if (folderPath) cfgUpdate.folder = folderPath;
const body = { credentials: creds };
if (Object.keys(cfgUpdate).length > 0) body.config = cfgUpdate;
document.getElementById('processing-message').innerHTML =
'<p>Saving credentials to your integration...</p>';
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
});
}
return response.json();
}).then(() => {
// Clean up
sessionStorage.removeItem('dropbox_app_key');
sessionStorage.removeItem('dropbox_app_secret');
sessionStorage.removeItem('dropbox_folder_path');
sessionStorage.removeItem('oauth_integration_id');
// Show brief success then redirect to integrations
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
});
}
// Global flow: update settings in memory
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);
+6
View File
@@ -358,6 +358,12 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
if (integrationId) {
sessionStorage.setItem('oauth_integration_id', integrationId);
}
// Tab elements // Tab elements
const oauthTabBtn = document.getElementById('oauth-tab-btn'); const oauthTabBtn = document.getElementById('oauth-tab-btn');
const saTabBtn = document.getElementById('sa-tab-btn'); const saTabBtn = document.getElementById('sa-tab-btn');
+46 -1
View File
@@ -138,6 +138,7 @@ document.addEventListener('DOMContentLoaded', function() {
const clientId = sessionStorage.getItem('google_drive_client_id'); const clientId = sessionStorage.getItem('google_drive_client_id');
const clientSecret = sessionStorage.getItem('google_drive_client_secret'); const clientSecret = sessionStorage.getItem('google_drive_client_secret');
const folderId = sessionStorage.getItem('google_drive_folder_id'); const folderId = sessionStorage.getItem('google_drive_folder_id');
const integrationId = sessionStorage.getItem('oauth_integration_id');
const redirectUri = window.location.origin + "/google-drive-callback"; const redirectUri = window.location.origin + "/google-drive-callback";
@@ -192,7 +193,51 @@ document.addEventListener('DOMContentLoaded', function() {
refreshToken = data.refresh_token; refreshToken = data.refresh_token;
accessToken = data.access_token; accessToken = data.access_token;
// Update settings in memory first // Per-user flow: save to the user's integration record
if (integrationId) {
const creds = {
client_id: clientId,
client_secret: clientSecret,
refresh_token: data.refresh_token,
};
const cfgUpdate = {};
if (folderId) cfgUpdate.folder_id = folderId;
const body = { credentials: creds };
if (Object.keys(cfgUpdate).length > 0) body.config = cfgUpdate;
document.getElementById('processing-message').innerHTML =
'<p>Saving credentials to your integration...</p>';
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
});
}
return response.json();
}).then(() => {
// Clean up
sessionStorage.removeItem('google_drive_client_id');
sessionStorage.removeItem('google_drive_client_secret');
sessionStorage.removeItem('google_drive_folder_id');
sessionStorage.removeItem('google_drive_use_oauth');
sessionStorage.removeItem('oauth_integration_id');
// Show brief success then redirect to integrations
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ Google Drive authorized successfully!</p>' +
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
});
}
// Global flow: update settings in memory first
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);
+24 -3
View File
@@ -206,6 +206,16 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<template x-if="isOAuthType(intg.integration_type) && !intg.has_credentials">
<a
:href="oauthLink(intg.integration_type) + '?integration_id=' + intg.id"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-yellow-400 text-yellow-700 bg-yellow-50 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
style="min-height:36px;"
:aria-label="`Authorize ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>Authorize
</a>
</template>
<button <button
type="button" type="button"
@click="testSavedIntegration(intg)" @click="testSavedIntegration(intg)"
@@ -279,6 +289,16 @@
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<template x-if="isOAuthType(intg.integration_type) && !intg.has_credentials">
<a
:href="oauthLink(intg.integration_type) + '?integration_id=' + intg.id"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-yellow-400 text-yellow-700 bg-yellow-50 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-500"
style="min-height:36px;"
:aria-label="`Authorize ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>Authorize
</a>
</template>
<button <button
type="button" type="button"
@click="testSavedIntegration(intg)" @click="testSavedIntegration(intg)"
@@ -561,9 +581,8 @@
</div> </div>
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-md p-3 text-sm text-blue-700 dark:text-blue-300"> <div class="bg-blue-50 dark:bg-blue-900/30 rounded-md p-3 text-sm text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> <i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
OAuth tokens can be configured via the dedicated Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
<a :href="oauthLink(form.integration_type)" class="underline hover:text-blue-900 dark:hover:text-blue-100" x-text="typeLabel(form.integration_type) + ' setup page'"></a>. Your credentials will be stored securely in your personal integration record.
Once authorised, the token is stored automatically.
</div> </div>
</div> </div>
</template> </template>
@@ -742,6 +761,7 @@ function integrationsDashboard() {
const SOURCE_TYPES = ['IMAP', 'WATCH_FOLDER', 'WEBHOOK']; const SOURCE_TYPES = ['IMAP', 'WATCH_FOLDER', 'WEBHOOK'];
const DEST_TYPES = ['S3', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'EMAIL', 'PAPERLESS', 'RCLONE']; const DEST_TYPES = ['S3', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'EMAIL', 'PAPERLESS', 'RCLONE'];
const TYPES_WITH_FORM_FIELDS = new Set(['IMAP', 'S3', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'EMAIL', 'WATCH_FOLDER', 'PAPERLESS']); const TYPES_WITH_FORM_FIELDS = new Set(['IMAP', 'S3', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'EMAIL', 'WATCH_FOLDER', 'PAPERLESS']);
const OAUTH_TYPES = new Set(['DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE']);
const TYPE_LABELS = { const TYPE_LABELS = {
IMAP: 'IMAP Email', IMAP: 'IMAP Email',
@@ -838,6 +858,7 @@ function integrationsDashboard() {
typeLabel(t) { return TYPE_LABELS[t] || t; }, typeLabel(t) { return TYPE_LABELS[t] || t; },
typeIcon(t) { return TYPE_ICONS[t] || 'fa-plug text-gray-400'; }, typeIcon(t) { return TYPE_ICONS[t] || 'fa-plug text-gray-400'; },
hasFormFields(t) { return TYPES_WITH_FORM_FIELDS.has(t); }, hasFormFields(t) { return TYPES_WITH_FORM_FIELDS.has(t); },
isOAuthType(t) { return OAUTH_TYPES.has(t); },
oauthLink(t) { oauthLink(t) {
if (t === 'DROPBOX') return '/dropbox-setup'; if (t === 'DROPBOX') return '/dropbox-setup';
+6
View File
@@ -237,6 +237,12 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Store integration_id if provided (for per-user OAuth flow)
const integrationId = "{{ integration_id or '' }}";
if (integrationId) {
sessionStorage.setItem('oauth_integration_id', integrationId);
}
// Elements // Elements
const startAuthFlowBtn = document.getElementById('start-auth-flow'); const startAuthFlowBtn = document.getElementById('start-auth-flow');
const testTokenBtn = document.getElementById('test-token'); const testTokenBtn = document.getElementById('test-token');
+47 -1
View File
@@ -98,6 +98,7 @@ document.addEventListener('DOMContentLoaded', function() {
const clientSecret = sessionStorage.getItem('onedrive_client_secret') || "{{ client_secret_value }}"; const clientSecret = sessionStorage.getItem('onedrive_client_secret') || "{{ client_secret_value }}";
const tenantId = sessionStorage.getItem('onedrive_tenant_id') || "{{ tenant_id }}" || "common"; const tenantId = sessionStorage.getItem('onedrive_tenant_id') || "{{ tenant_id }}" || "common";
const folderPath = sessionStorage.getItem('onedrive_folder_path') || ""; const folderPath = sessionStorage.getItem('onedrive_folder_path') || "";
const integrationId = sessionStorage.getItem('oauth_integration_id');
const redirectUri = window.location.origin + "/onedrive-callback"; const redirectUri = window.location.origin + "/onedrive-callback";
@@ -140,7 +141,52 @@ document.addEventListener('DOMContentLoaded', function() {
}) })
.then(data => { .then(data => {
if (data.refresh_token) { if (data.refresh_token) {
// Instead of saving to .env file, update settings in memory // Per-user flow: save to the user's integration record
if (integrationId) {
const creds = {
client_id: clientId,
client_secret: clientSecret,
refresh_token: data.refresh_token,
tenant_id: tenantId,
};
const cfgUpdate = {};
if (folderPath) cfgUpdate.folder_path = folderPath;
const body = { credentials: creds };
if (Object.keys(cfgUpdate).length > 0) body.config = cfgUpdate;
document.getElementById('processing-message').innerHTML =
'<p>Saving credentials to your integration...</p>';
return fetch(`/api/integrations/${integrationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
});
}
return response.json();
}).then(() => {
// Clean up
sessionStorage.removeItem('onedrive_client_id');
sessionStorage.removeItem('onedrive_client_secret');
sessionStorage.removeItem('onedrive_tenant_id');
sessionStorage.removeItem('onedrive_folder_path');
sessionStorage.removeItem('oauth_integration_id');
// Show brief success then redirect to integrations
document.getElementById('processing-message').innerHTML =
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>' +
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
});
}
// Global flow: update settings in memory
const updateFormData = new FormData(); const updateFormData = new FormData();
updateFormData.append('refresh_token', data.refresh_token); updateFormData.append('refresh_token', data.refresh_token);