From 65c3f099fafcb6770cb8d4edecc53700f04092c6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 22 Dec 2025 19:39:13 +0100 Subject: [PATCH] feat(astrolabe): implement app password provisioning for multi-user background sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../test_astrolabe_settings_buttons.py | 91 ++++++ third_party/astrolabe/appinfo/routes.php | 22 ++ .../astrolabe/lib/AppInfo/Application.php | 17 + .../lib/Controller/ApiController.php | 6 + .../lib/Controller/CredentialsController.php | 258 ++++++++++++++++ .../AstrolabeAdminSettingsListener.php | 101 ++++++ .../astrolabe/lib/Service/McpTokenStorage.php | 172 +++++++++++ .../lib/Settings/AstrolabeAdminSettings.php | 64 ++++ .../astrolabe/lib/Settings/Personal.php | 88 +++++- third_party/astrolabe/src/adminSettings.js | 1 + third_party/astrolabe/src/personalSettings.js | 124 ++++++++ third_party/astrolabe/src/styles/settings.css | 290 ++++++++++++++++++ third_party/astrolabe/templates/index.php | 1 + .../astrolabe/templates/settings/admin.php | 95 +----- .../astrolabe/templates/settings/error.php | 2 - .../templates/settings/oauth-required.php | 44 +-- .../astrolabe/templates/settings/personal.php | 240 ++++++++------- third_party/astrolabe/vite.config.js | 3 +- 18 files changed, 1387 insertions(+), 232 deletions(-) create mode 100644 tests/integration/test_astrolabe_settings_buttons.py create mode 100644 third_party/astrolabe/lib/Controller/CredentialsController.php create mode 100644 third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php create mode 100644 third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php create mode 100644 third_party/astrolabe/src/personalSettings.js create mode 100644 third_party/astrolabe/src/styles/settings.css diff --git a/tests/integration/test_astrolabe_settings_buttons.py b/tests/integration/test_astrolabe_settings_buttons.py new file mode 100644 index 0000000..e93ea7f --- /dev/null +++ b/tests/integration/test_astrolabe_settings_buttons.py @@ -0,0 +1,91 @@ +"""Integration tests for Astrolabe personal settings page buttons. + +Tests the button functionality on /settings/user/astrolabe: +1. Disable Indexing button (POST to /apps/astrolabe/api/revoke) +2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect) + +These tests verify that: +- The endpoints respond correctly to POST requests +- CSRF token validation works +- User actions are properly handled +- Appropriate redirects occur +""" + +import httpx +import pytest + + +@pytest.mark.integration +async def test_disable_indexing_button_endpoint_exists(): + """Test that the Disable Indexing endpoint is accessible.""" + async with httpx.AsyncClient() as client: + # Try without authentication - should return 401 or redirect + response = await client.post( + "http://localhost:8080/apps/astrolabe/api/revoke", + follow_redirects=False, + ) + + # Should get 401 Unauthorized or 30x redirect + assert response.status_code in [401, 301, 302, 303, 307, 308], ( + f"Expected 401 or redirect without auth, got {response.status_code}" + ) + + +@pytest.mark.integration +async def test_disconnect_button_endpoint_exists(): + """Test that the Disconnect endpoint is accessible.""" + async with httpx.AsyncClient() as client: + # Try without authentication - should return 401 or redirect + response = await client.post( + "http://localhost:8080/apps/astrolabe/oauth/disconnect", + follow_redirects=False, + ) + + # Should get 401 Unauthorized or 30x redirect + assert response.status_code in [401, 301, 302, 303, 307, 308], ( + f"Expected 401 or redirect without auth, got {response.status_code}" + ) + + +@pytest.mark.integration +async def test_settings_page_renders_buttons(): + """Test that the settings page template includes button forms. + + This test verifies that the PHP template renders the form elements. + It doesn't require authentication since we're just checking the route exists. + """ + async with httpx.AsyncClient(follow_redirects=False) as client: + # Try to access settings page + response = await client.get("http://localhost:8080/settings/user/astrolabe") + + # Should get 401/redirect if not authenticated (expected) + # or 200 if user session exists from browser testing + assert response.status_code in [200, 401, 302, 303, 307, 308], ( + f"Unexpected status code: {response.status_code}" + ) + + +@pytest.mark.integration +@pytest.mark.skip( + reason="Requires manual authentication - test with Playwright instead" +) +async def test_disconnect_button_functionality(): + """Test that clicking Disconnect button clears user OAuth tokens. + + NOTE: This test is skipped because programmatic login to Nextcloud is complex. + Use Playwright-based tests or manual testing instead. + """ + pass + + +@pytest.mark.integration +@pytest.mark.skip( + reason="Requires manual authentication - test with Playwright instead" +) +async def test_disable_indexing_button_functionality(): + """Test that clicking Disable Indexing button revokes background access. + + NOTE: This test is skipped because programmatic login to Nextcloud is complex. + Use Playwright-based tests or manual testing instead. + """ + pass diff --git a/third_party/astrolabe/appinfo/routes.php b/third_party/astrolabe/appinfo/routes.php index f9fb490..37ca97f 100644 --- a/third_party/astrolabe/appinfo/routes.php +++ b/third_party/astrolabe/appinfo/routes.php @@ -34,6 +34,28 @@ return [ 'verb' => 'POST', ], + // Background sync credentials routes + [ + 'name' => 'credentials#storeAppPassword', + 'url' => '/api/v1/background-sync/credentials', + 'verb' => 'POST', + ], + [ + 'name' => 'credentials#getCredentials', + 'url' => '/api/v1/background-sync/credentials/{userId}', + 'verb' => 'GET', + ], + [ + 'name' => 'credentials#deleteCredentials', + 'url' => '/api/v1/background-sync/credentials', + 'verb' => 'DELETE', + ], + [ + 'name' => 'credentials#getStatus', + 'url' => '/api/v1/background-sync/status', + 'verb' => 'GET', + ], + // Vector search API routes [ 'name' => 'api#search', diff --git a/third_party/astrolabe/lib/AppInfo/Application.php b/third_party/astrolabe/lib/AppInfo/Application.php index 38e7f3e..75aed93 100644 --- a/third_party/astrolabe/lib/AppInfo/Application.php +++ b/third_party/astrolabe/lib/AppInfo/Application.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace OCA\Astrolabe\AppInfo; +use OCA\Astrolabe\Listener\AstrolabeAdminSettingsListener; use OCA\Astrolabe\Search\SemanticSearchProvider; +use OCA\Astrolabe\Settings\AstrolabeAdminSettings; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Settings\Events\DeclarativeSettingsGetValueEvent; +use OCP\Settings\Events\DeclarativeSettingsSetValueEvent; class Application extends App implements IBootstrap { public const APP_ID = 'astrolabe'; @@ -21,6 +25,19 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { // Register unified search provider for semantic search $context->registerSearchProvider(SemanticSearchProvider::class); + + // Register declarative admin settings + $context->registerDeclarativeSettings(AstrolabeAdminSettings::class); + + // Register event listeners for declarative settings + $context->registerEventListener( + DeclarativeSettingsGetValueEvent::class, + AstrolabeAdminSettingsListener::class + ); + $context->registerEventListener( + DeclarativeSettingsSetValueEvent::class, + AstrolabeAdminSettingsListener::class + ); } public function boot(IBootContext $context): void { diff --git a/third_party/astrolabe/lib/Controller/ApiController.php b/third_party/astrolabe/lib/Controller/ApiController.php index 6189b8a..af45f8c 100644 --- a/third_party/astrolabe/lib/Controller/ApiController.php +++ b/third_party/astrolabe/lib/Controller/ApiController.php @@ -97,6 +97,12 @@ class ApiController extends Controller { // TODO: Add flash message/notification for user feedback } else { $this->logger->info("Successfully revoked background access for user $userId"); + + // Delete local OAuth tokens from Nextcloud config + // This ensures hasBackgroundAccess() returns false on next page load + $this->tokenStorage->deleteUserToken($userId); + $this->logger->debug("Deleted local OAuth tokens for user $userId"); + // TODO: Add success flash message/notification } diff --git a/third_party/astrolabe/lib/Controller/CredentialsController.php b/third_party/astrolabe/lib/Controller/CredentialsController.php new file mode 100644 index 0000000..c081886 --- /dev/null +++ b/third_party/astrolabe/lib/Controller/CredentialsController.php @@ -0,0 +1,258 @@ +tokenStorage = $tokenStorage; + $this->userSession = $userSession; + $this->logger = $logger; + $this->config = $config; + $this->client = $client; + $this->httpClientService = $httpClientService; + $this->urlGenerator = $urlGenerator; + } + + /** + * Store app password for background sync. + * + * Validates the app password by making a test request to Nextcloud, + * then stores it encrypted if valid. + * + * @param string $appPassword Nextcloud app password + * @return JSONResponse + */ + #[NoAdminRequired] + public function storeAppPassword(string $appPassword): JSONResponse { + $user = $this->userSession->getUser(); + if (!$user) { + $this->logger->error('storeAppPassword called without authenticated user'); + return new JSONResponse([ + 'success' => false, + 'error' => 'User not authenticated' + ], Http::STATUS_UNAUTHORIZED); + } + + $userId = $user->getUID(); + + // Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx) + if (!preg_match('/^[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}$/', $appPassword)) { + $this->logger->warning("Invalid app password format for user: $userId"); + return new JSONResponse([ + 'success' => false, + 'error' => 'Invalid app password format' + ], Http::STATUS_BAD_REQUEST); + } + + // Validate app password with Nextcloud + $isValid = $this->validateAppPassword($userId, $appPassword); + + if (!$isValid) { + $this->logger->warning("App password validation failed for user: $userId"); + return new JSONResponse([ + 'success' => false, + 'error' => 'Invalid app password. Please check the password and try again.' + ], Http::STATUS_UNAUTHORIZED); + } + + // Store encrypted app password + try { + $this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword); + $this->logger->info("Successfully stored app password for user: $userId"); + + return new JSONResponse([ + 'success' => true, + 'message' => 'App password saved successfully' + ], Http::STATUS_OK); + } catch (\Exception $e) { + $this->logger->error("Failed to store app password for user $userId", [ + 'error' => $e->getMessage() + ]); + return new JSONResponse([ + 'success' => false, + 'error' => 'Failed to save app password' + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Validate app password by making a test request to Nextcloud. + * + * @param string $userId User ID + * @param string $appPassword App password to validate + * @return bool True if valid, false otherwise + */ + private function validateAppPassword(string $userId, string $appPassword): bool { + try { + // Use 127.0.0.1 for internal validation (we're running inside Nextcloud container) + // Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting + // getAbsoluteURL() returns the external URL which isn't accessible from inside the container + $baseUrl = 'http://127.0.0.1'; + + // Make a test request to Nextcloud API with BasicAuth + // Using OCS API user endpoint as a lightweight test + $testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json'; + + $this->logger->debug("Validating app password for user: $userId against $testUrl"); + + // Use Nextcloud's HTTP client + $httpClient = $this->httpClientService->newClient(); + + $response = $httpClient->get($testUrl, [ + 'auth' => [$userId, $appPassword], + 'headers' => [ + 'OCS-APIRequest' => 'true', + 'Accept' => 'application/json', + ], + 'timeout' => 10, + ]); + + $statusCode = $response->getStatusCode(); + + // Success is 200 OK + if ($statusCode === 200) { + $this->logger->debug("App password validation successful for user: $userId"); + return true; + } + + $this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)"); + return false; + } catch (\Exception $e) { + $this->logger->error("Exception during app password validation for user $userId", [ + 'error' => $e->getMessage() + ]); + return false; + } + } + + /** + * Get background sync credentials status for the current user. + * + * @return JSONResponse + */ + #[NoAdminRequired] + public function getStatus(): JSONResponse { + $user = $this->userSession->getUser(); + if (!$user) { + return new JSONResponse([ + 'success' => false, + 'error' => 'User not authenticated' + ], Http::STATUS_UNAUTHORIZED); + } + + $userId = $user->getUID(); + + $hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId); + $syncType = $this->tokenStorage->getBackgroundSyncType($userId); + $provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId); + + return new JSONResponse([ + 'success' => true, + 'has_background_access' => $hasAccess, + 'sync_type' => $syncType, + 'provisioned_at' => $provisionedAt, + ], Http::STATUS_OK); + } + + /** + * Get credentials for a specific user (admin only). + * + * Note: This does NOT return the actual password, only metadata. + * + * @param string $userId User ID to check + * @return JSONResponse + */ + public function getCredentials(string $userId): JSONResponse { + // This endpoint should only be accessible by admins + // For now, just return metadata (not actual credentials) + $hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId); + $syncType = $this->tokenStorage->getBackgroundSyncType($userId); + $provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId); + + return new JSONResponse([ + 'success' => true, + 'user_id' => $userId, + 'has_background_access' => $hasAccess, + 'sync_type' => $syncType, + 'provisioned_at' => $provisionedAt, + ], Http::STATUS_OK); + } + + /** + * Delete background sync credentials for the current user. + * + * @return JSONResponse + */ + #[NoAdminRequired] + public function deleteCredentials(): JSONResponse { + $user = $this->userSession->getUser(); + if (!$user) { + return new JSONResponse([ + 'success' => false, + 'error' => 'User not authenticated' + ], Http::STATUS_UNAUTHORIZED); + } + + $userId = $user->getUID(); + + try { + // Delete both OAuth tokens and app password (if any exist) + $this->tokenStorage->deleteUserToken($userId); + $this->tokenStorage->deleteBackgroundSyncPassword($userId); + + $this->logger->info("Deleted background sync credentials for user: $userId"); + + return new JSONResponse([ + 'success' => true, + 'message' => 'Credentials deleted successfully' + ], Http::STATUS_OK); + } catch (\Exception $e) { + $this->logger->error("Failed to delete credentials for user $userId", [ + 'error' => $e->getMessage() + ]); + return new JSONResponse([ + 'success' => false, + 'error' => 'Failed to delete credentials' + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php b/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php new file mode 100644 index 0000000..8c84c7c --- /dev/null +++ b/third_party/astrolabe/lib/Listener/AstrolabeAdminSettingsListener.php @@ -0,0 +1,101 @@ + + */ +class AstrolabeAdminSettingsListener implements IEventListener { + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if (!$event instanceof DeclarativeSettingsGetValueEvent && !$event instanceof DeclarativeSettingsSetValueEvent) { + return; + } + + if ($event->getApp() !== Application::APP_ID) { + return; + } + + if ($event->getFormId() !== 'astrolabe-admin-settings') { + return; + } + + if ($event instanceof DeclarativeSettingsGetValueEvent) { + $this->handleGetValue($event); + } elseif ($event instanceof DeclarativeSettingsSetValueEvent) { + $this->handleSetValue($event); + } + } + + private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void { + $fieldId = $event->getFieldId(); + + // Map field IDs to system config keys + $value = match($fieldId) { + 'mcp_server_url' => $this->config->getSystemValue('mcp_server_url', ''), + 'mcp_server_api_key' => '****', // Never leak the API key on read + 'astrolabe_client_id' => $this->config->getSystemValue('astrolabe_client_id', ''), + 'astrolabe_client_secret' => '****', // Never leak the secret on read + default => null, + }; + + if ($value !== null) { + $event->setValue($value); + } + } + + private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void { + $fieldId = $event->getFieldId(); + $value = $event->getValue(); + + // Only save if value is not empty (allow clearing by setting to empty string) + // For password fields, if the value is '****', don't update (user didn't change it) + if ($fieldId === 'mcp_server_api_key' && $value === '****') { + $event->stopPropagation(); + return; + } + if ($fieldId === 'astrolabe_client_secret' && $value === '****') { + $event->stopPropagation(); + return; + } + + try { + match($fieldId) { + 'mcp_server_url' => $this->config->setSystemValue('mcp_server_url', (string)$value), + 'mcp_server_api_key' => $this->config->setSystemValue('mcp_server_api_key', (string)$value), + 'astrolabe_client_id' => $this->config->setSystemValue('astrolabe_client_id', (string)$value), + 'astrolabe_client_secret' => $this->config->setSystemValue('astrolabe_client_secret', (string)$value), + default => null, + }; + + $this->logger->info('Astrolabe admin setting updated', [ + 'field' => $fieldId, + 'app' => Application::APP_ID, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to update Astrolabe admin setting', [ + 'field' => $fieldId, + 'error' => $e->getMessage(), + 'app' => Application::APP_ID, + ]); + throw $e; + } + + $event->stopPropagation(); + } +} diff --git a/third_party/astrolabe/lib/Service/McpTokenStorage.php b/third_party/astrolabe/lib/Service/McpTokenStorage.php index df3242c..62cef18 100644 --- a/third_party/astrolabe/lib/Service/McpTokenStorage.php +++ b/third_party/astrolabe/lib/Service/McpTokenStorage.php @@ -202,4 +202,176 @@ class McpTokenStorage { return $token['access_token']; } + + /** + * Store app password for background sync. + * + * App passwords are encrypted before storage and used as an alternative + * to OAuth refresh tokens for background sync operations. + * + * @param string $userId User ID + * @param string $appPassword Nextcloud app password + */ + public function storeBackgroundSyncPassword( + string $userId, + string $appPassword, + ): void { + try { + // Encrypt app password before storage + $encrypted = $this->crypto->encrypt($appPassword); + + // Store in user preferences + $this->config->setUserValue( + $userId, + 'astrolabe', + 'background_sync_password', + $encrypted + ); + + // Mark credential type + $this->config->setUserValue( + $userId, + 'astrolabe', + 'background_sync_type', + 'app_password' + ); + + // Store provisioned timestamp + $this->config->setUserValue( + $userId, + 'astrolabe', + 'background_sync_provisioned_at', + (string)time() + ); + + $this->logger->info("Stored background sync app password for user: $userId"); + } catch (\Exception $e) { + $this->logger->error("Failed to store app password for user $userId", [ + 'error' => $e->getMessage() + ]); + throw $e; + } + } + + /** + * Get app password for background sync. + * + * @param string $userId User ID + * @return string|null Decrypted app password, or null if not set + */ + public function getBackgroundSyncPassword(string $userId): ?string { + try { + $encrypted = $this->config->getUserValue( + $userId, + 'astrolabe', + 'background_sync_password', + '' + ); + + if (empty($encrypted)) { + return null; + } + + // Decrypt app password + return $this->crypto->decrypt($encrypted); + } catch (\Exception $e) { + $this->logger->error("Failed to retrieve app password for user $userId", [ + 'error' => $e->getMessage() + ]); + return null; + } + } + + /** + * Delete background sync app password for a user. + * + * @param string $userId User ID + */ + public function deleteBackgroundSyncPassword(string $userId): void { + try { + $this->config->deleteUserValue( + $userId, + 'astrolabe', + 'background_sync_password' + ); + + $this->config->deleteUserValue( + $userId, + 'astrolabe', + 'background_sync_type' + ); + + $this->config->deleteUserValue( + $userId, + 'astrolabe', + 'background_sync_provisioned_at' + ); + + $this->logger->info("Deleted background sync app password for user: $userId"); + } catch (\Exception $e) { + $this->logger->error("Failed to delete app password for user $userId", [ + 'error' => $e->getMessage() + ]); + throw $e; + } + } + + /** + * Check if user has provisioned background sync access. + * + * Returns true if either OAuth tokens or app password is configured. + * + * @param string $userId User ID + * @return bool True if background sync is provisioned + */ + public function hasBackgroundSyncAccess(string $userId): bool { + // Check for OAuth tokens + $oauthToken = $this->getUserToken($userId); + if ($oauthToken !== null) { + return true; + } + + // Check for app password + $appPassword = $this->getBackgroundSyncPassword($userId); + return $appPassword !== null; + } + + /** + * Get background sync credential type for a user. + * + * @param string $userId User ID + * @return string|null 'oauth' or 'app_password', or null if not provisioned + */ + public function getBackgroundSyncType(string $userId): ?string { + $type = $this->config->getUserValue( + $userId, + 'astrolabe', + 'background_sync_type', + '' + ); + + // Fallback to OAuth if tokens exist but type not set + if (empty($type) && $this->getUserToken($userId) !== null) { + return 'oauth'; + } + + return empty($type) ? null : $type; + } + + /** + * Get background sync provisioned timestamp for a user. + * + * @param string $userId User ID + * @return int|null Unix timestamp, or null if not provisioned + */ + public function getBackgroundSyncProvisionedAt(string $userId): ?int { + $timestamp = $this->config->getUserValue( + $userId, + 'astrolabe', + 'background_sync_provisioned_at', + '' + ); + + return empty($timestamp) ? null : (int)$timestamp; + } } diff --git a/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php b/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php new file mode 100644 index 0000000..f549f5d --- /dev/null +++ b/third_party/astrolabe/lib/Settings/AstrolabeAdminSettings.php @@ -0,0 +1,64 @@ + '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' => '', + ], + ], + ]; + } +} diff --git a/third_party/astrolabe/lib/Settings/Personal.php b/third_party/astrolabe/lib/Settings/Personal.php index 3632faa..577f1e8 100644 --- a/third_party/astrolabe/lib/Settings/Personal.php +++ b/third_party/astrolabe/lib/Settings/Personal.php @@ -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( diff --git a/third_party/astrolabe/src/adminSettings.js b/third_party/astrolabe/src/adminSettings.js index 67aa6fc..84cde7f 100644 --- a/third_party/astrolabe/src/adminSettings.js +++ b/third_party/astrolabe/src/adminSettings.js @@ -9,6 +9,7 @@ import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' +import './styles/settings.css' document.addEventListener('DOMContentLoaded', () => { // Initialize search settings form diff --git a/third_party/astrolabe/src/personalSettings.js b/third_party/astrolabe/src/personalSettings.js new file mode 100644 index 0000000..12c1427 --- /dev/null +++ b/third_party/astrolabe/src/personalSettings.js @@ -0,0 +1,124 @@ +/** + * Personal settings page JavaScript for Astrolabe. + * + * Loads styles for the personal settings page and handles form interactions. + */ + +import './styles/settings.css' + +// Wait for DOM to be ready +document.addEventListener('DOMContentLoaded', function() { + // Helper function to show error notifications + function showError(message) { + if (typeof OC !== 'undefined' && OC.Notification) { + OC.Notification.showTemporary(message, { type: 'error' }) + } else { + alert(message) + } + } + + function showSuccess(message) { + if (typeof OC !== 'undefined' && OC.Notification) { + OC.Notification.showTemporary(message, { type: 'success' }) + } else { + alert(message) + } + } + + // App password form with error handling + const appPasswordForm = document.getElementById('mcp-app-password-form') + if (appPasswordForm) { + appPasswordForm.addEventListener('submit', async function(e) { + e.preventDefault() + const submitButton = document.getElementById('mcp-save-app-password-button') + const originalText = submitButton.textContent + + try { + submitButton.disabled = true + submitButton.textContent = t('astrolabe', 'Saving...') + + const formData = new FormData(appPasswordForm) + const response = await fetch(appPasswordForm.action, { + method: 'POST', + body: formData, + }) + + const result = await response.json() + + if (response.ok && result.success) { + showSuccess(t('astrolabe', 'Background sync access successfully provisioned!')) + setTimeout(() => window.location.reload(), 1000) + } else { + showError(result.error || t('astrolabe', 'Failed to save app password. Please check that it is valid.')) + } + } catch (error) { + console.error('App password provisioning error:', error) + showError(t('astrolabe', 'Unable to connect to server. Please check that the MCP server is running and try again.')) + } finally { + submitButton.disabled = false + submitButton.textContent = originalText + } + }) + } + + // Revoke form confirmation + const revokeForm = document.getElementById('mcp-revoke-form') + if (revokeForm) { + revokeForm.addEventListener('submit', function(e) { + if (!confirm(t('astrolabe', 'Are you sure you want to disable indexing? Your content will be removed from semantic search.'))) { + e.preventDefault() + } + }) + } + + // Disconnect form confirmation + const disconnectForm = document.getElementById('mcp-disconnect-form') + if (disconnectForm) { + disconnectForm.addEventListener('submit', function(e) { + if (!confirm(t('astrolabe', 'Are you sure you want to disconnect from Astrolabe? You will need to re-authorize to use semantic search.'))) { + e.preventDefault() + } + }) + } + + // Revoke background access form with error handling + const revokeBackgroundForm = document.getElementById('mcp-revoke-background-form') + if (revokeBackgroundForm) { + revokeBackgroundForm.addEventListener('submit', async function(e) { + e.preventDefault() + + if (!confirm(t('astrolabe', 'Are you sure you want to revoke background sync access? The MCP server will no longer be able to access your Nextcloud data for background operations.'))) { + return + } + + const submitButton = revokeBackgroundForm.querySelector('button[type="submit"]') + const originalText = submitButton.textContent + + try { + submitButton.disabled = true + submitButton.textContent = t('astrolabe', 'Revoking...') + + const formData = new FormData(revokeBackgroundForm) + const response = await fetch(revokeBackgroundForm.action, { + method: 'POST', + body: formData, + }) + + const result = await response.json() + + if (response.ok && result.success) { + showSuccess(t('astrolabe', 'Background sync access revoked successfully.')) + setTimeout(() => window.location.reload(), 1000) + } else { + showError(result.error || t('astrolabe', 'Failed to revoke background sync access.')) + } + } catch (error) { + console.error('Revoke error:', error) + showError(t('astrolabe', 'Unable to connect to server. Your access may already be revoked, or the server may be down.')) + } finally { + submitButton.disabled = false + submitButton.textContent = originalText + } + }) + } +}) diff --git a/third_party/astrolabe/src/styles/settings.css b/third_party/astrolabe/src/styles/settings.css new file mode 100644 index 0000000..69a2f71 --- /dev/null +++ b/third_party/astrolabe/src/styles/settings.css @@ -0,0 +1,290 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Astrolabe settings styles + * Relies on Nextcloud's core .section class for layout + */ + +/* Info tables */ +.mcp-info-table { + width: 100%; + border-collapse: collapse; + margin: calc(var(--default-grid-baseline) * 3) 0; +} + +.mcp-info-table tr { + border-bottom: 1px solid var(--color-border); +} + +.mcp-info-table tr:last-child { + border-bottom: none; +} + +.mcp-info-table td { + padding: calc(var(--default-grid-baseline) * 2) 0; + vertical-align: top; +} + +.mcp-info-table td:first-child { + width: 200px; + color: var(--color-text-maxcontrast); + font-weight: 600; + padding-inline-end: calc(var(--default-grid-baseline) * 4); +} + +.mcp-info-table td:last-child { + color: var(--color-main-text); +} + +/* Status badges */ +.badge { + display: inline-flex; + align-items: center; + gap: calc(var(--default-grid-baseline) * 1.5); + padding: calc(var(--default-grid-baseline) * 1.5) calc(var(--default-grid-baseline) * 3); + border-radius: calc(var(--border-radius-element) * 1.5); + font-size: 13px; + font-weight: 600; +} + +.badge-success { + background: var(--color-success); + color: var(--color-success-text); +} + +.badge-warning { + background: var(--color-warning); + color: var(--color-warning-text); +} + +.badge-neutral { + background: var(--color-background-dark); + color: var(--color-text-maxcontrast); +} + +.badge-info { + background: var(--color-primary-element); + color: var(--color-primary-element-text); +} + +/* Input groups */ +.mcp-input-group { + display: flex; + gap: calc(var(--default-grid-baseline) * 2); + align-items: stretch; + margin-top: calc(var(--default-grid-baseline) * 2); +} + +.mcp-input-group input[type='password'], +.mcp-input-group input[type='text'] { + flex: 1; + font-family: monospace; +} + +/* Revoke/warning sections */ +.mcp-revoke-section { + margin-top: calc(var(--default-grid-baseline) * 4); + padding: calc(var(--default-grid-baseline) * 4); + background: var(--color-warning); + border-radius: var(--border-radius-element); + border-inline-start: calc(var(--default-grid-baseline)) solid var(--color-warning-text); +} + +/* Feature lists */ +.mcp-feature-list { + list-style: none; + padding: 0; + margin: calc(var(--default-grid-baseline) * 3) 0; +} + +.mcp-feature-list li { + display: flex; + gap: calc(var(--default-grid-baseline) * 3); + padding: calc(var(--default-grid-baseline) * 2) 0; + align-items: start; +} + +.mcp-feature-list .icon { + flex-shrink: 0; + width: 24px; + height: 24px; + opacity: 0.7; +} + +.mcp-feature-list div { + flex: 1; +} + +.mcp-feature-list strong { + display: block; + font-weight: 600; + margin-bottom: calc(var(--default-grid-baseline)); +} + +.mcp-feature-list p { + margin: 0; + color: var(--color-text-maxcontrast); +} + +/* Responsive tables */ +@media (max-width: 768px) { + .mcp-info-table td:first-child, + .mcp-info-table td:last-child { + display: block; + width: 100%; + } + + .mcp-info-table td:first-child { + padding-bottom: calc(var(--default-grid-baseline)); + } + + .mcp-info-table td:last-child { + padding-top: calc(var(--default-grid-baseline)); + } +} + +/* Admin settings forms */ +.mcp-settings-form { + max-width: 600px; +} + +.mcp-form-group { + margin-bottom: calc(var(--default-grid-baseline) * 5); +} + +.mcp-form-group label { + display: block; + font-weight: 600; + margin-bottom: calc(var(--default-grid-baseline) * 2); +} + +.mcp-range { + width: 100%; + margin-top: calc(var(--default-grid-baseline) * 2); + accent-color: var(--color-primary-element); +} + +.mcp-form-actions { + display: flex; + align-items: center; + gap: calc(var(--default-grid-baseline) * 4); + margin-top: calc(var(--default-grid-baseline) * 6); + padding-top: calc(var(--default-grid-baseline) * 5); + border-top: 1px solid var(--color-border); +} + +/* Webhook preset cards */ +.mcp-preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: calc(var(--default-grid-baseline) * 4); + margin: calc(var(--default-grid-baseline) * 4) 0; +} + +.mcp-preset-card { + background: var(--color-background-dark); + border-radius: var(--border-radius-container); + padding: calc(var(--default-grid-baseline) * 4); + border: 2px solid transparent; + transition: border-color var(--animation-slow), box-shadow var(--animation-slow); +} + +.mcp-preset-card:hover { + border-color: var(--color-border-dark); + box-shadow: 0 2px 8px var(--color-box-shadow); +} + +.mcp-preset-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: calc(var(--default-grid-baseline) * 3); +} + +.mcp-preset-header h4 { + margin: 0; + font-weight: 600; +} + +.mcp-preset-status { + padding: calc(var(--default-grid-baseline)) calc(var(--default-grid-baseline) * 2.5); + border-radius: calc(var(--border-radius-element) * 1.5); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.mcp-status-enabled { + background: var(--color-success); + color: var(--color-success-text); +} + +.mcp-status-disabled { + background: var(--color-background-darker); + color: var(--color-text-maxcontrast); +} + +.mcp-preset-description { + color: var(--color-text-maxcontrast); + margin-bottom: calc(var(--default-grid-baseline) * 3); +} + +.mcp-preset-meta { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: calc(var(--default-grid-baseline) * 3); + border-top: 1px solid var(--color-border); + margin-bottom: calc(var(--default-grid-baseline) * 3); + font-size: 12px; + color: var(--color-text-maxcontrast); +} + +.mcp-preset-actions { + display: flex; + gap: calc(var(--default-grid-baseline) * 2); +} + +.mcp-preset-toggle { + flex: 1; + padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 4); + border-radius: var(--border-radius-element); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--animation-quick); + border: none; +} + +.mcp-preset-toggle.primary { + background: var(--color-primary-element); + color: var(--color-primary-element-text); +} + +.mcp-preset-toggle.primary:hover:not(:disabled) { + background: var(--color-primary-element-hover); +} + +.mcp-preset-toggle.secondary { + background: var(--color-background-darker); + color: var(--color-main-text); + border: 1px solid var(--color-border); +} + +.mcp-preset-toggle.secondary:hover:not(:disabled) { + background: var(--color-background-hover); + border-color: var(--color-border-dark); +} + +.mcp-preset-toggle:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.mcp-loading { + text-align: center; + padding: calc(var(--default-grid-baseline) * 5); + color: var(--color-text-maxcontrast); + font-style: italic; +} diff --git a/third_party/astrolabe/templates/index.php b/third_party/astrolabe/templates/index.php index af41b7c..a2ca69f 100644 --- a/third_party/astrolabe/templates/index.php +++ b/third_party/astrolabe/templates/index.php @@ -5,6 +5,7 @@ declare(strict_types=1); use OCP\Util; Util::addScript(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main'); +Util::addStyle(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main'); ?> diff --git a/third_party/astrolabe/templates/settings/admin.php b/third_party/astrolabe/templates/settings/admin.php index 88c09b9..64d4625 100644 --- a/third_party/astrolabe/templates/settings/admin.php +++ b/third_party/astrolabe/templates/settings/admin.php @@ -14,7 +14,7 @@ */ script('astrolabe', 'astrolabe-adminSettings'); -style('astrolabe', 'astrolabe-settings'); +style('astrolabe', 'astrolabe-adminSettings'); ?>
@@ -22,98 +22,7 @@ style('astrolabe', 'astrolabe-settings');

t('Monitor and configure the semantic search service for your Nextcloud instance.')); ?>

-
- - -
-

t('Configuration')); ?>

- - - - - - - - - - - - - - - - - -
t('Service URL')); ?> - - - - t('Not configured')); ?> - -
t('API Key')); ?> - - - - t('Configured')); ?> - - - - - t('Not configured')); ?> - - -
t('OAuth Client ID')); ?> - - - - t('Configured')); ?> - - - - - t('Not configured - OAuth will not work')); ?> - - -
t('OAuth Client Secret')); ?> - - - - t('Configured')); ?> - - - - t('Optional - Uses PKCE fallback')); ?> - - -
- - -
-

t('Configuration Required')); ?>

-

t('Add the following to your config.php:')); ?>

-
'mcp_server_url' => 'http://localhost:8000',
-'mcp_server_api_key' => 'your-secret-api-key',
-'astrolabe_client_id' => 'your-oauth-client-id',
-

- - t('See documentation for details')); ?> - -

-
- - - -
-

t('Optional: Confidential OAuth Client')); ?>

-

t('To use refresh tokens for long-lived sessions, generate a client secret:')); ?>

-
openssl rand -hex 32
-

t('Then add it to your config.php:')); ?>

-
'astrolabe_client_secret' => 'your-generated-secret',
-

- 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.')); ?> -

-
- +

t('Use the "MCP Server Configuration" section above to configure the connection settings.')); ?>

diff --git a/third_party/astrolabe/templates/settings/error.php b/third_party/astrolabe/templates/settings/error.php index 39533ae..6f31e49 100644 --- a/third_party/astrolabe/templates/settings/error.php +++ b/third_party/astrolabe/templates/settings/error.php @@ -10,8 +10,6 @@ * @var string $_['server_url'] Configured server URL (optional) * @var string $_['help_text'] Additional help text (optional) */ - -style('astrolabe', 'astrolabe-settings'); ?>
diff --git a/third_party/astrolabe/templates/settings/oauth-required.php b/third_party/astrolabe/templates/settings/oauth-required.php index 9ce8fdf..0443d50 100644 --- a/third_party/astrolabe/templates/settings/oauth-required.php +++ b/third_party/astrolabe/templates/settings/oauth-required.php @@ -14,29 +14,23 @@ use OCP\Util; -Util::addStyle('astrolabe', 'astrolabe-settings'); +Util::addStyle('astrolabe', 'astrolabe-personalSettings'); ?> -
-
-

t('AI-powered semantic search across your Nextcloud content.')); ?>

-
+
+

t('Astrolabe')); ?>

+

t('AI-powered semantic search across your Nextcloud content.')); ?>

+
- -
-

- - t('Session Expired')); ?> -

-

-
- + +
+

t('Session Expired')); ?>

+

+
+ -
-

- - t('Enable Semantic Search')); ?> -

+
+

t('Enable Semantic Search')); ?>

@@ -96,16 +90,13 @@ Util::addStyle('astrolabe', 'astrolabe-settings');

-

+

t('You can disable indexing at any time from this settings page.')); ?>

-
+
-
-

- - t('About Astrolabe')); ?> -

+
+

t('About Astrolabe')); ?>

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'); -

diff --git a/third_party/astrolabe/templates/settings/personal.php b/third_party/astrolabe/templates/settings/personal.php index 5abfe57..f4738be 100644 --- a/third_party/astrolabe/templates/settings/personal.php +++ b/third_party/astrolabe/templates/settings/personal.php @@ -18,102 +18,156 @@ $urlGenerator = \OC::$server->getURLGenerator(); script('astrolabe', 'astrolabe-personalSettings'); -style('astrolabe', 'astrolabe-settings'); +style('astrolabe', 'astrolabe-personalSettings'); ?> -
+

t('Astrolabe')); ?>

+

t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?>

+
-
-

t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?>

-
- - -
-

t('Service Status')); ?>

+
+

t('Service Status')); ?>

- + - +
t('Service URL')); ?>t('Service URL')); ?>
t('Version')); ?>t('Version')); ?>
-
+
- -
-

t('Content Indexing')); ?>

- - - - - -
t('Status')); ?> - - - - t('Active')); ?> - - - - t('Not Enabled')); ?> - - -
+
+

t('Background Sync Access')); ?>

- -
-

- 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.')); ?> + + +

+

+ + + t('Active')); ?> +

- - - t('Enable Semantic Search')); ?> - -
- - - -
-

t('Indexing Details')); ?>

- - - - - - + + + + + + + + + + + + + + + + + + +
t('Enabled Since')); ?>
t('Indexed Content')); ?>t('Credential Type')); ?> + + t('App Password')); ?> + + t('OAuth Refresh Token')); ?> + +
t('Provisioned At')); ?>
t('Provisioned At')); ?>
t('Indexed Content')); ?>
-
+ + + + +

+ t('This will revoke background sync access. The MCP server will no longer be able to access your Nextcloud data for background operations.')); ?> +

+
+ +
+ + +

+ t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?> +

+
+ +
+
+ + +

+ t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?> +

+ +
+

t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?>

+

+ t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?> +

+ + + t('Authorize via OAuth')); ?> + +
+ +
+

t('Option 2: App Password (Works Today - Recommended)')); ?>

+

+ t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?> +

+ +
+

t('Step 1:')); ?> + + t('Generate app password in Security settings')); ?> + +

+ +

t('Step 2:')); ?> t('Enter the app password below:')); ?>

+ +
- +
+ + +

- t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?> + t('The app password will be validated and securely encrypted before storage.')); ?>

-
+
- - -
-

t('Identity Provider Profile')); ?>

+ +
+

t('Identity Provider Profile')); ?>

$value): ?> - +
@@ -124,31 +178,29 @@ style('astrolabe', 'astrolabe-settings');
-
- +
+ - - -
-

t('Search Your Content')); ?>

+ +
+

t('Search Your Content')); ?>

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".')); ?>

t('Open Astrolabe')); ?> -
- -
-

t('Semantic Search')); ?>

-

- t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?> -

-
- +
+ +
+

t('Semantic Search')); ?>

+

+ t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?> +

+
+ - -
-

t('Manage Connection')); ?>

+
+

t('Manage Connection')); ?>

t('You are connected to the Astrolabe service.')); ?>

@@ -162,29 +214,5 @@ style('astrolabe', 'astrolabe-settings'); t('This will disconnect from the Astrolabe service. You will need to re-authorize to use semantic search features.')); ?>

-
- - diff --git a/third_party/astrolabe/vite.config.js b/third_party/astrolabe/vite.config.js index 078602d..e7db461 100644 --- a/third_party/astrolabe/vite.config.js +++ b/third_party/astrolabe/vite.config.js @@ -3,6 +3,5 @@ import { createAppConfig } from '@nextcloud/vite-config' export default createAppConfig({ main: 'src/main.js', adminSettings: 'src/adminSettings.js', -}, { - inlineCSS: { relativeCSSInjection: true }, + personalSettings: 'src/personalSettings.js', })