input field overrides
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { BackendOption } from '../../types/rclone'
|
||||
|
||||
const FILEN_GATEWAY_URL = 'https://gateway.filen.io'
|
||||
|
||||
function toHex(buffer: ArrayBuffer): string {
|
||||
let hex = ''
|
||||
for (const byte of new Uint8Array(buffer)) {
|
||||
hex += byte.toString(16).padStart(2, '0')
|
||||
}
|
||||
return hex
|
||||
}
|
||||
|
||||
async function sha512Hex(input: string): Promise<string> {
|
||||
return toHex(await crypto.subtle.digest('SHA-512', new TextEncoder().encode(input)))
|
||||
}
|
||||
|
||||
// POST to the Filen gateway the same way @filen/sdk's APIClient does: anonymous bearer auth plus a
|
||||
// SHA-512 checksum of the exact body, then unwrap its { status, message, code, data } envelope.
|
||||
async function filenPost<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
|
||||
const body = JSON.stringify(data)
|
||||
|
||||
const response = await fetch(`${FILEN_GATEWAY_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer anonymous',
|
||||
Checksum: await sha512Hex(body),
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Filen request failed (${response.status})`)
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
status?: boolean
|
||||
code?: string
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
if (json.status === false) {
|
||||
throw new Error(json.message || json.code || 'Filen rejected the request')
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
// Reproduces `filen export-api-key` over raw HTTP: derive the login hash from the plaintext password
|
||||
// (mirroring @filen/sdk's generatePasswordAndMasterKeyBasedOnAuthVersion), log in, return the apiKey.
|
||||
export async function generateFilenApiKey({
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<string> {
|
||||
const authInfo = await filenPost<{ authVersion: number; salt: string }>('/v3/auth/info', {
|
||||
email,
|
||||
})
|
||||
|
||||
if (authInfo.authVersion !== 2) {
|
||||
// v1 is deprecated; v3 uses Argon2id, which WebCrypto can't derive.
|
||||
throw new Error(
|
||||
`Unsupported Filen auth version (${authInfo.authVersion}). Run \`filen export-api-key\` and paste the key instead.`
|
||||
)
|
||||
}
|
||||
|
||||
// PBKDF2-HMAC-SHA512, 200k iterations, 512-bit output. The second half of the derived key is
|
||||
// hashed once more with SHA-512 to form the login password (the first half is the master key).
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
)
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: new TextEncoder().encode(authInfo.salt),
|
||||
iterations: 200000,
|
||||
hash: 'SHA-512',
|
||||
},
|
||||
keyMaterial,
|
||||
512
|
||||
)
|
||||
const derivedKey = toHex(derivedBits)
|
||||
const derivedPassword = await sha512Hex(derivedKey.slice(derivedKey.length / 2))
|
||||
|
||||
const login = await filenPost<{ apiKey: string }>('/v3/login', {
|
||||
email,
|
||||
password: derivedPassword,
|
||||
twoFactorCode: 'XXXXXX',
|
||||
authVersion: authInfo.authVersion,
|
||||
})
|
||||
|
||||
if (!login.apiKey) {
|
||||
throw new Error('Filen did not return an API key')
|
||||
}
|
||||
|
||||
return login.apiKey
|
||||
}
|
||||
|
||||
// Controlled variant of the standard string RemoteField, with a "Generate" button that logs in with
|
||||
// the email/password already entered in the form and fills the field with the resulting API key.
|
||||
export default function FilenApiKeyField({
|
||||
option,
|
||||
config,
|
||||
setConfig,
|
||||
isDisabled = false,
|
||||
helpTitle,
|
||||
helpDescription,
|
||||
}: {
|
||||
option: BackendOption
|
||||
config: Record<string, any>
|
||||
setConfig: (config: Record<string, any>) => void
|
||||
isDisabled?: boolean
|
||||
helpTitle: string
|
||||
helpDescription: string
|
||||
}) {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isRevealed, setIsRevealed] = useState(false)
|
||||
|
||||
const email = (config?.email ?? '').trim()
|
||||
const password = config?.password ?? ''
|
||||
const canGenerate = Boolean(email && password)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const apiKey = await generateFilenApiKey({ email, password })
|
||||
setConfig((prev: Record<string, any>) => ({ ...prev, [option.Name]: apiKey }))
|
||||
} catch (e) {
|
||||
console.error('[FilenApiKeyField] failed to generate API key', e)
|
||||
await message(e instanceof Error ? e.message : 'Failed to generate API key', {
|
||||
title: 'Could not generate API key',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={option.Name}
|
||||
id={`field-${option.Name}`}
|
||||
name={option.Name}
|
||||
label={option.Name}
|
||||
labelPlacement="outside"
|
||||
placeholder={helpTitle}
|
||||
type={option.IsPassword && !isRevealed ? 'password' : 'text'}
|
||||
classNames={{ 'inputWrapper': 'pr-0' }}
|
||||
value={config?.[option.Name] ?? option.DefaultStr ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setConfig((prev: Record<string, any>) => ({
|
||||
...prev,
|
||||
[option.Name]: value,
|
||||
}))
|
||||
}}
|
||||
endContent={
|
||||
<div className="flex items-center h-full gap-1">
|
||||
{option.IsPassword && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
className="px-1 text-foreground-400 outline-none focus:outline-none"
|
||||
onClick={() => setIsRevealed((prev) => !prev)}
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOffIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<EyeIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-full gap-1 rounded-l-none"
|
||||
color="primary"
|
||||
isLoading={isGenerating}
|
||||
isDisabled={isDisabled || !canGenerate}
|
||||
onPress={handleGenerate}
|
||||
>
|
||||
GENERATE
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
isRequired={option.Required}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
description={
|
||||
canGenerate
|
||||
? helpDescription
|
||||
: 'Enter your Filen email and password above, then press Generate.'
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { FolderOpenIcon } from 'lucide-react'
|
||||
import type { BackendOption } from '../../types/rclone'
|
||||
|
||||
// Standard string RemoteField for options that hold a local filesystem path (rclone has no explicit
|
||||
// "path" flag, so callers opt fields in by name). Adds a button that opens the native file/folder
|
||||
// picker and fills the field with the chosen path; the field stays freely editable.
|
||||
export default function PathPickerField({
|
||||
option,
|
||||
config,
|
||||
setConfig,
|
||||
isDisabled = false,
|
||||
helpTitle,
|
||||
helpDescription,
|
||||
directory = false,
|
||||
}: {
|
||||
option: BackendOption
|
||||
config: Record<string, any>
|
||||
setConfig: (config: Record<string, any>) => void
|
||||
isDisabled?: boolean
|
||||
helpTitle: string
|
||||
helpDescription: string
|
||||
directory?: boolean
|
||||
}) {
|
||||
const handleBrowse = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory,
|
||||
multiple: false,
|
||||
title: directory ? 'Select folder' : 'Select file',
|
||||
})
|
||||
|
||||
if (typeof selected !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig((prev: Record<string, any>) => ({ ...prev, [option.Name]: selected }))
|
||||
} catch (e) {
|
||||
console.error('[PathPickerField] selection failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={option.Name}
|
||||
id={`field-${option.Name}`}
|
||||
name={option.Name}
|
||||
label={option.Name}
|
||||
labelPlacement="outside"
|
||||
placeholder={helpTitle}
|
||||
type="text"
|
||||
classNames={{ 'inputWrapper': 'pr-0' }}
|
||||
value={config?.[option.Name] ?? option.DefaultStr ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setConfig((prev: Record<string, any>) => ({
|
||||
...prev,
|
||||
[option.Name]: value,
|
||||
}))
|
||||
}}
|
||||
endContent={
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
size="sm"
|
||||
className="h-full rounded-l-none"
|
||||
aria-label={directory ? 'Browse for folder' : 'Browse for file'}
|
||||
isDisabled={isDisabled}
|
||||
onPress={handleBrowse}
|
||||
>
|
||||
<FolderOpenIcon className="size-4 shrink-0" />
|
||||
</Button>
|
||||
}
|
||||
isRequired={option.Required}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
description={helpDescription}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,29 @@
|
||||
import { Autocomplete, AutocompleteItem, Button, Checkbox, Input } from '@heroui/react'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ExternalLinkIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { ExternalLinkIcon, EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { OWN_OAUTH_TYPES } from '../../lib/rclone/overrides'
|
||||
import type { BackendOption } from '../../types/rclone'
|
||||
import FilenApiKeyField from './FilenApiKeyField'
|
||||
import PathPickerField from './PathPickerField'
|
||||
|
||||
// rclone has no "is a local path" flag, so opt fields in by name. Value = whether it's a directory.
|
||||
const PATH_PICKER_FIELDS: Record<string, { directory: boolean }> = {
|
||||
service_account_file: { directory: false }, // drive, google cloud storage
|
||||
box_config_file: { directory: false }, // box
|
||||
client_certificate_path: { directory: false }, // azureblob, azurefiles
|
||||
service_principal_file: { directory: false }, // azureblob, azurefiles
|
||||
config_file: { directory: false }, // oracleobjectstorage
|
||||
sse_customer_key_file: { directory: false }, // oracleobjectstorage
|
||||
shared_credentials_file: { directory: false }, // s3
|
||||
key_file: { directory: false }, // sftp
|
||||
known_hosts_file: { directory: false }, // sftp
|
||||
pubkey_file: { directory: false }, // sftp
|
||||
kerberos_ccache: { directory: false }, // smb
|
||||
chunk_path: { directory: true }, // cache
|
||||
db_path: { directory: true }, // cache
|
||||
tmp_upload_path: { directory: true }, // cache
|
||||
}
|
||||
|
||||
export default function RemoteField({
|
||||
option,
|
||||
@@ -32,6 +52,9 @@ export default function RemoteField({
|
||||
const helpDetails = useMemo(() => option.Help.split('\n').slice(1), [option.Help])
|
||||
const helpDescription = useMemo(() => helpDetails.join('\n'), [helpDetails])
|
||||
|
||||
// Password fields (rclone `IsPassword`) render obscured; this toggles a plaintext reveal.
|
||||
const [isRevealed, setIsRevealed] = useState(false)
|
||||
|
||||
// console.log(
|
||||
// '[RemoteField] option',
|
||||
// option.Name,
|
||||
@@ -67,6 +90,37 @@ export default function RemoteField({
|
||||
}
|
||||
|
||||
if (option.Type === 'string') {
|
||||
// Filen's api_key can be generated from the account's email + password (mirrors the
|
||||
// `filen export-api-key` CLI command), so render it with an inline "Generate" button.
|
||||
if (config?.type === 'filen' && option.Name === 'api_key') {
|
||||
return (
|
||||
<FilenApiKeyField
|
||||
option={option}
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
isDisabled={isDisabled}
|
||||
helpTitle={helpTitle}
|
||||
helpDescription={helpDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Local-path options (service account files, certs, cache dirs, …) get a native picker.
|
||||
const pathPicker = PATH_PICKER_FIELDS[option.Name]
|
||||
if (pathPicker) {
|
||||
return (
|
||||
<PathPickerField
|
||||
option={option}
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
isDisabled={isDisabled}
|
||||
helpTitle={helpTitle}
|
||||
helpDescription={helpDescription}
|
||||
directory={pathPicker.directory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldUseAutocomplete =
|
||||
!(config?.provider === 'Other' && option.Name === 'endpoint') &&
|
||||
option.Examples &&
|
||||
@@ -132,6 +186,38 @@ export default function RemoteField({
|
||||
OWN_OAUTH_TYPES.includes(config?.type) &&
|
||||
(option.Name === 'client_id' || option.Name === 'client_secret')
|
||||
|
||||
const inputType = option.IsPassword && !isRevealed ? 'password' : 'text'
|
||||
|
||||
// GUIDE button takes priority over the reveal toggle when both could apply.
|
||||
const endContent = requiresOwnCredentials ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-full gap-1 rounded-l-none"
|
||||
color="warning"
|
||||
endContent={<ExternalLinkIcon className="mb-0.5 size-4 shrink-0" />}
|
||||
onPress={() => {
|
||||
openUrl(
|
||||
`https://rclone.org/${config?.type === 'google photos' ? 'googlephotos' : 'drive'}/#making-your-own-client-id`
|
||||
)
|
||||
}}
|
||||
>
|
||||
GUIDE
|
||||
</Button>
|
||||
) : option.IsPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
className="text-foreground-400 outline-none focus:outline-none"
|
||||
onClick={() => setIsRevealed((prev) => !prev)}
|
||||
>
|
||||
{isRevealed ? (
|
||||
<EyeOffIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<EyeIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={option.Name}
|
||||
@@ -140,7 +226,7 @@ export default function RemoteField({
|
||||
label={option.Name}
|
||||
labelPlacement="outside"
|
||||
placeholder={helpTitle}
|
||||
type={option.IsPassword ? 'password' : 'text'}
|
||||
type={inputType}
|
||||
classNames={
|
||||
requiresOwnCredentials
|
||||
? {
|
||||
@@ -155,23 +241,7 @@ export default function RemoteField({
|
||||
[option.Name]: value,
|
||||
}))
|
||||
}}
|
||||
endContent={
|
||||
requiresOwnCredentials && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-full gap-1 rounded-l-none"
|
||||
color="warning"
|
||||
endContent={<ExternalLinkIcon className="mb-0.5 size-4 shrink-0" />}
|
||||
onPress={() => {
|
||||
openUrl(
|
||||
`https://rclone.org/${config?.type === 'google photos' ? 'googlephotos' : 'drive'}/#making-your-own-client-id`
|
||||
)
|
||||
}}
|
||||
>
|
||||
GUIDE
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
endContent={endContent}
|
||||
isRequired={option.Required || requiresOwnCredentials}
|
||||
defaultValue={initialFieldValue}
|
||||
autoComplete="off"
|
||||
|
||||
Reference in New Issue
Block a user