diff --git a/.gitignore b/.gitignore index 35ba9ea..66f3984 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ tests/clover.xml build/artifacts vendor .php-cs-fixer.cache + +# frontend +node_modules +js/*.js +js/*.js.map +js/*.LICENSE.txt diff --git a/appinfo/info.xml b/appinfo/info.xml index 34cbe12..cfd0fad 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -35,4 +35,8 @@ Read the [documentation](https://github.com/nextcloud/user_external#readme) to l + + OCA\UserExternal\Settings\Admin + OCA\UserExternal\Settings\AdminSection + diff --git a/appinfo/routes.php b/appinfo/routes.php new file mode 100644 index 0000000..109ec16 --- /dev/null +++ b/appinfo/routes.php @@ -0,0 +1,10 @@ + [ + ['name' => 'Config#getBackends', 'url' => '/api/v1/backends', 'verb' => 'GET'], + ['name' => 'Config#setBackends', 'url' => '/api/v1/backends', 'verb' => 'PUT'], + ], +]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 442cd72..359d254 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -10,8 +10,10 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; class Application extends App implements IBootstrap { + public const APP_ID = 'user_external'; + public function __construct() { - parent::__construct('user_external'); + parent::__construct(self::APP_ID); } public function register(IRegistrationContext $context): void { diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php new file mode 100644 index 0000000..9927ec5 --- /dev/null +++ b/lib/Controller/ConfigController.php @@ -0,0 +1,62 @@ +backendConfigService->getBackends()); + } + + /** + * Replaces the user_backends configuration. + * Requires admin (no @NoAdminRequired). + * + * @param list}> $backends + */ + public function setBackends(array $backends): JSONResponse { + // Validate that each entry has the required keys and an allowed class. + $allowed = [ + '\\OCA\\UserExternal\\IMAP', + '\\OCA\\UserExternal\\FTP', + '\\OCA\\UserExternal\\SMB', + '\\OCA\\UserExternal\\SSH', + '\\OCA\\UserExternal\\BasicAuth', + '\\OCA\\UserExternal\\WebDavAuth', + '\\OCA\\UserExternal\\XMPP', + ]; + + foreach ($backends as $backend) { + if (!isset($backend['class'], $backend['arguments'])) { + return new JSONResponse(['error' => 'Invalid backend format'], Http::STATUS_BAD_REQUEST); + } + if (!in_array($backend['class'], $allowed, true)) { + return new JSONResponse(['error' => 'Unknown backend class: ' . $backend['class']], Http::STATUS_BAD_REQUEST); + } + } + + $this->backendConfigService->setBackends($backends); + return new JSONResponse(['status' => 'ok']); + } +} diff --git a/lib/Service/BackendConfigService.php b/lib/Service/BackendConfigService.php new file mode 100644 index 0000000..878c60c --- /dev/null +++ b/lib/Service/BackendConfigService.php @@ -0,0 +1,25 @@ +}> */ + public function getBackends(): array { + return $this->config->getSystemValue('user_backends', []); + } + + /** @param list}> $backends */ + public function setBackends(array $backends): void { + $this->config->setSystemValue('user_backends', $backends); + } +} diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php new file mode 100644 index 0000000..4bb7e37 --- /dev/null +++ b/lib/Settings/Admin.php @@ -0,0 +1,35 @@ +initialState->provideInitialState('backends', $this->backendConfigService->getBackends()); + + return new TemplateResponse('user_external', 'admin', [], TemplateResponse::RENDER_AS_BLANK); + } + + #[\Override] + public function getSection(): string { + return 'user_external'; + } + + #[\Override] + public function getPriority(): int { + return 50; + } +} diff --git a/lib/Settings/AdminSection.php b/lib/Settings/AdminSection.php new file mode 100644 index 0000000..b78c93d --- /dev/null +++ b/lib/Settings/AdminSection.php @@ -0,0 +1,37 @@ +url->imagePath('user_external', 'app.svg'); + } + + #[\Override] + public function getID(): string { + return 'user_external'; + } + + #[\Override] + public function getName(): string { + return $this->l->t('External user authentication'); + } + + #[\Override] + public function getPriority(): int { + return 75; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7382ea2 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "user_external", + "version": "3.6.0", + "private": true, + "description": "External user authentication backends for Nextcloud", + "homepage": "https://github.com/nextcloud/user_external", + "bugs": { + "url": "https://github.com/nextcloud/user_external/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/nextcloud/user_external" + }, + "license": "AGPL-3.0-or-later", + "type": "module", + "scripts": { + "build": "vite --mode production build", + "dev": "export NODE_ENV=development; vite --mode development build", + "lint": "eslint --ext .js,.vue src", + "lint:fix": "eslint --ext .js,.vue src --fix", + "watch": "export NODE_ENV=development; vite --mode development build --watch" + }, + "browserslist": [ + "extends @nextcloud/browserslist-config" + ], + "dependencies": { + "@nextcloud/axios": "^2.5.0", + "@nextcloud/dialogs": "^7.3.0", + "@nextcloud/initial-state": "^3.0.0", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/router": "^3.1.0", + "@nextcloud/vue": "^9.5.0", + "vue": "^3.5.29" + }, + "devDependencies": { + "@nextcloud/browserslist-config": "^3.1.2", + "@nextcloud/vite-config": "^2.5.2", + "vite": "^7.3.1" + }, + "engines": { + "node": "^24.0.0", + "npm": "^11.3.0" + } +} diff --git a/src/adminSettings.ts b/src/adminSettings.ts new file mode 100644 index 0000000..e823465 --- /dev/null +++ b/src/adminSettings.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import AdminSettings from './views/AdminSettings.vue' + +const app = createApp(AdminSettings) +app.mount('#user-external-admin-settings') diff --git a/src/backendSpecs.ts b/src/backendSpecs.ts new file mode 100644 index 0000000..eba1f4b --- /dev/null +++ b/src/backendSpecs.ts @@ -0,0 +1,92 @@ +export type FieldType = 'text' | 'number' | 'password' | 'checkbox' | 'select' + +export interface FieldSpec { + key: string + label: string + type: FieldType + required?: boolean + default?: string | number | boolean + options?: string[] // for select fields +} + +export interface BackendSpec { + label: string + fields: FieldSpec[] +} + +export type BackendClass = + | '\\OCA\\UserExternal\\IMAP' + | '\\OCA\\UserExternal\\FTP' + | '\\OCA\\UserExternal\\SMB' + | '\\OCA\\UserExternal\\SSH' + | '\\OCA\\UserExternal\\BasicAuth' + | '\\OCA\\UserExternal\\WebDavAuth' + | '\\OCA\\UserExternal\\XMPP' + +export const BACKEND_SPECS: Record = { + '\\OCA\\UserExternal\\IMAP': { + label: 'IMAP', + fields: [ + { key: 'host', label: 'IMAP server', type: 'text', required: true }, + { key: 'port', label: 'Port', type: 'number', default: 143 }, + { key: 'sslmode', label: 'SSL mode', type: 'select', options: ['', 'ssl', 'tls'], default: '' }, + { key: 'domain', label: 'Email domain restriction', type: 'text', default: '' }, + { key: 'stripDomain', label: 'Strip domain from username', type: 'checkbox', default: true }, + { key: 'groupDomain', label: 'Create group per domain', type: 'checkbox', default: false }, + ], + }, + '\\OCA\\UserExternal\\FTP': { + label: 'FTP', + fields: [ + { key: 'host', label: 'FTP server', type: 'text', required: true }, + { key: 'secure', label: 'Use FTPS (secure)', type: 'checkbox', default: false }, + ], + }, + '\\OCA\\UserExternal\\SMB': { + label: 'SMB / Windows', + fields: [ + { key: 'host', label: 'SMB server', type: 'text', required: true }, + ], + }, + '\\OCA\\UserExternal\\SSH': { + label: 'SSH', + fields: [ + { key: 'host', label: 'SSH server', type: 'text', required: true }, + { key: 'port', label: 'Port', type: 'number', default: 22 }, + ], + }, + '\\OCA\\UserExternal\\BasicAuth': { + label: 'HTTP Basic Auth', + fields: [ + { key: 'url', label: 'Auth URL', type: 'text', required: true }, + ], + }, + '\\OCA\\UserExternal\\WebDavAuth': { + label: 'WebDAV', + fields: [ + { key: 'url', label: 'WebDAV URL', type: 'text', required: true }, + ], + }, + '\\OCA\\UserExternal\\XMPP': { + label: 'XMPP (Prosody)', + fields: [ + { key: 'host', label: 'XMPP domain', type: 'text', required: true }, + { key: 'dbName', label: 'Database name', type: 'text', required: true }, + { key: 'dbUser', label: 'Database user', type: 'text', required: true }, + { key: 'dbPassword', label: 'Database password', type: 'password', required: true }, + { key: 'xmppDomain', label: 'XMPP domain filter', type: 'text', required: true }, + { key: 'passwordHashed', label: 'Passwords are hashed', type: 'checkbox', default: true }, + ], + }, +} + +/** Convert named field values to the positional arguments array the PHP class expects. */ +export function fieldsToArguments(cls: BackendClass, values: Record): unknown[] { + return BACKEND_SPECS[cls].fields.map(f => values[f.key] ?? f.default ?? '') +} + +/** Convert a positional arguments array back to named field values. */ +export function argumentsToFields(cls: BackendClass, args: unknown[]): Record { + const fields = BACKEND_SPECS[cls].fields + return Object.fromEntries(fields.map((f, i) => [f.key, args[i] ?? f.default ?? ''])) +} diff --git a/src/components/BackendEntry.vue b/src/components/BackendEntry.vue new file mode 100644 index 0000000..f129b13 --- /dev/null +++ b/src/components/BackendEntry.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue new file mode 100644 index 0000000..067deb3 --- /dev/null +++ b/src/views/AdminSettings.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/templates/admin.php b/templates/admin.php new file mode 100644 index 0000000..59088ae --- /dev/null +++ b/templates/admin.php @@ -0,0 +1,9 @@ + + +
diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b99e60a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { createAppConfig } from '@nextcloud/vite-config' +import { join } from 'path' + +declare const __dirname: string + +export default createAppConfig({ + adminSettings: join(__dirname, 'src', 'adminSettings.ts'), +}, { + inlineCSS: { relativeCSSInjection: true }, + extractLicenseInformation: true, +})