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:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+1
-2
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user