feat(astrolabe): implement app password provisioning for multi-user background sync

Adds complete app password provisioning workflow for multi-user BasicAuth
deployments, allowing users to independently enable background sync by
generating and storing Nextcloud app passwords.

**New Components:**

Backend (PHP):
- CredentialsController: Validates and stores app passwords
  * Validates app password format and authenticity via OCS API
  * Stores encrypted passwords in oc_preferences
  * Provides status and credential management endpoints
- AstrolabeAdminSettings: Admin configuration page for MCP server URL
- AstrolabeAdminSettingsListener: Event listener for admin section
- Updated McpTokenStorage: Added background sync credential methods

Frontend:
- personalSettings.js: Form handling for app password entry
  * AJAX submission with error handling
  * Shows success/error notifications
  * Triggers page reload after successful save
- settings.css: Styling for settings pages
- Updated personal.php template: Two-option UI
  * Option 1: OAuth refresh token (future, not yet available)
  * Option 2: App password (works today, recommended)
  * Shows "Active" badge when provisioned
  * Displays credential type and provisioned timestamp

Routes:
- POST /api/v1/background-sync/credentials - Store app password
- GET /api/v1/background-sync/status - Get provisioning status
- DELETE /api/v1/background-sync/credentials - Revoke credentials
- GET /api/v1/background-sync/credentials/{userId} - Admin only

**Testing:**
- test_astrolabe_settings_buttons.py: Integration test for UI buttons

**Workflow:**
1. User generates app password in Nextcloud Security settings
2. User navigates to Astrolabe personal settings
3. User enters app password in "Option 2: App Password" form
4. Backend validates password via OCS API call
5. Password stored encrypted in oc_preferences
6. Page reloads showing "Active" badge with credential details
7. MCP server can now use stored password for background operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-22 19:39:13 +01:00
co-authored by Claude Sonnet 4.5
parent b293258210
commit 65c3f099fa
18 changed files with 1387 additions and 232 deletions
+2 -93
View File
@@ -14,7 +14,7 @@
*/
script('astrolabe', 'astrolabe-adminSettings');
style('astrolabe', 'astrolabe-settings');
style('astrolabe', 'astrolabe-adminSettings');
?>
<div id="mcp-admin-settings" class="section">
@@ -22,98 +22,7 @@ style('astrolabe', 'astrolabe-settings');
<div class="mcp-settings-info">
<p><?php p($l->t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?></p>
</div>
<!-- Configuration Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Configuration')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Service URL')); ?></strong></td>
<td>
<?php if (!empty($_['serverUrl'])): ?>
<code><?php p($_['serverUrl']); ?></code>
<?php else: ?>
<span class="error"><?php p($l->t('Not configured')); ?></span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('API Key')); ?></strong></td>
<td>
<?php if ($_['apiKeyConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-warning">
<span class="icon icon-alert"></span>
<?php p($l->t('Not configured')); ?>
</span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('OAuth Client ID')); ?></strong></td>
<td>
<?php if ($_['clientIdConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-warning">
<span class="icon icon-alert"></span>
<?php p($l->t('Not configured - OAuth will not work')); ?>
</span>
<?php endif; ?>
</td>
</tr>
<tr>
<td><strong><?php p($l->t('OAuth Client Secret')); ?></strong></td>
<td>
<?php if ($_['clientSecretConfigured']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Configured')); ?>
</span>
<?php else: ?>
<span class="badge badge-info">
<?php p($l->t('Optional - Uses PKCE fallback')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
<?php if (empty($_['serverUrl']) || !$_['apiKeyConfigured'] || !$_['clientIdConfigured']): ?>
<div class="notecard notecard-warning">
<p><strong><?php p($l->t('Configuration Required')); ?></strong></p>
<p><?php p($l->t('Add the following to your config.php:')); ?></p>
<pre><code>'mcp_server_url' => 'http://localhost:8000',
'mcp_server_api_key' => 'your-secret-api-key',
'astrolabe_client_id' => 'your-oauth-client-id',</code></pre>
<p class="mcp-help-text">
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
<?php p($l->t('See documentation for details')); ?>
</a>
</p>
</div>
<?php endif; ?>
<?php if (!$_['clientSecretConfigured']): ?>
<div class="notecard notecard-info">
<p><strong><?php p($l->t('Optional: Confidential OAuth Client')); ?></strong></p>
<p><?php p($l->t('To use refresh tokens for long-lived sessions, generate a client secret:')); ?></p>
<pre><code>openssl rand -hex 32</code></pre>
<p><?php p($l->t('Then add it to your config.php:')); ?></p>
<pre><code>'astrolabe_client_secret' => 'your-generated-secret',</code></pre>
<p class="mcp-help-text">
<?php p($l->t('Without a client secret, the system will use PKCE (public client) authentication. Both methods work, but confidential clients provide better security for long-lived sessions.')); ?>
</p>
</div>
<?php endif; ?>
<p><?php p($l->t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?></p>
</div>
<!-- Service Status -->
-2
View File
@@ -10,8 +10,6 @@
* @var string $_['server_url'] Configured server URL (optional)
* @var string $_['help_text'] Additional help text (optional)
*/
style('astrolabe', 'astrolabe-settings');
?>
<div class="mcp-settings-error">
+17 -27
View File
@@ -14,29 +14,23 @@
use OCP\Util;
Util::addStyle('astrolabe', 'astrolabe-settings');
Util::addStyle('astrolabe', 'astrolabe-personalSettings');
?>
<div id="mcp-personal-settings">
<div class="mcp-settings-info">
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content.')); ?></p>
</div>
<div class="section">
<h2><?php p($l->t('Astrolabe')); ?></h2>
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content.')); ?></p>
</div>
<?php if (isset($_['error_message'])): ?>
<div class="mcp-status-card mcp-error">
<h3>
<span class="icon icon-error"></span>
<?php p($l->t('Session Expired')); ?>
</h3>
<p><?php p($_['error_message']); ?></p>
</div>
<?php endif; ?>
<?php if (isset($_['error_message'])): ?>
<div class="section">
<h2><?php p($l->t('Session Expired')); ?></h2>
<p><?php p($_['error_message']); ?></p>
</div>
<?php endif; ?>
<div class="mcp-status-card">
<h3>
<span class="icon icon-search"></span>
<?php p($l->t('Enable Semantic Search')); ?>
</h3>
<div class="section">
<h2><?php p($l->t('Enable Semantic Search')); ?></h2>
<?php if (isset($_['has_expired']) && $_['has_expired']): ?>
<p>
@@ -96,16 +90,13 @@ Util::addStyle('astrolabe', 'astrolabe-settings');
</a>
</div>
<p class="mcp-help-text" style="margin-top: 16px;">
<p>
<?php p($l->t('You can disable indexing at any time from this settings page.')); ?>
</p>
</div>
</div>
<div class="mcp-status-card">
<h3>
<span class="icon icon-info"></span>
<?php p($l->t('About Astrolabe')); ?>
</h3>
<div class="section">
<h2><?php p($l->t('About Astrolabe')); ?></h2>
<p>
<?php p($l->t('Astrolabe enables semantic search - finding content by meaning rather than exact keywords. Ask questions like "meeting notes from last week" or "recipes with chicken" to find relevant documents.')); ?>
@@ -123,5 +114,4 @@ Util::addStyle('astrolabe', 'astrolabe-settings');
</a>
</li>
</ul>
</div>
</div>
+134 -106
View File
@@ -18,102 +18,156 @@
$urlGenerator = \OC::$server->getURLGenerator();
script('astrolabe', 'astrolabe-personalSettings');
style('astrolabe', 'astrolabe-settings');
style('astrolabe', 'astrolabe-personalSettings');
?>
<div id="mcp-personal-settings" class="section">
<div class="section">
<h2><?php p($l->t('Astrolabe')); ?></h2>
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?></p>
</div>
<div class="mcp-settings-info">
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?></p>
</div>
<!-- Service Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Service Status')); ?></h3>
<div class="section">
<h2><?php p($l->t('Service Status')); ?></h2>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Service URL')); ?></strong></td>
<td><?php p($l->t('Service URL')); ?></td>
<td><code><?php p($_['serverUrl']); ?></code></td>
</tr>
<tr>
<td><strong><?php p($l->t('Version')); ?></strong></td>
<td><?php p($l->t('Version')); ?></td>
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
</tr>
</table>
</div>
</div>
<!-- Indexing Status -->
<div class="mcp-status-card">
<h3><?php p($l->t('Content Indexing')); ?></h3>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Status')); ?></strong></td>
<td>
<?php if ($_['backgroundAccessGranted']): ?>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Active')); ?>
</span>
<?php else: ?>
<span class="badge badge-neutral">
<?php p($l->t('Not Enabled')); ?>
</span>
<?php endif; ?>
</td>
</tr>
</table>
<div class="section">
<h2><?php p($l->t('Background Sync Access')); ?></h2>
<?php if (!$_['backgroundAccessGranted']): ?>
<div class="mcp-grant-section">
<p class="mcp-help-text">
<?php p($l->t('Enable background indexing to use semantic search. Your Notes, Files, Calendar events, and Deck cards will be indexed so you can search by meaning.')); ?>
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?>
<!-- Already configured -->
<div class="mcp-background-status">
<p>
<span class="badge badge-success">
<span class="icon icon-checkmark-white"></span>
<?php p($l->t('Active')); ?>
</span>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button primary" id="mcp-grant-button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Enable Semantic Search')); ?>
</a>
</div>
<?php endif; ?>
<?php if ($_['backgroundAccessGranted'] && isset($_['session']['background_access_details'])): ?>
<div class="mcp-background-details">
<h4><?php p($l->t('Indexing Details')); ?></h4>
<table class="mcp-info-table">
<tr>
<td><strong><?php p($l->t('Enabled Since')); ?></strong></td>
<td><?php p($_['session']['background_access_details']['provisioned_at'] ?? 'N/A'); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Indexed Content')); ?></strong></td>
<td><code style="font-size: 11px;"><?php p($_['session']['background_access_details']['scopes'] ?? 'N/A'); ?></code></td>
<td><?php p($l->t('Credential Type')); ?></td>
<td>
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
<?php p($l->t('App Password')); ?>
<?php else: ?>
<?php p($l->t('OAuth Refresh Token')); ?>
<?php endif; ?>
</td>
</tr>
<?php if ($_['backgroundSyncProvisionedAt']): ?>
<tr>
<td><?php p($l->t('Provisioned At')); ?></td>
<td><?php p(date('c', $_['backgroundSyncProvisionedAt'])); ?></td>
</tr>
<?php elseif (isset($_['session']['background_access_details']['provisioned_at'])): ?>
<tr>
<td><?php p($l->t('Provisioned At')); ?></td>
<td><?php p($_['session']['background_access_details']['provisioned_at']); ?></td>
</tr>
<?php endif; ?>
<?php if (isset($_['session']['background_access_details']['scopes'])): ?>
<tr>
<td><?php p($l->t('Indexed Content')); ?></td>
<td><code><?php p($_['session']['background_access_details']['scopes'] ?? 'N/A'); ?></code></td>
</tr>
<?php endif; ?>
</table>
<div class="mcp-revoke-section">
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.api.revokeAccess')); ?>" id="mcp-revoke-form">
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.deleteCredentials')); ?>" id="mcp-revoke-background-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" id="mcp-revoke-background-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Revoke Access')); ?>
</button>
<p class="mcp-help-text">
<?php p($l->t('This will revoke background sync access. The MCP server will no longer be able to access your Nextcloud data for background operations.')); ?>
</p>
</form>
<?php else: ?>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.api.revokeAccess')); ?>" id="mcp-revoke-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" id="mcp-revoke-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Disable Indexing')); ?>
</button>
<p class="mcp-help-text">
<?php p($l->t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?>
</p>
</form>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<!-- Not configured - show provisioning options -->
<p class="mcp-help-text">
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
</p>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
</p>
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
<span class="icon icon-confirm"></span>
<?php p($l->t('Authorize via OAuth')); ?>
</a>
</div>
<div class="mcp-grant-section">
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
<p class="mcp-help-text">
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
</p>
<div class="mcp-app-password-steps">
<p><strong><?php p($l->t('Step 1:')); ?></strong>
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
<?php p($l->t('Generate app password in Security settings')); ?>
</a>
</p>
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" id="mcp-revoke-button">
<span class="icon icon-delete"></span>
<?php p($l->t('Disable Indexing')); ?>
</button>
<div class="mcp-input-group">
<input type="password" name="appPassword" id="mcp-app-password-input"
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
required>
<button type="submit" class="button primary" id="mcp-save-app-password-button">
<span class="icon icon-checkmark"></span>
<?php p($l->t('Save')); ?>
</button>
</div>
<p class="mcp-help-text">
<?php p($l->t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?>
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
</p>
</form>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Identity Provider Profile -->
<?php if (isset($_['session']['idp_profile'])): ?>
<div class="mcp-status-card">
<h3><?php p($l->t('Identity Provider Profile')); ?></h3>
<?php if (isset($_['session']['idp_profile'])): ?>
<div class="section">
<h2><?php p($l->t('Identity Provider Profile')); ?></h2>
<table class="mcp-info-table">
<?php foreach ($_['session']['idp_profile'] as $key => $value): ?>
<tr>
<td><strong><?php p(ucfirst(str_replace('_', ' ', $key))); ?></strong></td>
<td><?php p(ucfirst(str_replace('_', ' ', $key))); ?></td>
<td>
<?php if (is_array($value)): ?>
<?php p(implode(', ', $value)); ?>
@@ -124,31 +178,29 @@ style('astrolabe', 'astrolabe-settings');
</tr>
<?php endforeach; ?>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Semantic Search Features -->
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-status-card">
<h3><?php p($l->t('Search Your Content')); ?></h3>
<?php if ($_['vectorSyncEnabled']): ?>
<div class="section">
<h2><?php p($l->t('Search Your Content')); ?></h2>
<p><?php p($l->t('Use natural language to search across your Notes, Files, Calendar, and Deck cards. Ask questions like "meeting notes from last week" or "recipes with chicken".')); ?></p>
<a href="<?php p($urlGenerator->linkToRoute('astrolabe.page.index')); ?>" class="button primary">
<span class="icon icon-search"></span>
<?php p($l->t('Open Astrolabe')); ?>
</a>
</div>
<?php else: ?>
<div class="mcp-status-card mcp-disabled">
<h3><?php p($l->t('Semantic Search')); ?></h3>
<p class="mcp-help-text">
<?php p($l->t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?>
</p>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="section">
<h2><?php p($l->t('Semantic Search')); ?></h2>
<p>
<?php p($l->t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?>
</p>
</div>
<?php endif; ?>
<!-- Connection Management -->
<div class="mcp-status-card">
<h3><?php p($l->t('Manage Connection')); ?></h3>
<div class="section">
<h2><?php p($l->t('Manage Connection')); ?></h2>
<p><?php p($l->t('You are connected to the Astrolabe service.')); ?></p>
<div class="mcp-revoke-section">
@@ -162,29 +214,5 @@ style('astrolabe', 'astrolabe-settings');
<?php p($l->t('This will disconnect from the Astrolabe service. You will need to re-authorize to use semantic search features.')); ?>
</p>
</form>
</div>
</div>
</div>
<script>
// Confirm disable and disconnect actions
document.addEventListener('DOMContentLoaded', function() {
const revokeForm = document.getElementById('mcp-revoke-form');
if (revokeForm) {
revokeForm.addEventListener('submit', function(e) {
if (!confirm('<?php p($l->t('Are you sure you want to disable indexing? Your content will be removed from semantic search.')); ?>')) {
e.preventDefault();
}
});
}
const disconnectForm = document.getElementById('mcp-disconnect-form');
if (disconnectForm) {
disconnectForm.addEventListener('submit', function(e) {
if (!confirm('<?php p($l->t('Are you sure you want to disconnect from Astrolabe? You will need to re-authorize to use semantic search.')); ?>')) {
e.preventDefault();
}
});
}
});
</script>