diff --git a/src/schemas/registry.test.ts b/src/schemas/registry.test.ts index 720fe94..ab75f61 100644 --- a/src/schemas/registry.test.ts +++ b/src/schemas/registry.test.ts @@ -1,23 +1,81 @@ import { describe, it, expect } from 'vitest'; -import { BACKEND_REGISTRY, BackendType } from './registry'; +import { BACKEND_REGISTRY, BackendType, BackendCategory } from './registry'; -const EXPECTED_BACKENDS: BackendType[] = ['azureblob', 's3', 's3-compatible', 'onedrive', 'sftp', 'gcs', 'b2']; +// 7 cloud-object-storage + 5 cloud-drives + 6 protocol-based = 18 total +const ALL_BACKENDS: BackendType[] = [ + 'azureblob', 's3', 's3-compatible', 'gcs', 'b2', 'azure-files', 'swift', + 'onedrive', 'gdrive', 'dropbox', 'box', 'pcloud', + 'sftp', 'ftp', 'webdav', 'smb', 'http', 'seafile', +]; + +// Keep the original 7 for backwards compatibility checks +const ORIGINAL_7_BACKENDS: BackendType[] = ['azureblob', 's3', 's3-compatible', 'onedrive', 'sftp', 'gcs', 'b2']; + +const VALID_CATEGORIES: BackendCategory[] = ['cloud-object-storage', 'cloud-drives', 'protocol-based']; describe('Backend Schema Registry', () => { - it('exports all three required backend types', () => { - for (const backend of EXPECTED_BACKENDS) { + it('has exactly 18 backend entries', () => { + const keys = Object.keys(BACKEND_REGISTRY) as BackendType[]; + expect(keys).toHaveLength(18); + }); + + it('exports all 17 required backend types', () => { + for (const backend of ALL_BACKENDS) { expect(BACKEND_REGISTRY[backend]).toBeDefined(); } }); + it('exports all original 7 backend types (regression)', () => { + for (const backend of ORIGINAL_7_BACKENDS) { + expect(BACKEND_REGISTRY[backend]).toBeDefined(); + } + }); + + it('each backend has displayName, description, category, and at least one field', () => { + for (const backend of ALL_BACKENDS) { + const entry = BACKEND_REGISTRY[backend]; + expect(entry.displayName, `${backend}.displayName`).toBeTruthy(); + expect(entry.description, `${backend}.description`).toBeTruthy(); + expect(entry.category, `${backend}.category`).toBeTruthy(); + expect(entry.fields.length, `${backend}.fields.length`).toBeGreaterThan(0); + } + }); + + it('each backend has a valid category value', () => { + for (const backend of ALL_BACKENDS) { + expect(VALID_CATEGORIES).toContain(BACKEND_REGISTRY[backend].category); + } + }); + + it('cloud-object-storage backends are correctly categorized', () => { + const expected: BackendType[] = ['azureblob', 's3', 's3-compatible', 'gcs', 'b2', 'azure-files', 'swift']; + for (const b of expected) { + expect(BACKEND_REGISTRY[b].category).toBe('cloud-object-storage'); + } + }); + + it('cloud-drives backends are correctly categorized', () => { + const expected: BackendType[] = ['onedrive', 'gdrive', 'dropbox', 'box', 'pcloud']; + for (const b of expected) { + expect(BACKEND_REGISTRY[b].category).toBe('cloud-drives'); + } + }); + + it('protocol-based backends are correctly categorized', () => { + const expected: BackendType[] = ['sftp', 'ftp', 'webdav', 'smb', 'http', 'seafile']; + for (const b of expected) { + expect(BACKEND_REGISTRY[b].category).toBe('protocol-based'); + } + }); + it('each backend has at least one field definition', () => { - for (const backend of EXPECTED_BACKENDS) { + for (const backend of ALL_BACKENDS) { expect(BACKEND_REGISTRY[backend].fields.length).toBeGreaterThan(0); } }); it('each FieldDef has a non-empty key (snake_case, no camelCase)', () => { - for (const backend of EXPECTED_BACKENDS) { + for (const backend of ALL_BACKENDS) { for (const field of BACKEND_REGISTRY[backend].fields) { expect(field.key).toBeTruthy(); // Reject camelCase — rclone keys are snake_case or lowercase @@ -27,13 +85,20 @@ describe('Backend Schema Registry', () => { }); it('each FieldDef has a non-empty label', () => { - for (const backend of EXPECTED_BACKENDS) { + for (const backend of ALL_BACKENDS) { for (const field of BACKEND_REGISTRY[backend].fields) { expect(field.label).toBeTruthy(); } } }); + it('each backend entry has displayName and description metadata', () => { + for (const backend of ALL_BACKENDS) { + expect(BACKEND_REGISTRY[backend].displayName).toBeTruthy(); + expect(BACKEND_REGISTRY[backend].description).toBeTruthy(); + } + }); + it('Azure Blob has account field (required)', () => { const accountField = BACKEND_REGISTRY.azureblob.fields.find(f => f.key === 'account'); expect(accountField).toBeDefined(); @@ -53,13 +118,6 @@ describe('Backend Schema Registry', () => { expect(endpointField!.required).toBe(true); }); - it('each backend entry has displayName and description metadata', () => { - for (const backend of EXPECTED_BACKENDS) { - expect(BACKEND_REGISTRY[backend].displayName).toBeTruthy(); - expect(BACKEND_REGISTRY[backend].description).toBeTruthy(); - } - }); - describe('OneDrive backend', () => { it('has token field (required)', () => { const f = BACKEND_REGISTRY.onedrive.fields.find(f => f.key === 'token'); @@ -130,4 +188,170 @@ describe('Backend Schema Registry', () => { expect(f!.inputType).toBe('password'); }); }); + + describe('Azure Files backend (new)', () => { + it('has account field (required)', () => { + const f = BACKEND_REGISTRY['azure-files'].fields.find(f => f.key === 'account'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has key field (required)', () => { + const f = BACKEND_REGISTRY['azure-files'].fields.find(f => f.key === 'key'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + expect(f!.inputType).toBe('password'); + }); + }); + + describe('Swift backend (new)', () => { + it('has user, key, auth, and tenant fields (all required)', () => { + const fields = BACKEND_REGISTRY.swift.fields; + const keys = fields.map(f => f.key); + expect(keys).toContain('user'); + expect(keys).toContain('key'); + expect(keys).toContain('auth'); + expect(keys).toContain('tenant'); + }); + it('has optional region field', () => { + const f = BACKEND_REGISTRY.swift.fields.find(f => f.key === 'region'); + expect(f).toBeDefined(); + expect(f!.required).toBe(false); + }); + }); + + describe('Google Drive backend (new)', () => { + it('has token field (optional — service account path also supported)', () => { + const f = BACKEND_REGISTRY.gdrive.fields.find(f => f.key === 'token'); + expect(f).toBeDefined(); + expect(f!.inputType).toBe('password'); + }); + it('has service_account_credentials field (optional)', () => { + const f = BACKEND_REGISTRY.gdrive.fields.find(f => f.key === 'service_account_credentials'); + expect(f).toBeDefined(); + expect(f!.required).toBe(false); + }); + it('has root_folder_id field (optional)', () => { + const f = BACKEND_REGISTRY.gdrive.fields.find(f => f.key === 'root_folder_id'); + expect(f).toBeDefined(); + expect(f!.required).toBe(false); + }); + }); + + describe('Dropbox backend (new)', () => { + it('has token field (required)', () => { + const f = BACKEND_REGISTRY.dropbox.fields.find(f => f.key === 'token'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + expect(f!.inputType).toBe('password'); + }); + }); + + describe('Box backend (new)', () => { + it('has token field (required)', () => { + const f = BACKEND_REGISTRY.box.fields.find(f => f.key === 'token'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has box_sub_type select field (optional)', () => { + const f = BACKEND_REGISTRY.box.fields.find(f => f.key === 'box_sub_type'); + expect(f).toBeDefined(); + expect(f!.inputType).toBe('select'); + expect(f!.required).toBe(false); + }); + }); + + describe('pCloud backend (new)', () => { + it('has token field (required)', () => { + const f = BACKEND_REGISTRY.pcloud.fields.find(f => f.key === 'token'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has hostname select field (optional, US/EU options)', () => { + const f = BACKEND_REGISTRY.pcloud.fields.find(f => f.key === 'hostname'); + expect(f).toBeDefined(); + expect(f!.inputType).toBe('select'); + const values = f!.options!.map(o => o.value); + expect(values).toContain('api.pcloud.com'); + expect(values).toContain('eapi.pcloud.com'); + }); + }); + + describe('FTP backend (new)', () => { + it('has host field (required)', () => { + const f = BACKEND_REGISTRY.ftp.fields.find(f => f.key === 'host'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has user, pass, port fields (all optional)', () => { + const fields = BACKEND_REGISTRY.ftp.fields; + const optional = ['user', 'pass', 'port']; + for (const key of optional) { + const f = fields.find(f => f.key === key); + expect(f, `ftp.${key}`).toBeDefined(); + expect(f!.required, `ftp.${key}.required`).toBe(false); + } + }); + it('has explicit_tls select field (optional)', () => { + const f = BACKEND_REGISTRY.ftp.fields.find(f => f.key === 'explicit_tls'); + expect(f).toBeDefined(); + expect(f!.inputType).toBe('select'); + }); + }); + + describe('WebDAV backend (new)', () => { + it('has url, user, pass fields (required)', () => { + const fields = BACKEND_REGISTRY.webdav.fields; + for (const key of ['url', 'user', 'pass']) { + const f = fields.find(f => f.key === key); + expect(f, `webdav.${key}`).toBeDefined(); + expect(f!.required, `webdav.${key}.required`).toBe(true); + } + }); + it('has vendor select field (optional) with nextcloud/sharepoint options', () => { + const f = BACKEND_REGISTRY.webdav.fields.find(f => f.key === 'vendor'); + expect(f).toBeDefined(); + expect(f!.inputType).toBe('select'); + const values = f!.options!.map(o => o.value); + expect(values).toContain('nextcloud'); + expect(values).toContain('sharepoint'); + }); + }); + + describe('SMB backend (new)', () => { + it('has host field (required)', () => { + const f = BACKEND_REGISTRY.smb.fields.find(f => f.key === 'host'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has user field (required)', () => { + const f = BACKEND_REGISTRY.smb.fields.find(f => f.key === 'user'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + it('has pass, domain, port fields (optional)', () => { + for (const key of ['pass', 'domain', 'port']) { + const f = BACKEND_REGISTRY.smb.fields.find(f => f.key === key); + expect(f, `smb.${key}`).toBeDefined(); + expect(f!.required, `smb.${key}.required`).toBe(false); + } + }); + }); + + describe('HTTP backend (new)', () => { + it('has url field (required)', () => { + const f = BACKEND_REGISTRY.http.fields.find(f => f.key === 'url'); + expect(f).toBeDefined(); + expect(f!.required).toBe(true); + }); + }); + + describe('Seafile backend (new)', () => { + it('has url, user, pass fields (all required)', () => { + for (const key of ['url', 'user', 'pass']) { + const f = BACKEND_REGISTRY.seafile.fields.find(f => f.key === key); + expect(f, `seafile.${key}`).toBeDefined(); + expect(f!.required, `seafile.${key}.required`).toBe(true); + } + }); + }); }); diff --git a/src/schemas/registry.ts b/src/schemas/registry.ts index 5990627..50a7dc8 100644 --- a/src/schemas/registry.ts +++ b/src/schemas/registry.ts @@ -3,8 +3,11 @@ // IMPORTANT: FieldDef.key values MUST match rclone config key names exactly. // These keys are used by Phase 2 generators to build rclone.conf INI content. // Verify against https://rclone.org/azureblob/ and https://rclone.org/s3/ before Phase 2. +// +// BackendType is DERIVED from keyof typeof BACKEND_REGISTRY — do NOT add a manual union. +// BackendCategory is the grouping used for UI categorization and search. -export type BackendType = 'azureblob' | 's3' | 's3-compatible' | 'onedrive' | 'sftp' | 'gcs' | 'b2'; +export type BackendCategory = 'cloud-object-storage' | 'cloud-drives' | 'protocol-based'; export interface FieldDef { key: string; // MUST match rclone config key exactly (snake_case) @@ -18,15 +21,23 @@ export interface FieldDef { tooltipText?: string; } +export interface BackendMeta { + displayName: string; + description: string; + category: BackendCategory; + fields: FieldDef[]; +} + export const BACKEND_REGISTRY = { azureblob: { displayName: 'Azure Blob Storage', description: 'Microsoft Azure cloud storage', + category: 'cloud-object-storage' as const, fields: [ { key: 'account', label: 'Storage Account Name', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: 'mystorageaccount', helpText: 'The storage account name (not the full URL)', @@ -38,7 +49,7 @@ export const BACKEND_REGISTRY = { { key: 'key', label: 'Access Key', - inputType: 'password', + inputType: 'password' as const, required: false, helpText: 'Base64-encoded storage account key. Provide either this or a SAS URL, not both.', tooltipText: 'The full storage account key grants unrestricted read/write access to all containers in the account. Keep this secret. If you only need limited access, use a SAS URL instead.', @@ -46,7 +57,7 @@ export const BACKEND_REGISTRY = { { key: 'sas_url', label: 'SAS URL', - inputType: 'password', + inputType: 'password' as const, required: false, placeholder: 'https://mystorageaccount.blob.core.windows.net/?sv=...', helpText: 'Full SAS URL including account and container. Provide either this or an access key, not both.', @@ -57,18 +68,19 @@ export const BACKEND_REGISTRY = { s3: { displayName: 'Amazon S3', description: 'AWS Simple Storage Service', + category: 'cloud-object-storage' as const, fields: [ { key: 'provider', label: 'Provider', - inputType: 'select', + inputType: 'select' as const, required: true, options: [{ value: 'AWS', label: 'Amazon S3' }], }, { key: 'access_key_id', label: 'Access Key ID', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: 'AKIAIOSFODNN7EXAMPLE', helpText: 'The access key ID from your IAM credentials (starts with AKIA for long-term keys).', @@ -77,7 +89,7 @@ export const BACKEND_REGISTRY = { { key: 'secret_access_key', label: 'Secret Access Key', - inputType: 'password', + inputType: 'password' as const, required: true, helpText: 'The secret access key paired with your Access Key ID. Only shown once at creation time.', tooltipText: 'This value is shown only once when you create an access key in IAM. If you did not save it, delete the existing key and create a new one. Never share or commit this value — treat it as a password.', @@ -85,7 +97,7 @@ export const BACKEND_REGISTRY = { { key: 'region', label: 'Region', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: 'us-east-1', tooltipText: 'The AWS region where your S3 bucket is located. Find this in the S3 console next to your bucket name (e.g. us-east-1, eu-west-2, ap-southeast-1).', @@ -99,11 +111,12 @@ export const BACKEND_REGISTRY = { 's3-compatible': { displayName: 'S3-Compatible', description: 'Wasabi, MinIO, Cloudflare R2, and others', + category: 'cloud-object-storage' as const, fields: [ { key: 'provider', label: 'Provider', - inputType: 'select', + inputType: 'select' as const, required: true, options: [{ value: 'Other', label: 'S3-Compatible' }], helpText: 'Covers Wasabi, MinIO, Cloudflare R2, and any S3-compatible storage', @@ -111,7 +124,7 @@ export const BACKEND_REGISTRY = { { key: 'access_key_id', label: 'Access Key ID', - inputType: 'text', + inputType: 'text' as const, required: true, helpText: 'The access key ID from your storage provider\'s dashboard.', tooltipText: 'Where to find this varies by provider: Wasabi — Account > Access Keys; Cloudflare R2 — R2 > Manage R2 API Tokens; MinIO — admin console > Identity > Users or Service Accounts.', @@ -119,7 +132,7 @@ export const BACKEND_REGISTRY = { { key: 'secret_access_key', label: 'Secret Access Key', - inputType: 'password', + inputType: 'password' as const, required: true, helpText: 'The secret key paired with your Access Key ID.', tooltipText: 'This value is typically shown only once when you create the key. Store it securely — if lost, you will need to generate a new key pair.', @@ -127,7 +140,7 @@ export const BACKEND_REGISTRY = { { key: 'endpoint', label: 'Endpoint URL', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: 'https://s3.wasabisys.com', helpText: 'The S3-compatible endpoint URL for your storage provider', @@ -136,57 +149,22 @@ export const BACKEND_REGISTRY = { { key: 'region', label: 'Region', - inputType: 'text', + inputType: 'text' as const, required: false, placeholder: 'us-east-1', helpText: 'Optional for most S3-compatible providers', }, ], }, - onedrive: { - displayName: 'OneDrive', - description: 'Microsoft OneDrive (paste pre-obtained rclone token)', - fields: [ - { - key: 'token', - label: 'OAuth Token (JSON)', - inputType: 'password', - required: true, - placeholder: '{"access_token":"...","token_type":"Bearer","refresh_token":"...","expiry":"..."}', - helpText: 'Paste the JSON token from: rclone authorize "onedrive"', - tooltipText: 'This is the JSON token obtained by running `rclone authorize "onedrive"` on a machine with a browser. The command opens a browser window, you authenticate, and rclone prints a JSON token — paste that entire JSON blob here.', - }, - { - key: 'drive_id', - label: 'Drive ID', - inputType: 'text', - required: true, - placeholder: 'b!...', - helpText: 'The drive ID from your OneDrive. Found in the rclone authorize output.', - tooltipText: 'The drive ID appears in the JSON output of `rclone authorize "onedrive"` — look for the "drive_id" field in the response. It typically starts with "b!" for business/SharePoint drives. Personal OneDrive drive IDs look like a UUID.', - }, - { - key: 'drive_type', - label: 'Drive Type', - inputType: 'select', - required: true, - options: [ - { value: 'personal', label: 'Personal' }, - { value: 'business', label: 'Business' }, - { value: 'documentLibrary', label: 'SharePoint Document Library' }, - ], - helpText: 'Personal for consumer OneDrive, Business for Microsoft 365', - }, - ], - }, gcs: { displayName: 'Google Cloud Storage', description: 'Google Cloud Storage', + category: 'cloud-object-storage' as const, fields: [ { key: 'project_number', label: 'Project Number', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: '123456789', helpText: 'Your GCP project number (not project ID)', @@ -199,7 +177,7 @@ export const BACKEND_REGISTRY = { { key: 'service_account_credentials', label: 'Service Account JSON', - inputType: 'password', + inputType: 'password' as const, required: true, placeholder: '{"type":"service_account","project_id":"..."}', helpText: 'Paste the full content of your service account JSON key file', @@ -207,49 +185,15 @@ export const BACKEND_REGISTRY = { }, ], }, - sftp: { - displayName: 'SFTP', - description: 'SSH File Transfer Protocol', - fields: [ - { - key: 'host', - label: 'Host', - inputType: 'text', - required: true, - placeholder: 'sftp.example.com', - helpText: 'The SSH server hostname or IP address', - }, - { - key: 'user', - label: 'Username', - inputType: 'text', - required: true, - placeholder: 'admin', - }, - { - key: 'pass', - label: 'Password', - inputType: 'password', - required: false, - helpText: 'SFTP password. Note: rclone may require the password to be obscured using `rclone obscure `. If authentication fails, use the obscured value instead of the plain password.', - }, - { - key: 'key_pem', - label: 'Private Key (PEM)', - inputType: 'password', - required: false, - helpText: 'Paste your private key in PEM format (-----BEGIN ... PRIVATE KEY-----)', - }, - ], - }, b2: { displayName: 'Backblaze B2', description: 'Backblaze B2 Cloud Storage', + category: 'cloud-object-storage' as const, fields: [ { key: 'account', label: 'Application Key ID', - inputType: 'text', + inputType: 'text' as const, required: true, placeholder: 'your-application-key-id', helpText: 'The applicationKeyId — not the master account ID', @@ -258,11 +202,441 @@ export const BACKEND_REGISTRY = { { key: 'key', label: 'Application Key', - inputType: 'password', + inputType: 'password' as const, required: true, helpText: 'The application key (secret value from the B2 dashboard)', tooltipText: 'The application key value is shown only once when you create a new app key in B2 > App Keys. Copy it immediately — it cannot be retrieved later. If lost, delete the key and create a new one.', }, ], }, -} as Record; + 'azure-files': { + displayName: 'Azure Files', + description: 'Microsoft Azure Files (SMB/REST cloud file shares)', + category: 'cloud-object-storage' as const, + fields: [ + { + key: 'account', + label: 'Storage Account Name', + inputType: 'text' as const, + required: true, + placeholder: 'mystorageaccount', + helpText: 'The Azure storage account name', + validate: { + regex: /^[a-z0-9]{3,24}$/, + message: 'Must be 3–24 lowercase alphanumeric characters', + }, + }, + { + key: 'key', + label: 'Storage Account Key', + inputType: 'password' as const, + required: true, + helpText: 'The base64-encoded storage account key from the Azure portal', + tooltipText: 'Found in Azure Portal > your storage account > Security + Networking > Access keys. Use key1 or key2 — either works.', + }, + ], + }, + swift: { + displayName: 'OpenStack Swift', + description: 'OpenStack Swift object storage', + category: 'cloud-object-storage' as const, + fields: [ + { + key: 'user', + label: 'Username', + inputType: 'text' as const, + required: true, + helpText: 'The OpenStack username (or tenant:user for v2 auth)', + }, + { + key: 'key', + label: 'API Key / Password', + inputType: 'password' as const, + required: true, + helpText: 'The OpenStack API key or password for this user', + }, + { + key: 'auth', + label: 'Auth URL', + inputType: 'text' as const, + required: true, + placeholder: 'https://auth.example.com/v3', + helpText: 'The OpenStack identity (Keystone) auth endpoint URL', + tooltipText: 'This is the Keystone endpoint for your OpenStack provider (v2 or v3). Check your provider\'s dashboard or ask your administrator.', + }, + { + key: 'tenant', + label: 'Tenant / Project Name', + inputType: 'text' as const, + required: true, + helpText: 'The OpenStack tenant (v2) or project name (v3)', + }, + { + key: 'region', + label: 'Region', + inputType: 'text' as const, + required: false, + helpText: 'The OpenStack region (optional — leave blank if your provider has a single region)', + }, + ], + }, + onedrive: { + displayName: 'OneDrive', + description: 'Microsoft OneDrive (paste pre-obtained rclone token)', + category: 'cloud-drives' as const, + fields: [ + { + key: 'token', + label: 'OAuth Token (JSON)', + inputType: 'password' as const, + required: true, + placeholder: '{"access_token":"...","token_type":"Bearer","refresh_token":"...","expiry":"..."}', + helpText: 'Paste the JSON token from: rclone authorize "onedrive"', + tooltipText: 'This is the JSON token obtained by running `rclone authorize "onedrive"` on a machine with a browser. The command opens a browser window, you authenticate, and rclone prints a JSON token — paste that entire JSON blob here.', + }, + { + key: 'drive_id', + label: 'Drive ID', + inputType: 'text' as const, + required: true, + placeholder: 'b!...', + helpText: 'The drive ID from your OneDrive. Found in the rclone authorize output.', + tooltipText: 'The drive ID appears in the JSON output of `rclone authorize "onedrive"` — look for the "drive_id" field in the response. It typically starts with "b!" for business/SharePoint drives. Personal OneDrive drive IDs look like a UUID.', + }, + { + key: 'drive_type', + label: 'Drive Type', + inputType: 'select' as const, + required: true, + options: [ + { value: 'personal', label: 'Personal' }, + { value: 'business', label: 'Business' }, + { value: 'documentLibrary', label: 'SharePoint Document Library' }, + ], + helpText: 'Personal for consumer OneDrive, Business for Microsoft 365', + }, + ], + }, + gdrive: { + displayName: 'Google Drive', + description: 'Google Drive (paste pre-obtained rclone token or service account)', + category: 'cloud-drives' as const, + fields: [ + { + key: 'token', + label: 'OAuth Token (JSON)', + inputType: 'password' as const, + required: false, + helpText: 'Paste the JSON token from: rclone authorize "drive". Use this OR a service account JSON, not both.', + tooltipText: 'Run `rclone authorize "drive"` on a machine with a browser. A browser window opens, you sign in with Google, and rclone prints a JSON token — paste that entire JSON blob here.', + }, + { + key: 'service_account_credentials', + label: 'Service Account JSON', + inputType: 'password' as const, + required: false, + helpText: 'For unattended/server use: paste the full service account JSON key file content. Use this OR an OAuth token, not both.', + tooltipText: 'Create a service account at Google Cloud Console > IAM & Admin > Service Accounts, share the target Drive with that service account email, then download the JSON key and paste its contents here.', + }, + { + key: 'root_folder_id', + label: 'Root Folder ID', + inputType: 'text' as const, + required: false, + helpText: 'Optional: restrict rclone to a specific folder. Leave blank to access the full Drive.', + tooltipText: 'The folder ID is the last segment of the Google Drive folder URL: https://drive.google.com/drive/folders/{FOLDER_ID}', + }, + ], + }, + dropbox: { + displayName: 'Dropbox', + description: 'Dropbox (paste pre-obtained rclone token)', + category: 'cloud-drives' as const, + fields: [ + { + key: 'token', + label: 'OAuth Token (JSON)', + inputType: 'password' as const, + required: true, + helpText: 'Paste the JSON token from: rclone authorize "dropbox"', + tooltipText: 'Run `rclone authorize "dropbox"` on a machine with a browser. Sign in to Dropbox when the browser opens, then paste the resulting JSON token here.', + }, + ], + }, + box: { + displayName: 'Box', + description: 'Box cloud storage (paste pre-obtained rclone token)', + category: 'cloud-drives' as const, + fields: [ + { + key: 'token', + label: 'OAuth Token (JSON)', + inputType: 'password' as const, + required: true, + helpText: 'Paste the JSON token from: rclone authorize "box"', + tooltipText: 'Run `rclone authorize "box"` on a machine with a browser. Sign in to Box when the browser opens, then paste the resulting JSON token here.', + }, + { + key: 'box_sub_type', + label: 'Account Type', + inputType: 'select' as const, + required: false, + options: [ + { value: 'user', label: 'User account (default)' }, + { value: 'enterprise', label: 'Enterprise / Service Account' }, + ], + helpText: 'Use Enterprise for Box App Users or service accounts', + }, + ], + }, + pcloud: { + displayName: 'pCloud', + description: 'pCloud cloud storage (paste pre-obtained rclone token)', + category: 'cloud-drives' as const, + fields: [ + { + key: 'token', + label: 'OAuth Token (JSON)', + inputType: 'password' as const, + required: true, + helpText: 'Paste the JSON token from: rclone authorize "pcloud"', + tooltipText: 'Run `rclone authorize "pcloud"` on a machine with a browser. Sign in to pCloud when the browser opens, then paste the resulting JSON token here.', + }, + { + key: 'hostname', + label: 'Server Region', + inputType: 'select' as const, + required: false, + options: [ + { value: 'api.pcloud.com', label: 'US (default)' }, + { value: 'eapi.pcloud.com', label: 'EU' }, + ], + helpText: 'Select EU if your pCloud account is registered in Europe', + tooltipText: 'pCloud has separate server infrastructure for US and EU users. EU-registered accounts must use eapi.pcloud.com — using the wrong region will return authentication errors.', + }, + ], + }, + sftp: { + displayName: 'SFTP', + description: 'SSH File Transfer Protocol', + category: 'protocol-based' as const, + fields: [ + { + key: 'host', + label: 'Host', + inputType: 'text' as const, + required: true, + placeholder: 'sftp.example.com', + helpText: 'The SSH server hostname or IP address', + }, + { + key: 'user', + label: 'Username', + inputType: 'text' as const, + required: true, + placeholder: 'admin', + }, + { + key: 'pass', + label: 'Password', + inputType: 'password' as const, + required: false, + helpText: 'SFTP password. Note: rclone may require the password to be obscured using `rclone obscure `. If authentication fails, use the obscured value instead of the plain password.', + }, + { + key: 'key_pem', + label: 'Private Key (PEM)', + inputType: 'password' as const, + required: false, + helpText: 'Paste your private key in PEM format (-----BEGIN ... PRIVATE KEY-----)', + }, + ], + }, + ftp: { + displayName: 'FTP', + description: 'File Transfer Protocol (supports plain FTP and FTPS)', + category: 'protocol-based' as const, + fields: [ + { + key: 'host', + label: 'Host', + inputType: 'text' as const, + required: true, + placeholder: 'ftp.example.com', + helpText: 'The FTP server hostname or IP address', + }, + { + key: 'user', + label: 'Username', + inputType: 'text' as const, + required: false, + placeholder: 'anonymous', + helpText: 'Leave blank or enter "anonymous" for anonymous FTP access', + }, + { + key: 'pass', + label: 'Password', + inputType: 'password' as const, + required: false, + helpText: 'FTP password. Leave blank for anonymous access.', + }, + { + key: 'port', + label: 'Port', + inputType: 'text' as const, + required: false, + placeholder: '21', + helpText: 'FTP port (default: 21)', + }, + { + key: 'explicit_tls', + label: 'TLS Mode', + inputType: 'select' as const, + required: false, + options: [ + { value: '', label: 'Plain FTP (no encryption)' }, + { value: 'true', label: 'Explicit FTPS (STARTTLS)' }, + ], + helpText: 'Use Explicit FTPS for encrypted FTP connections (port 21 + STARTTLS)', + }, + ], + }, + webdav: { + displayName: 'WebDAV', + description: 'WebDAV (NextCloud, SharePoint, OwnCloud, and others)', + category: 'protocol-based' as const, + fields: [ + { + key: 'url', + label: 'WebDAV URL', + inputType: 'text' as const, + required: true, + placeholder: 'https://example.com/dav', + helpText: 'The full WebDAV endpoint URL', + tooltipText: 'NextCloud: https://yourserver/remote.php/dav/files/username/ | OwnCloud: https://yourserver/remote.php/webdav/ | SharePoint: https://company.sharepoint.com/sites/mysite/Documents', + }, + { + key: 'user', + label: 'Username', + inputType: 'text' as const, + required: true, + helpText: 'The WebDAV username', + }, + { + key: 'pass', + label: 'Password', + inputType: 'password' as const, + required: true, + helpText: 'The WebDAV password', + }, + { + key: 'vendor', + label: 'Vendor / Server Type', + inputType: 'select' as const, + required: false, + options: [ + { value: 'nextcloud', label: 'NextCloud' }, + { value: 'owncloud', label: 'OwnCloud' }, + { value: 'sharepoint', label: 'SharePoint (Microsoft 365 OAuth)' }, + { value: 'sharepoint-ntlm', label: 'SharePoint (NTLM / on-premises)' }, + { value: 'other', label: 'Other WebDAV server' }, + ], + helpText: 'Selecting the correct vendor enables server-specific optimizations', + }, + ], + }, + smb: { + displayName: 'SMB / Windows Share', + description: 'SMB / CIFS Windows file shares', + category: 'protocol-based' as const, + fields: [ + { + key: 'host', + label: 'Host', + inputType: 'text' as const, + required: true, + placeholder: 'fileserver.example.com', + helpText: 'The SMB server hostname or IP address', + }, + { + key: 'user', + label: 'Username', + inputType: 'text' as const, + required: true, + helpText: 'The SMB username (can include domain: DOMAIN\\user)', + }, + { + key: 'pass', + label: 'Password', + inputType: 'password' as const, + required: false, + helpText: 'The SMB password. Leave blank for guest/anonymous access.', + }, + { + key: 'domain', + label: 'Domain', + inputType: 'text' as const, + required: false, + placeholder: 'WORKGROUP', + helpText: 'Windows domain name (optional, defaults to WORKGROUP)', + }, + { + key: 'port', + label: 'Port', + inputType: 'text' as const, + required: false, + placeholder: '445', + helpText: 'SMB port (default: 445)', + }, + ], + }, + http: { + displayName: 'HTTP (read-only)', + description: 'Read-only access to HTTP/HTTPS file listings', + category: 'protocol-based' as const, + fields: [ + { + key: 'url', + label: 'URL', + inputType: 'text' as const, + required: true, + placeholder: 'https://example.com/path/', + helpText: 'The base URL of the HTTP file listing. Must expose directory listings.', + tooltipText: 'The HTTP backend is read-only and works with Apache/Nginx directory listings or any server that returns HTML with links. Ensure the URL ends with a trailing slash for directory access.', + }, + ], + }, + seafile: { + displayName: 'Seafile', + description: 'Seafile self-hosted cloud storage', + category: 'protocol-based' as const, + fields: [ + { + key: 'url', + label: 'Server URL', + inputType: 'text' as const, + required: true, + placeholder: 'https://cloud.seafile.com', + helpText: 'The Seafile server URL (without trailing slash)', + }, + { + key: 'user', + label: 'Email / Username', + inputType: 'text' as const, + required: true, + helpText: 'Your Seafile account email address', + }, + { + key: 'pass', + label: 'Password', + inputType: 'password' as const, + required: true, + helpText: 'Your Seafile account password', + }, + ], + }, +} as const; + +// BackendType is derived from registry keys — no manual union needed. +// Adding a backend to BACKEND_REGISTRY automatically extends BackendType. +export type BackendType = keyof typeof BACKEND_REGISTRY;