input field overrides

This commit is contained in:
FTCHD
2026-08-16 00:04:46 +03:00
parent f448c89e1c
commit 34376773c5
3 changed files with 381 additions and 20 deletions
+209
View File
@@ -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}
/>
)
}
+82
View File
@@ -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}
/>
)
}
+90 -20
View File
@@ -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"