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
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Settings;
use OCP\IL10N;
use OCP\Settings\DeclarativeSettingsTypes;
use OCP\Settings\IDeclarativeSettingsForm;
class AstrolabeAdminSettings implements IDeclarativeSettingsForm {
public function __construct(
private IL10N $l,
) {
}
public function getSchema(): array {
return [
'id' => 'astrolabe-admin-settings',
'priority' => 10,
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
'section_id' => 'astrolabe',
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
'title' => $this->l->t('MCP Server Configuration'),
'description' => $this->l->t('Configure the connection to your Nextcloud MCP Server'),
'doc_url' => 'https://github.com/cbcoutinho/nextcloud-mcp-server',
'fields' => [
[
'id' => 'mcp_server_url',
'title' => $this->l->t('MCP Server URL'),
'description' => $this->l->t('The base URL of your Nextcloud MCP Server instance (e.g., http://localhost:8000)'),
'type' => DeclarativeSettingsTypes::URL,
'placeholder' => 'http://localhost:8000',
'default' => '',
],
[
'id' => 'mcp_server_api_key',
'title' => $this->l->t('API Key'),
'description' => $this->l->t('Authentication key for the MCP server (leave empty if not required)'),
'type' => DeclarativeSettingsTypes::PASSWORD,
'placeholder' => $this->l->t('Enter API key'),
'default' => '',
],
[
'id' => 'astrolabe_client_id',
'title' => $this->l->t('OAuth Client ID'),
'description' => $this->l->t('The OAuth client ID for Astrolabe (required for multi-user deployments)'),
'type' => DeclarativeSettingsTypes::TEXT,
'placeholder' => $this->l->t('Enter OAuth client ID'),
'default' => '',
],
[
'id' => 'astrolabe_client_secret',
'title' => $this->l->t('OAuth Client Secret'),
'description' => $this->l->t('Optional: Client secret for OAuth. If not set, PKCE will be used as fallback.'),
'type' => DeclarativeSettingsTypes::PASSWORD,
'placeholder' => $this->l->t('Enter client secret (optional)'),
'default' => '',
],
],
];
}
}
+86 -2
View File
@@ -55,11 +55,87 @@ class Personal implements ISettings {
$userId = $user->getUID();
// Fetch server status to determine auth mode
$serverStatus = $this->client->getStatus();
// Check for server connection error
if (isset($serverStatus['error'])) {
return new TemplateResponse(
Application::APP_ID,
'settings/error',
[
'error' => 'Cannot connect to MCP server',
'details' => $serverStatus['error'],
'server_url' => $this->client->getPublicServerUrl(),
],
TemplateResponse::RENDER_AS_BLANK
);
}
// Get auth mode from server (defaults to oauth if not specified)
$authMode = $serverStatus['auth_mode'] ?? 'oauth';
$supportsAppPasswords = $serverStatus['supports_app_passwords'] ?? false;
// Check if user has MCP OAuth token
$token = $this->tokenStorage->getUserToken($userId);
// If no token or token is expired, show OAuth authorization UI
if (!$token || $this->tokenStorage->isExpired($token)) {
// For multi_user_basic mode with app password support, check if user has app password
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
// Check if user has already provided an app password
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
if (!$hasBackgroundAccess) {
// No app password yet - show app password entry form
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
[
'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl
'serverStatus' => $serverStatus,
'auth_mode' => $authMode,
'authMode' => $authMode, // Add camelCase version for template
'supports_app_passwords' => $supportsAppPasswords,
'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version
'session' => null, // No session yet
'hasBackgroundAccess' => false, // FIXED: Add missing parameter
'backgroundAccessGranted' => false, // FIXED: Add missing parameter
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'hasToken' => false, // No OAuth token in multi_user_basic mode
'requesttoken' => \OCP\Util::callRegister(),
],
TemplateResponse::RENDER_AS_BLANK
);
} else {
// User has app password - show active status
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
$parameters = [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => null, // No user session for app passwords
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => true, // App password grants background access
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => false, // No OAuth token
'hasBackgroundAccess' => true,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
'authMode' => $authMode,
'supportsAppPasswords' => $supportsAppPasswords,
'requesttoken' => \OCP\Util::callRegister(),
];
return new TemplateResponse(
Application::APP_ID,
'settings/personal',
$parameters,
TemplateResponse::RENDER_AS_BLANK
);
}
}
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
elseif (!$token || $this->tokenStorage->isExpired($token)) {
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
return new TemplateResponse(
@@ -117,6 +193,11 @@ class Personal implements ISettings {
);
}
// Check background sync credential status
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
// Provide initial state for Vue.js frontend (if needed)
$this->initialState->provideInitialState('user-data', [
'userId' => $userId,
@@ -132,6 +213,9 @@ class Personal implements ISettings {
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
'serverUrl' => $this->client->getPublicServerUrl(),
'hasToken' => true,
'hasBackgroundAccess' => $hasBackgroundAccess,
'backgroundSyncType' => $backgroundSyncType,
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
];
return new TemplateResponse(