feat(webdav): Anleitung zum Einhaengen neben den App-Passwoertern

Ein Info-Knopf unter Einstellungen -> Access oeffnet drei Wege:
Windows (Netzlaufwerk), Linux ueber davfs2 samt fstab und secrets, und
Linux ueber den Dateimanager. Dazu WebDAV-Adresse, Benutzername und
Cloud-Adresse zum Kopieren.

Die WebDAV-Adresse stand bisher nirgends in der Oberflaeche - sie ist
zwar ableitbar, aber nur wenn man weiss, dass es sie gibt. Sie entsteht
aus window.location statt aus PUBLIC_URL: Was im Browser traegt, traegt
auch im Dateimanager.

Mitgenommen sind die drei Stellen, an denen es sonst scheitert: Windows
bricht ohne HTTPS ab und laedt nichts ueber 50 MB, die Konsole braucht
davfs2, und Dateimanager wollen davs:// statt https://.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 14:49:57 +02:00
1 parent 9a2f90c89b
commit 0ef7bd67ab
3 files changed
+364 -1

No files matched your search

+20
View File
@@ -1336,6 +1336,26 @@ Kreise plus Rechteck dazwischen, keine Freihandkurve) und in RGBA gerendert.
- [ ] Auf dem Startbildschirm frühestens nach Neu-Installieren (Chrome hält
eine eigene Kopie)
## Anleitung „Connect as a drive" (24.08.2026)
Neuer Info-Knopf unter Einstellungen → Access, neben den App-Passwörtern.
Die Adressen entstehen aus `window.location`, nicht aus `PUBLIC_URL` – was im
Browser trägt, trägt auch im Dateimanager.
Geprüft auf 1280×900 und 412×915:
- Alle drei Reiter (Windows, Linux · Konsole, Linux · Dateimanager) öffnen.
- Über HTTP erscheint der rote Hinweis und der Dateimanager-Reiter bietet
`dav://` an; über HTTPS `davs://` und kein Hinweis.
- Die Kopierknöpfe stehen an WebDAV-Adresse, Benutzername, Cloud-Adresse und
an jedem Befehlsblock.
- [ ] Auf der produktiven Instanz gegenprüfen, dass dort `https://…/dav/`
steht und der HTTPS-Hinweis **nicht** erscheint
- [ ] Einen der Befehle wirklich ausführen (davfs2-Einhängen)
- [ ] Windows-Weg auf einem echten Windows durchspielen – die 50-MB-Grenze und
der Registry-Pfad stammen aus der Dokumentation, nicht aus einem Versuch
## Automatisiert geprüft (22.08.2026)
Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie
+23 -1
View File
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { appPasswordsApi, type AppPassword } from '../api/client'
import ConfirmDialog from './ConfirmDialog'
import WebDavGuide from './WebDavGuide'
import { InfoIcon } from './icons'
/** Zugangsdaten für WebDAV-Clients, die das Hauptpasswort ersetzen: Geht ein
* Gerät verloren, widerruft man dieses eine Token statt das Konto neu
@@ -12,6 +14,7 @@ export default function AppPasswords({ username }: { username: string }) {
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [revoking, setRevoking] = useState<AppPassword | null>(null)
const [guide, setGuide] = useState(false)
// Nur einmal im Klartext vorhanden – danach kennt der Server nur den Hash.
const [freshToken, setFreshToken] = useState<string | null>(null)
@@ -67,12 +70,29 @@ export default function AppPasswords({ username }: { username: string }) {
return (
<>
<p style={{ margin: '-8px 0 16px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
<p style={{ margin: '-8px 0 12px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
WebDAV and sync clients need an app password — your account password does
not work there. Each one can be revoked on its own, so a lost device costs
you one password, not your account.
</p>
{/* Die Anleitung steht neben der Erklärung, nicht in ihr: Wer schon weiß,
wie es geht, braucht sie nie – wer sie braucht, sucht sie genau hier,
weil hier das Passwort entsteht. Die WebDAV-Adresse stand vorher
nirgends in der Oberfläche. */}
<button
onClick={() => setGuide(true)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginBottom: 16,
padding: '6px 12px', borderRadius: 8,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>
<InfoIcon size={14} />
How to connect a drive
</button>
{/* Der Klartext erscheint genau einmal; deshalb prominent und mit
deutlicher Ansage statt als beiläufige Zeile in der Liste. */}
{freshToken && (
@@ -202,6 +222,8 @@ export default function AppPasswords({ username }: { username: string }) {
</div>
)}
{guide && <WebDavGuide username={username} onClose={() => setGuide(false)} />}
{revoking && (
<ConfirmDialog
title="Revoke app password"
+321
View File
@@ -0,0 +1,321 @@
import { useState } from 'react'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
import Tabs from './Tabs'
/**
* Wie hänge ich diese Cloud als Laufwerk ein?
*
* Die Adresse steht bisher nirgends. Sie ist zwar ableitbar – Cloud-Adresse
* plus `/dav/` –, aber „ableitbar" heißt: Man muss wissen, dass es sie gibt.
* Und die drei Wege dorthin (Windows, Konsole, Dateimanager) haben je eine
* Eigenheit, an der es sonst scheitert:
*
* * Windows verlangt HTTPS und lädt ohne Registry-Eingriff nichts über 50 MB.
* * Die Konsole braucht ein zusätzliches Paket (davfs2).
* * Dateimanager wollen ein eigenes Schema: `davs://` statt `https://`.
*
* Die Adressen entstehen aus `window.location`, nicht aus einer Einstellung:
* Was im Browser funktioniert, funktioniert auch im Dateimanager – eine
* falsch gesetzte PUBLIC_URL würde hier sonst eine Adresse anbieten, die
* nachweislich nicht die ist, über die der Betrachter gerade hereinkam.
*/
interface Props {
username: string
onClose: () => void
}
type Tab = 'windows' | 'terminal' | 'files'
/** Eine Zeile zum Abschreiben – mit Knopf, weil genau das sonst schiefgeht. */
function CopyRow({ label, value, hint }: { label: string; value: string; hint?: string }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard?.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{label}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 12.5, fontFamily: 'monospace',
overflowX: 'auto', whiteSpace: 'nowrap', userSelect: 'all',
}}>{value}</code>
<button
onClick={copy}
style={{
padding: '8px 12px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', flexShrink: 0,
fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
{hint && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.5 }}>
{hint}
</div>
)}
</div>
)
}
function Step({ n, children }: { n: number; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', gap: 10, marginBottom: 9 }}>
<div style={{
width: 19, height: 19, borderRadius: '50%', flexShrink: 0, marginTop: 1,
background: 'var(--color-surface-2)', color: 'var(--color-text-muted)',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{n}</div>
<div style={{ fontSize: 13, color: 'var(--color-text)', lineHeight: 1.55 }}>{children}</div>
</div>
)
}
/** Ein Kasten für das, was an dieser Stelle erfahrungsgemäß schiefgeht. */
function Caveat({ children }: { children: React.ReactNode }) {
return (
<div style={{
marginTop: 12, padding: '10px 12px', borderRadius: 9,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6,
}}>
{children}
</div>
)
}
function Code({ children }: { children: string }) {
const [copied, setCopied] = useState(false)
return (
<div style={{ position: 'relative', marginBottom: 10 }}>
<pre style={{
margin: 0, padding: '10px 12px', paddingRight: 62, borderRadius: 8,
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 12, lineHeight: 1.65, overflowX: 'auto',
fontFamily: 'monospace',
}}>{children}</pre>
<button
onClick={() => {
navigator.clipboard?.writeText(children).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}}
style={{
position: 'absolute', top: 6, right: 6,
padding: '4px 9px', borderRadius: 6, border: '1px solid var(--color-border)',
background: 'var(--color-surface)', color: 'var(--color-text-muted)',
fontSize: 10.5, fontWeight: 600, cursor: 'pointer',
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)
}
export default function WebDavGuide({ username, onClose }: Props) {
const overlay = useOverlayDismiss(onClose)
const [tab, setTab] = useState<Tab>('windows')
const origin = window.location.origin
const davURL = `${origin}/dav/`
// Dateimanager sprechen nicht https, sondern davs (verschlüsselt) bzw. dav.
const davsURL = davURL.replace(/^https:/, 'davs:').replace(/^http:/, 'dav:')
const isHTTPS = window.location.protocol === 'https:'
const host = window.location.host
// Dolphin kennt beide Schreibweisen; die verschlüsselte heißt anders.
const kioURL = `${isHTTPS ? 'webdavs' : 'webdav'}://${host}/dav/`
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0,0,0,0.6)' }}
{...overlay}>
<div className="w-full rounded-xl shadow-2xl border"
style={{
maxWidth: 620, maxHeight: '86vh', overflowY: 'auto',
padding: 20,
background: 'var(--color-surface)', borderColor: 'var(--color-border)',
}}>
<h2 className="font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
Connect as a drive
</h2>
<p style={{ margin: '0 0 16px', fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
Your files over WebDAV — same folders, in your file manager. Sign in with
your username and an <strong style={{ color: 'var(--color-text)' }}>app password</strong>;
your account password is refused here on purpose.
</p>
<CopyRow label="WebDAV address" value={davURL} />
<CopyRow label="Username" value={username} />
<CopyRow
label="Cloud address (browser)"
value={origin}
hint="The normal address — for sharing links or opening the cloud on another device."
/>
{!isHTTPS && (
<Caveat>
<strong style={{ color: 'var(--color-danger)' }}>This page is not using HTTPS.</strong>{' '}
Windows refuses WebDAV over plain HTTP outright, and every other client
sends your app password unencrypted. Put a reverse proxy with TLS in
front before you connect a device.
</Caveat>
)}
<div style={{ marginTop: 18 }}>
<Tabs<Tab>
tabs={[
{ id: 'windows', label: 'Windows' },
{ id: 'terminal', label: 'Linux · terminal' },
{ id: 'files', label: 'Linux · file manager' },
]}
active={tab}
onChange={setTab}
/>
</div>
{tab === 'windows' && (
<div>
<Step n={1}>Open Explorer, right-click <strong>This PC</strong> → <strong>Map network drive…</strong></Step>
<Step n={2}>Folder: <code style={{ fontSize: 12 }}>{davURL}</code></Step>
<Step n={3}>Tick <strong>Connect using different credentials</strong>.</Step>
<Step n={4}>Username <strong>{username}</strong>, password = your app password.</Step>
<Caveat>
<strong style={{ color: 'var(--color-text)' }}>Two things Windows will do to you.</strong>
<br />
Downloads stop at <strong>50 MB</strong> — that is a built-in limit of the
Windows WebDAV client, not of this server. Raise it in
<code style={{ fontSize: 11.5 }}> HKLM\SYSTEM\CurrentControlSet\Services\WebClient\Parameters </code>
→ <code style={{ fontSize: 11.5 }}>FileSizeLimitInBytes</code> (decimal
4294967295 = 4 GB), then restart the <em>WebClient</em> service.
<br /><br />
If mapping fails immediately, that service is usually not running:
<code style={{ fontSize: 11.5 }}> services.msc </code>→ WebClient → start,
set to Automatic.
<br /><br />
For large files, a dedicated client (WinSCP, Cyberduck, RaiDrive) is the
calmer route — none of them have that limit.
</Caveat>
</div>
)}
{tab === 'terminal' && (
<div>
<p style={{ margin: '0 0 10px', fontSize: 13, color: 'var(--color-text)', lineHeight: 1.55 }}>
Mount it with <strong>davfs2</strong>. It asks for username and password
on the first mount.
</p>
<Code>{`sudo apt install davfs2 # Debian/Ubuntu
# sudo dnf install davfs2 # Fedora
# sudo pacman -S davfs2 # Arch
sudo mkdir -p /mnt/aether
sudo mount -t davfs ${davURL} /mnt/aether`}</Code>
<p style={{ margin: '14px 0 8px', fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Without typing the password every time
</p>
<p style={{ margin: '0 0 8px', fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
Put the credentials in <code style={{ fontSize: 12 }}>~/.davfs2/secrets</code> — the
file must be readable by you alone, davfs2 refuses it otherwise:
</p>
<Code>{`mkdir -p ~/.davfs2
echo '${davURL} ${username} YOUR-APP-PASSWORD' >> ~/.davfs2/secrets
chmod 600 ~/.davfs2/secrets`}</Code>
<p style={{ margin: '14px 0 8px', fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Mount it at boot
</p>
<p style={{ margin: '0 0 8px', fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
One line in <code style={{ fontSize: 12 }}>/etc/fstab</code>:
</p>
<Code>{`${davURL} /mnt/aether davfs user,noauto,_netdev 0 0`}</Code>
<Caveat>
<code style={{ fontSize: 11.5 }}>_netdev</code> matters: without it the
system tries to mount before the network is up and the boot stalls.
<br /><br />
davfs2 caches in <code style={{ fontSize: 11.5 }}>~/.davfs2/cache</code>.
A large file is written there first and only then uploaded — so a copy
can look finished while it is still on its way.
<br /><br />
Prefer <strong>rclone</strong>? <code style={{ fontSize: 11.5 }}>rclone config</code> →
type <code style={{ fontSize: 11.5 }}>webdav</code> → vendor{' '}
<code style={{ fontSize: 11.5 }}>other</code> → URL as above. It handles
interrupted transfers better.
</Caveat>
</div>
)}
{tab === 'files' && (
<div>
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text)', lineHeight: 1.55 }}>
GNOME Files, Dolphin, Nemo and Thunar all speak WebDAV natively — but they
want their own scheme, not <code style={{ fontSize: 12 }}>https://</code>:
</p>
<CopyRow
label="Address for the file manager"
value={davsURL}
hint={isHTTPS
? 'davs:// means WebDAV over TLS. Plain dav:// would send your password in the clear.'
: 'dav:// (unencrypted) because this page is not on HTTPS — see the warning above.'}
/>
<Step n={1}>
<strong>GNOME Files:</strong> <em>Other Locations</em> at the bottom of the
sidebar → <em>Connect to Server</em> → paste the address → Connect.
</Step>
<Step n={2}>
<strong>KDE Dolphin:</strong> type the address straight into the location
bar (Ctrl&nbsp;+&nbsp;L). Dolphin also accepts{' '}
<code style={{ fontSize: 12 }}>{kioURL}</code>.
</Step>
<Step n={3}>
Username <strong>{username}</strong>, password = your app password. Tick
“remember” so the keyring keeps it.
</Step>
<Step n={4}>
Right-click the mount in the sidebar → <em>Add Bookmark</em>, and it stays
one click away.
</Step>
<Caveat>
These mounts live in GVFS/KIO, not in the kernel — they show up in the file
manager, but not always for other programs. If a tool cannot see the files,
look under <code style={{ fontSize: 11.5 }}>/run/user/$(id -u)/gvfs/</code>{' '}
or mount with davfs2 instead (the terminal tab).
</Caveat>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
Close
</button>
</div>
</div>
</div>
)
}