0150bf4b2f
- Firefox-Import: CSV aus Einstellungen > Passwoerter > Exportieren Domain wird automatisch als Titel extrahiert - Generischer CSV-Import: Erkennt automatisch Spaltennamen aus Chrome, Bitwarden, 1Password und anderen Managern - KeePass-Import bleibt bestehen - Einheitlicher Import-Dialog mit Quellen-Auswahl (Dropdown) - Jede Quelle zeigt eine kurze Anleitung an - Alle Eintraege werden clientseitig verschluesselt vor dem Speichern - Backend: /passwords/import/firefox und /passwords/import/csv Endpunkte Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
511 lines
20 KiB
Vue
511 lines
20 KiB
Vue
<template>
|
|
<div class="view-container">
|
|
<div class="view-header">
|
|
<h2>Passwoerter</h2>
|
|
<div class="header-actions">
|
|
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
|
|
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
|
|
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="showImport = true" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="passwords-layout">
|
|
<aside class="folders-sidebar">
|
|
<div class="folder-item" :class="{ active: selectedFolderId === null }" @click="selectedFolderId = null; loadEntries()">
|
|
<i class="pi pi-key"></i>
|
|
<span>Alle</span>
|
|
</div>
|
|
<div v-for="folder in folders" :key="folder.id"
|
|
class="folder-item" :class="{ active: selectedFolderId === folder.id }"
|
|
@click="selectedFolderId = folder.id; loadEntries()">
|
|
<i :class="folder.icon || 'pi pi-folder'"></i>
|
|
<span>{{ folder.name }}</span>
|
|
<span v-if="folder.owner_name" class="shared-label">({{ folder.owner_name }})</span>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="entries-main">
|
|
<div class="search-bar">
|
|
<InputText v-model="searchQuery" placeholder="Passwoerter suchen..." fluid />
|
|
</div>
|
|
|
|
<div class="entries-list">
|
|
<div v-for="entry in filteredEntries" :key="entry.id"
|
|
class="entry-item" @click="openEntry(entry)">
|
|
<div class="entry-icon">
|
|
<i class="pi pi-key"></i>
|
|
</div>
|
|
<div class="entry-info">
|
|
<div class="entry-title">{{ decryptedEntries[entry.id]?.title || '(Verschluesselt)' }}</div>
|
|
<div class="entry-url">{{ decryptedEntries[entry.id]?.username || '' }}</div>
|
|
</div>
|
|
<div class="entry-actions">
|
|
<Button icon="pi pi-copy" text size="small" title="Passwort kopieren"
|
|
@click.stop="copyPassword(entry)" />
|
|
<Button v-if="decryptedEntries[entry.id]?.totp_secret" icon="pi pi-clock" text size="small"
|
|
title="TOTP Code" @click.stop="showTotp(entry)" />
|
|
</div>
|
|
</div>
|
|
<div v-if="!filteredEntries.length" class="empty">
|
|
<i class="pi pi-key" style="font-size: 2rem; color: var(--p-surface-400)"></i>
|
|
<p>Keine Eintraege</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Entry Dialog -->
|
|
<Dialog v-model:visible="showEntryDialog" :header="editingEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag'" modal :style="{ width: '500px' }">
|
|
<div class="field">
|
|
<label>Titel</label>
|
|
<InputText v-model="entryForm.title" fluid autofocus />
|
|
</div>
|
|
<div class="field">
|
|
<label>URL</label>
|
|
<InputText v-model="entryForm.url" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Benutzername</label>
|
|
<InputText v-model="entryForm.username" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Passwort</label>
|
|
<div class="password-field">
|
|
<Password v-model="entryForm.password" :feedback="false" toggle-mask fluid />
|
|
<Button icon="pi pi-sync" text size="small" title="Generieren" @click="generatePassword" />
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>TOTP Secret (optional)</label>
|
|
<InputText v-model="entryForm.totp_secret" fluid placeholder="otpauth:// oder Secret" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Ordner</label>
|
|
<Select v-model="entryForm.folder_id" :options="folderOptions" optionLabel="name" optionValue="id" fluid placeholder="Kein Ordner" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Kategorie</label>
|
|
<InputText v-model="entryForm.category" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Notizen</label>
|
|
<Textarea v-model="entryForm.notes" rows="3" fluid />
|
|
</div>
|
|
<template #footer>
|
|
<Button v-if="editingEntry" label="Loeschen" severity="danger" text @click="deleteEntry" />
|
|
<Button label="Abbrechen" text @click="showEntryDialog = false" />
|
|
<Button :label="editingEntry ? 'Speichern' : 'Erstellen'" @click="saveEntry" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- New Folder -->
|
|
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Abbrechen" text @click="showNewFolder = false" />
|
|
<Button label="Erstellen" @click="createFolder" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- Import Dialog -->
|
|
<Dialog v-model:visible="showImport" header="Passwoerter importieren" modal :style="{ width: '520px' }">
|
|
<div class="field">
|
|
<label>Quelle</label>
|
|
<Select v-model="importSource" :options="importSources" optionLabel="label" optionValue="value" fluid />
|
|
</div>
|
|
|
|
<div class="import-hint">
|
|
<template v-if="importSource === 'keepass'">
|
|
<p>Waehle eine <strong>.kdbx</strong>-Datei und gib das KeePass-Master-Passwort ein.</p>
|
|
</template>
|
|
<template v-else-if="importSource === 'firefox'">
|
|
<p>Firefox: <strong>Einstellungen > Passwoerter > ⋯ > Passwoerter exportieren</strong><br/>Waehle die exportierte CSV-Datei aus.</p>
|
|
</template>
|
|
<template v-else-if="importSource === 'csv'">
|
|
<p>CSV mit Spalten wie <em>title/name, url, username, password, notes</em>.<br/>Funktioniert mit Chrome, Bitwarden, 1Password und anderen Managern.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Datei</label>
|
|
<input ref="importFileInput" type="file" :accept="importAccept" />
|
|
</div>
|
|
|
|
<div v-if="importSource === 'keepass'" class="field">
|
|
<label>KeePass-Passwort</label>
|
|
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Abbrechen" text @click="showImport = false" />
|
|
<Button label="Importieren" @click="doImport" :loading="importing" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- TOTP Dialog -->
|
|
<Dialog v-model:visible="showTotpDialog" header="TOTP Code" modal :style="{ width: '300px' }">
|
|
<div class="totp-display">
|
|
<div class="totp-code">{{ totpCode }}</div>
|
|
<Button icon="pi pi-copy" text @click="copyToClipboard(totpCode)" />
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import apiClient from '../api/client'
|
|
import Button from 'primevue/button'
|
|
import Dialog from 'primevue/dialog'
|
|
import InputText from 'primevue/inputtext'
|
|
import Password from 'primevue/password'
|
|
import Textarea from 'primevue/textarea'
|
|
import Select from 'primevue/select'
|
|
|
|
const toast = useToast()
|
|
const auth = useAuthStore()
|
|
|
|
const folders = ref([])
|
|
const entries = ref([])
|
|
const decryptedEntries = ref({})
|
|
const selectedFolderId = ref(null)
|
|
const searchQuery = ref('')
|
|
|
|
const showEntryDialog = ref(false)
|
|
const editingEntry = ref(null)
|
|
const entryForm = ref({ title: '', url: '', username: '', password: '', totp_secret: '', folder_id: null, category: '', notes: '' })
|
|
|
|
const showNewFolder = ref(false)
|
|
const newFolderName = ref('')
|
|
const showImport = ref(false)
|
|
const importSource = ref('firefox')
|
|
const importPassword = ref('')
|
|
const importing = ref(false)
|
|
const importFileInput = ref(null)
|
|
const importSources = [
|
|
{ label: 'Firefox (CSV)', value: 'firefox' },
|
|
{ label: 'KeePass (.kdbx)', value: 'keepass' },
|
|
{ label: 'CSV (Chrome, Bitwarden, etc.)', value: 'csv' },
|
|
]
|
|
const importAccept = computed(() => {
|
|
if (importSource.value === 'keepass') return '.kdbx'
|
|
return '.csv'
|
|
})
|
|
|
|
const showTotpDialog = ref(false)
|
|
const totpCode = ref('')
|
|
|
|
const folderOptions = computed(() => [{ id: null, name: '(Kein Ordner)' }, ...folders.value])
|
|
const filteredEntries = computed(() => {
|
|
if (!searchQuery.value) return entries.value
|
|
const q = searchQuery.value.toLowerCase()
|
|
return entries.value.filter(e => {
|
|
const d = decryptedEntries.value[e.id]
|
|
if (!d) return false
|
|
return (d.title || '').toLowerCase().includes(q) ||
|
|
(d.username || '').toLowerCase().includes(q) ||
|
|
(d.url || '').toLowerCase().includes(q)
|
|
})
|
|
})
|
|
|
|
// --- Crypto helpers using Web Crypto API ---
|
|
async function getMasterKey() {
|
|
const salt = auth.masterKeySalt
|
|
if (!salt) return null
|
|
const enc = new TextEncoder()
|
|
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(auth.user?.username + ':' + 'stored'), 'PBKDF2', false, ['deriveKey'])
|
|
return crypto.subtle.deriveKey(
|
|
{ name: 'PBKDF2', salt: Uint8Array.from(atob(salt), c => c.charCodeAt(0)), iterations: 600000, hash: 'SHA-256' },
|
|
keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
|
|
)
|
|
}
|
|
|
|
async function encryptText(text, key) {
|
|
if (!text) return null
|
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
const enc = new TextEncoder()
|
|
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text))
|
|
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
|
|
combined.set(iv)
|
|
combined.set(new Uint8Array(ciphertext), iv.length)
|
|
return btoa(String.fromCharCode(...combined))
|
|
}
|
|
|
|
async function decryptText(b64, key) {
|
|
if (!b64) return ''
|
|
try {
|
|
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
|
|
const iv = raw.slice(0, 12)
|
|
const ciphertext = raw.slice(12)
|
|
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
|
|
return new TextDecoder().decode(plaintext)
|
|
} catch { return '(Entschluesselung fehlgeschlagen)' }
|
|
}
|
|
|
|
async function decryptEntries() {
|
|
const key = await getMasterKey()
|
|
if (!key) return
|
|
const result = {}
|
|
for (const entry of entries.value) {
|
|
result[entry.id] = {
|
|
title: await decryptText(entry.title_encrypted, key),
|
|
url: await decryptText(entry.url_encrypted, key),
|
|
username: await decryptText(entry.username_encrypted, key),
|
|
password: await decryptText(entry.password_encrypted, key),
|
|
notes: await decryptText(entry.notes_encrypted, key),
|
|
totp_secret: await decryptText(entry.totp_secret_encrypted, key),
|
|
}
|
|
}
|
|
decryptedEntries.value = result
|
|
}
|
|
|
|
// --- Data loading ---
|
|
async function loadFolders() {
|
|
const res = await apiClient.get('/passwords/folders')
|
|
folders.value = res.data
|
|
}
|
|
|
|
async function loadEntries() {
|
|
const params = {}
|
|
if (selectedFolderId.value !== null) params.folder_id = selectedFolderId.value
|
|
const res = await apiClient.get('/passwords/entries', { params })
|
|
entries.value = res.data
|
|
await decryptEntries()
|
|
}
|
|
|
|
async function createFolder() {
|
|
if (!newFolderName.value.trim()) return
|
|
await apiClient.post('/passwords/folders', { name: newFolderName.value.trim() })
|
|
showNewFolder.value = false
|
|
newFolderName.value = ''
|
|
await loadFolders()
|
|
}
|
|
|
|
function openNewEntry() {
|
|
editingEntry.value = null
|
|
entryForm.value = { title: '', url: '', username: '', password: '', totp_secret: '', folder_id: selectedFolderId.value, category: '', notes: '' }
|
|
showEntryDialog.value = true
|
|
}
|
|
|
|
async function openEntry(entry) {
|
|
editingEntry.value = entry
|
|
const d = decryptedEntries.value[entry.id] || {}
|
|
entryForm.value = {
|
|
title: d.title || '',
|
|
url: d.url || '',
|
|
username: d.username || '',
|
|
password: d.password || '',
|
|
totp_secret: d.totp_secret || '',
|
|
folder_id: entry.folder_id,
|
|
category: entry.category || '',
|
|
notes: d.notes || '',
|
|
}
|
|
showEntryDialog.value = true
|
|
}
|
|
|
|
async function saveEntry() {
|
|
const key = await getMasterKey()
|
|
if (!key) { toast.add({ severity: 'error', summary: 'Kein Master-Key', life: 3000 }); return }
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
const ivB64 = btoa(String.fromCharCode(...iv))
|
|
|
|
const payload = {
|
|
title_encrypted: await encryptText(entryForm.value.title, key),
|
|
url_encrypted: await encryptText(entryForm.value.url, key),
|
|
username_encrypted: await encryptText(entryForm.value.username, key),
|
|
password_encrypted: await encryptText(entryForm.value.password, key),
|
|
notes_encrypted: await encryptText(entryForm.value.notes, key),
|
|
totp_secret_encrypted: await encryptText(entryForm.value.totp_secret, key),
|
|
iv: ivB64,
|
|
folder_id: entryForm.value.folder_id,
|
|
category: entryForm.value.category,
|
|
}
|
|
|
|
if (editingEntry.value) {
|
|
await apiClient.put(`/passwords/entries/${editingEntry.value.id}`, payload)
|
|
} else {
|
|
await apiClient.post('/passwords/entries', payload)
|
|
}
|
|
showEntryDialog.value = false
|
|
await loadEntries()
|
|
}
|
|
|
|
async function deleteEntry() {
|
|
if (!editingEntry.value) return
|
|
await apiClient.delete(`/passwords/entries/${editingEntry.value.id}`)
|
|
showEntryDialog.value = false
|
|
await loadEntries()
|
|
}
|
|
|
|
async function copyPassword(entry) {
|
|
const d = decryptedEntries.value[entry.id]
|
|
if (d?.password) {
|
|
await navigator.clipboard.writeText(d.password)
|
|
toast.add({ severity: 'info', summary: 'Passwort kopiert', life: 2000 })
|
|
}
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text)
|
|
toast.add({ severity: 'info', summary: 'Kopiert', life: 2000 })
|
|
}
|
|
|
|
function generatePassword() {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-='
|
|
const arr = new Uint8Array(20)
|
|
crypto.getRandomValues(arr)
|
|
entryForm.value.password = Array.from(arr, b => chars[b % chars.length]).join('')
|
|
}
|
|
|
|
async function showTotp(entry) {
|
|
const d = decryptedEntries.value[entry.id]
|
|
if (!d?.totp_secret) return
|
|
// Simple TOTP generation
|
|
try {
|
|
const secret = d.totp_secret.replace(/^otpauth:\/\/.*secret=/, '').replace(/&.*/, '')
|
|
totpCode.value = await generateTOTP(secret)
|
|
showTotpDialog.value = true
|
|
} catch {
|
|
toast.add({ severity: 'error', summary: 'TOTP-Fehler', life: 3000 })
|
|
}
|
|
}
|
|
|
|
async function generateTOTP(secret) {
|
|
// Base32 decode
|
|
const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
|
const bits = secret.toUpperCase().replace(/=+$/, '').split('').map(c => {
|
|
const val = base32.indexOf(c)
|
|
return val >= 0 ? val.toString(2).padStart(5, '0') : ''
|
|
}).join('')
|
|
const bytes = new Uint8Array(bits.match(/.{8}/g).map(b => parseInt(b, 2)))
|
|
|
|
const time = Math.floor(Date.now() / 30000)
|
|
const timeBytes = new Uint8Array(8)
|
|
let t = time
|
|
for (let i = 7; i >= 0; i--) { timeBytes[i] = t & 0xff; t >>= 8 }
|
|
|
|
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
|
|
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, timeBytes))
|
|
const offset = sig[sig.length - 1] & 0xf
|
|
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1000000
|
|
return code.toString().padStart(6, '0')
|
|
}
|
|
|
|
async function doImport() {
|
|
if (!importFileInput.value?.files?.length) return
|
|
if (importSource.value === 'keepass' && !importPassword.value) {
|
|
toast.add({ severity: 'warn', summary: 'KeePass-Passwort erforderlich', life: 3000 })
|
|
return
|
|
}
|
|
|
|
importing.value = true
|
|
try {
|
|
// Step 1: Upload file to backend for parsing
|
|
const formData = new FormData()
|
|
formData.append('file', importFileInput.value.files[0])
|
|
if (importSource.value === 'keepass') {
|
|
formData.append('password', importPassword.value)
|
|
}
|
|
|
|
const endpoint = {
|
|
keepass: '/passwords/import/keepass',
|
|
firefox: '/passwords/import/firefox',
|
|
csv: '/passwords/import/csv',
|
|
}[importSource.value]
|
|
|
|
const res = await apiClient.post(endpoint, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
})
|
|
|
|
if (!res.data.count) {
|
|
toast.add({ severity: 'warn', summary: 'Keine Eintraege gefunden', life: 3000 })
|
|
return
|
|
}
|
|
|
|
// Step 2: Encrypt and store entries client-side
|
|
const key = await getMasterKey()
|
|
if (!key) return
|
|
|
|
// Create folders
|
|
const folderMap = {}
|
|
for (const group of res.data.groups) {
|
|
try {
|
|
const folder = await apiClient.post('/passwords/folders', { name: group.name })
|
|
folderMap[group.uuid || group.name] = folder.data.id
|
|
} catch { /* folder may already exist */ }
|
|
}
|
|
|
|
// Import entries
|
|
let imported = 0
|
|
for (const entry of res.data.entries) {
|
|
const folderId = folderMap[entry.group_uuid || entry.group] || null
|
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
try {
|
|
await apiClient.post('/passwords/entries', {
|
|
title_encrypted: await encryptText(entry.title, key),
|
|
url_encrypted: await encryptText(entry.url, key),
|
|
username_encrypted: await encryptText(entry.username, key),
|
|
password_encrypted: await encryptText(entry.password, key),
|
|
notes_encrypted: await encryptText(entry.notes, key),
|
|
totp_secret_encrypted: await encryptText(entry.totp, key),
|
|
iv: btoa(String.fromCharCode(...iv)),
|
|
folder_id: folderId,
|
|
})
|
|
imported++
|
|
} catch { /* skip duplicates */ }
|
|
}
|
|
|
|
showImport.value = false
|
|
toast.add({ severity: 'success', summary: `${imported} Eintraege importiert`, life: 5000 })
|
|
await loadFolders()
|
|
await loadEntries()
|
|
} catch (err) {
|
|
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error || String(err), life: 5000 })
|
|
} finally {
|
|
importing.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadFolders()
|
|
await loadEntries()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.view-container { padding: 1.5rem; }
|
|
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
|
.view-header h2 { margin: 0; }
|
|
.header-actions { display: flex; gap: 0.5rem; }
|
|
.passwords-layout { display: flex; gap: 1rem; }
|
|
.folders-sidebar { width: 220px; flex-shrink: 0; }
|
|
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
|
.folder-item:hover { background: var(--p-surface-100); }
|
|
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
|
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
|
.entries-main { flex: 1; }
|
|
.search-bar { margin-bottom: 1rem; }
|
|
.entries-list { display: flex; flex-direction: column; gap: 2px; }
|
|
.entry-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--p-surface-0); border-radius: 6px; cursor: pointer; }
|
|
.entry-item:hover { background: var(--p-surface-100); }
|
|
.entry-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--p-primary-50); border-radius: 8px; color: var(--p-primary-color); }
|
|
.entry-info { flex: 1; }
|
|
.entry-title { font-weight: 500; font-size: 0.9rem; }
|
|
.entry-url { font-size: 0.8rem; color: var(--p-text-muted-color); }
|
|
.entry-actions { display: flex; gap: 0; }
|
|
.field { margin-bottom: 1rem; }
|
|
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
|
.password-field { display: flex; gap: 0.5rem; align-items: flex-start; }
|
|
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); }
|
|
.totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; }
|
|
.totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; }
|
|
.import-hint { background: var(--p-surface-50); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
|
|
.import-hint p { margin: 0; line-height: 1.5; }
|
|
</style>
|