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
parent b293258210
commit 65c3f099fa
18 changed files with 1387 additions and 232 deletions
@@ -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
+22
View File
@@ -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',
+17
View File
@@ -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 {
@@ -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
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Controller;
use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Controller for managing background sync credentials (app passwords).
*
* Handles storing and validating app passwords for multi-user BasicAuth mode.
*/
class CredentialsController extends Controller {
private $tokenStorage;
private $userSession;
private $logger;
private $config;
private $client;
private $httpClientService;
private $urlGenerator;
public function __construct(
string $appName,
IRequest $request,
McpTokenStorage $tokenStorage,
IUserSession $userSession,
LoggerInterface $logger,
IConfig $config,
McpServerClient $client,
IClientService $httpClientService,
IURLGenerator $urlGenerator,
) {
parent::__construct($appName, $request);
$this->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);
}
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Listener;
use OCA\Astrolabe\AppInfo\Application;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent>
*/
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();
}
}
+172
View File
@@ -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;
}
}
@@ -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(
+1
View File
@@ -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
+124
View File
@@ -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
}
})
}
})
+290
View File
@@ -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;
}
+1
View File
@@ -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');
?>
+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>
+1 -2
View File
@@ -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',
})