Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ Read the [documentation](https://github.com/nextcloud/user_external#readme) to l
<dependencies>
<nextcloud min-version="25" max-version="30" />
</dependencies>
<settings>
<admin>OCA\UserExternal\Settings\Admin</admin>
<admin-section>OCA\UserExternal\Settings\AdminSection</admin-section>
</settings>
</info>
10 changes: 10 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

return [
'routes' => [
['name' => 'Config#getBackends', 'url' => '/api/v1/backends', 'verb' => 'GET'],
['name' => 'Config#setBackends', 'url' => '/api/v1/backends', 'verb' => 'PUT'],
],
];
4 changes: 3 additions & 1 deletion lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace OCA\UserExternal\Controller;

use OCA\UserExternal\Service\BackendConfigService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;

class ConfigController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private readonly BackendConfigService $backendConfigService,
) {
parent::__construct($appName, $request);
}

/**
* Returns the current user_backends configuration.
* Requires admin (no @NoAdminRequired).
*/
#[NoCSRFRequired]
public function getBackends(): JSONResponse {
return new JSONResponse($this->backendConfigService->getBackends());
}

/**
* Replaces the user_backends configuration.
* Requires admin (no @NoAdminRequired).
*
* @param list<array{class: string, arguments: list<mixed>}> $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']);
}
}
25 changes: 25 additions & 0 deletions lib/Service/BackendConfigService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace OCA\UserExternal\Service;

use OCP\IConfig;

/**
* Reads and writes the user_backends system config key.
*/
class BackendConfigService {
public function __construct(private readonly IConfig $config) {
}

/** @return list<array{class: string, arguments: list<mixed>}> */
public function getBackends(): array {
return $this->config->getSystemValue('user_backends', []);
}

/** @param list<array{class: string, arguments: list<mixed>}> $backends */
public function setBackends(array $backends): void {
$this->config->setSystemValue('user_backends', $backends);
}
}
35 changes: 35 additions & 0 deletions lib/Settings/Admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace OCA\UserExternal\Settings;

use OCA\UserExternal\Service\BackendConfigService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Settings\ISettings;

class Admin implements ISettings {
public function __construct(
private readonly BackendConfigService $backendConfigService,
private readonly IInitialState $initialState,
) {
}

#[\Override]
public function getForm(): TemplateResponse {
$this->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;
}
}
37 changes: 37 additions & 0 deletions lib/Settings/AdminSection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace OCA\UserExternal\Settings;

use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;

class AdminSection implements IIconSection {
public function __construct(
private readonly IURLGenerator $url,
private readonly IL10N $l,
) {
}

#[\Override]
public function getIcon(): string {
return $this->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;
}
}
44 changes: 44 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions src/adminSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import AdminSettings from './views/AdminSettings.vue'

const app = createApp(AdminSettings)
app.mount('#user-external-admin-settings')
92 changes: 92 additions & 0 deletions src/backendSpecs.ts
Original file line number Diff line number Diff line change
@@ -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<BackendClass, BackendSpec> = {
'\\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<string, unknown>): 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<string, unknown> {
const fields = BACKEND_SPECS[cls].fields
return Object.fromEntries(fields.map((f, i) => [f.key, args[i] ?? f.default ?? '']))
}
Loading
Loading