mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
9e216f5ae4
Once a store was connected for a trip, its address and login could only be entered again after a sync run had been refused, and the stored connection could not be removed at all: the server's DELETE route had no way in from the app. A binding's settings now open the connection at any time, the folder picker leads back to it before a folder is chosen, and the connection form removes a stored connection after a confirmation, on the desktop panel and the phone sheet alike. Removing it drops the trip's bindings through it; documents stay on both sides. Closes #2842
215 lines
8.0 KiB
TypeScript
215 lines
8.0 KiB
TypeScript
import { useId, useState } from 'react'
|
|
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
|
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
|
import ConfirmDialog from '../../shared/ConfirmDialog'
|
|
import { Tooltip } from '../../shared/Tooltip'
|
|
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
|
import { useTranslation } from '../../../i18n/TranslationContext'
|
|
import { useConnectForm } from './useConnectForm'
|
|
import { Badge } from './DocSyncBits'
|
|
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/documentProviderIconMap'
|
|
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
|
|
|
/**
|
|
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
|
*
|
|
* Entering a URL and a token is a task with a beginning and an end, and doing it
|
|
* inside the list meant the list jumped around while someone typed. The dialog
|
|
* also gives the probe result somewhere to live that is not a line of text
|
|
* squeezed between two fields.
|
|
*/
|
|
export default function DocSyncConnectModal({
|
|
provider,
|
|
sync,
|
|
onClose,
|
|
onConnected,
|
|
onRemoved,
|
|
}: {
|
|
provider: DocSyncProvider
|
|
sync: ReturnType<typeof useDocSync>
|
|
onClose: () => void
|
|
onConnected: (providerId: string) => void
|
|
/** After the stored connection was removed from here; left out, the form offers no removal. */
|
|
onRemoved?: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const existing = sync.connectionFor(provider.id)
|
|
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
|
|
|
const form = useConnectForm(provider, existing, sync)
|
|
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
|
|
|
const remove = async () => {
|
|
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
|
|
}
|
|
|
|
const test = async () => setVerdict(await form.probe())
|
|
const labelId = useId()
|
|
|
|
const save = async () => {
|
|
if (await form.save()) onConnected(provider.id)
|
|
}
|
|
|
|
return (
|
|
<DialogShell
|
|
onClose={onClose}
|
|
labelledBy={labelId}
|
|
blocked={confirmRemove}
|
|
header={(
|
|
<DialogHeader
|
|
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
|
|
tint={NEUTRAL_TINT}
|
|
labelId={labelId}
|
|
onClose={onClose}
|
|
title={provider.name}
|
|
/>
|
|
)}
|
|
footer={(
|
|
<DialogFooter>
|
|
{existing && onRemoved && (
|
|
<DeleteButton
|
|
label={t('docsync.connection.remove')}
|
|
onClick={() => setConfirmRemove(true)}
|
|
disabled={sync.busy === 'connection'}
|
|
/>
|
|
)}
|
|
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
|
|
<FooterSpacer />
|
|
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
|
|
{t('docsync.test')}
|
|
</DialogButton>
|
|
<DialogButton variant="primary" onClick={() => void save()} disabled={!form.complete || sync.busy === 'save'}
|
|
icon={sync.busy === 'save' ? <Loader2 size={14} className="animate-spin" /> : undefined}>
|
|
{t('docsync.connect.submit')}
|
|
</DialogButton>
|
|
</DialogFooter>
|
|
)}
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
|
|
|
|
{form.fields.map(f => (
|
|
<Field
|
|
key={f.field_key}
|
|
label={t(form.labelKey(f))}
|
|
hint={form.hintKey(f) ? t(form.hintKey(f) as string) : undefined}
|
|
required={f.required}
|
|
>
|
|
<input
|
|
type={f.input_type === 'password' ? 'password' : 'text'}
|
|
value={form.valueOf(f.field_key)}
|
|
placeholder={form.placeholderOf(f)}
|
|
onChange={e => form.setValue(f.field_key, e.target.value)}
|
|
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2"
|
|
/>
|
|
</Field>
|
|
))}
|
|
|
|
<label className="flex items-start justify-between gap-4 rounded-lg border border-edge bg-surface-secondary px-3 py-2.5">
|
|
<span className="min-w-0">
|
|
<span className="block text-body text-content">{t('docsync.allowInsecureTls')}</span>
|
|
<span className="mt-0.5 block text-caption text-content-muted">
|
|
{t('docsync.connect.insecureHint')}
|
|
</span>
|
|
</span>
|
|
<span className="shrink-0 pt-0.5">
|
|
<ToggleSwitch on={form.insecureTls} onToggle={() => form.setInsecureTls(!form.insecureTls)} />
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirmRemove}
|
|
onClose={() => setConfirmRemove(false)}
|
|
onConfirm={() => void remove()}
|
|
title={t('docsync.connection.remove')}
|
|
message={t('docsync.connection.confirmRemove')}
|
|
confirmLabel={t('docsync.connection.remove')}
|
|
/>
|
|
</DialogShell>
|
|
)
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
hint,
|
|
required,
|
|
children,
|
|
}: {
|
|
label: string
|
|
hint?: string
|
|
required?: boolean
|
|
children: React.ReactNode
|
|
}) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1.5 flex items-center gap-2">
|
|
<span className="text-body font-medium text-content">{label}</span>
|
|
{!required && <Badge tone="neutral">{t('docsync.connect.optional')}</Badge>}
|
|
</span>
|
|
{children}
|
|
{hint && <span className="mt-1.5 block text-caption text-content-muted">{hint}</span>}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
/** The probe result, in the footer where it stays put while the form scrolls. */
|
|
function Verdict({
|
|
verdict,
|
|
busy,
|
|
failure,
|
|
}: {
|
|
verdict: DocSyncVerdict | null
|
|
busy: boolean
|
|
/** A call that failed outside the connection test (saving, most of all). */
|
|
failure: string | null
|
|
}) {
|
|
const { t } = useTranslation()
|
|
if (busy) {
|
|
return (
|
|
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
{t('docsync.connect.testing')}
|
|
</span>
|
|
)
|
|
}
|
|
// A save that the server refused left the dialog open with the button back to
|
|
// normal and nothing else said, which reads as nothing having happened. The
|
|
// test verdict wins the space when there is one: it is the more specific
|
|
// answer and the one the person just asked for.
|
|
if (!verdict && failure) {
|
|
return (
|
|
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
|
<AlertTriangle size={14} />
|
|
<span className="truncate">{t(`docsync.error.${failure}`)}</span>
|
|
</span>
|
|
)
|
|
}
|
|
if (!verdict) return <span />
|
|
if (verdict.connected) {
|
|
return (
|
|
<span className="flex min-w-0 items-center gap-2 text-caption text-success">
|
|
<Check size={14} strokeWidth={2.5} />
|
|
<span className="truncate">
|
|
{verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')}
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
|
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
|
// The footer shares its row with the buttons, and a refusal cut to "The
|
|
// credentials were re..." with no way to read the rest left nothing to go on
|
|
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
|
return (
|
|
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
|
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
|
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
|
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
}
|