feat(profile): Passkey-Labels, Sicherheits-Checkliste und Geräte-Block
Erweitert die Profilseite um benennbare Passkeys, Sicherheitsübersicht, Gerät/Sync-Status, Backup-Hinweis in der Gefahrenzone und Dialog beim Löschen des letzten Passkeys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+86
-1
@@ -918,7 +918,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
|
|
||||||
.profile-passkey-item {
|
.profile-passkey-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -927,6 +927,91 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-passkey-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-rename {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-passkey-rename .input-text {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-add-passkey {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-add-passkey .input-group label {
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item--ok {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-item--warn {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-recovery-hint {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-device-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-danger-zone__hint {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-passkey-id {
|
.profile-passkey-id {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||||
|
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||||
|
|
||||||
<div className="form-actions account-danger-zone__actions">
|
<div className="form-actions account-danger-zone__actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Lock,
|
Lock,
|
||||||
BarChart2
|
BarChart2,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
RefreshCw,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
CircleCheck,
|
||||||
|
CircleAlert
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
@@ -26,10 +33,13 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import {
|
import {
|
||||||
addPasskey,
|
addPasskey,
|
||||||
fetchUserProfile,
|
fetchUserProfile,
|
||||||
|
forgetUsername,
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
|
getKnownUsernames,
|
||||||
hasLocalPin,
|
hasLocalPin,
|
||||||
removeLocalPin,
|
removeLocalPin,
|
||||||
removePasskey,
|
removePasskey,
|
||||||
|
renamePasskey,
|
||||||
setLocalPin,
|
setLocalPin,
|
||||||
type UserProfile
|
type UserProfile
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
@@ -81,6 +91,15 @@ function KpiCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||||
|
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm, showAlert } = useDialog()
|
const { showConfirm, showAlert } = useDialog()
|
||||||
@@ -96,6 +115,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
const [pinInput, setPinInput] = useState('')
|
const [pinInput, setPinInput] = useState('')
|
||||||
const [pinConfirm, setPinConfirm] = useState('')
|
const [pinConfirm, setPinConfirm] = useState('')
|
||||||
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||||
|
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||||
|
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||||
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
|
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||||
|
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||||
|
|
||||||
const sharedLogbookCount = useLiveQuery(
|
const sharedLogbookCount = useLiveQuery(
|
||||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||||
@@ -127,6 +154,26 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
void loadData()
|
void loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setOnline(true)
|
||||||
|
const handleOffline = () => setOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const cred of profile.credentials) {
|
||||||
|
labels[cred.id] = cred.label ?? ''
|
||||||
|
}
|
||||||
|
setPasskeyLabels(labels)
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
const statsTotals = accountStats?.totals
|
const statsTotals = accountStats?.totals
|
||||||
const logbookCount =
|
const logbookCount =
|
||||||
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||||
@@ -151,7 +198,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
setPasskeyBusy(true)
|
setPasskeyBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await addPasskey()
|
await addPasskey(newPasskeyLabel)
|
||||||
|
setNewPasskeyLabel('')
|
||||||
await loadData()
|
await loadData()
|
||||||
showAlert(t('profile.add_passkey_success'))
|
showAlert(t('profile.add_passkey_success'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -161,7 +209,42 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRenamePasskey = async (credentialId: string) => {
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||||
|
await loadData()
|
||||||
|
showAlert(t('profile.passkey_rename_success'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForgetDevice = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.device_forget_confirm_desc'),
|
||||||
|
t('profile.device_forget_confirm_title'),
|
||||||
|
t('profile.device_forget_confirm_yes'),
|
||||||
|
t('profile.remove_passkey_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
forgetUsername(username)
|
||||||
|
setIsKnownDevice(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleRemovePasskey = async (credentialId: string) => {
|
const handleRemovePasskey = async (credentialId: string) => {
|
||||||
|
if (profile && profile.credentials.length <= 1) {
|
||||||
|
await showAlert(
|
||||||
|
t('profile.remove_passkey_last_desc'),
|
||||||
|
t('profile.remove_passkey_last_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
t('profile.remove_passkey_confirm_desc'),
|
t('profile.remove_passkey_confirm_desc'),
|
||||||
t('profile.remove_passkey_confirm_title'),
|
t('profile.remove_passkey_confirm_title'),
|
||||||
@@ -303,6 +386,80 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Shield size={20} />
|
||||||
|
<h3>{t('profile.security_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||||
|
<ul className="profile-security-list">
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.credentials.length > 0}
|
||||||
|
label={
|
||||||
|
profile.credentials.length > 0
|
||||||
|
? t('profile.security_passkeys_ok')
|
||||||
|
: t('profile.security_passkeys_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.hasPrfEncryption}
|
||||||
|
label={
|
||||||
|
profile.hasPrfEncryption
|
||||||
|
? t('profile.security_prf_ok')
|
||||||
|
: t('profile.security_prf_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={pinActive}
|
||||||
|
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||||
|
</ul>
|
||||||
|
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Smartphone size={20} />
|
||||||
|
<h3>{t('profile.device_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||||
|
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||||
|
{online ? (
|
||||||
|
pendingSyncCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||||
|
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi size={16} aria-hidden="true" />
|
||||||
|
<span>{t('profile.device_sync_ok')}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff size={16} aria-hidden="true" />
|
||||||
|
<span>{t('sync.status_offline')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="profile-pin-status">
|
||||||
|
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||||
|
</p>
|
||||||
|
{isKnownDevice && (
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleForgetDevice()}
|
||||||
|
>
|
||||||
|
{t('profile.device_forget_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<Lock size={20} />
|
<Lock size={20} />
|
||||||
@@ -378,24 +535,44 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
<ul className="profile-passkey-list">
|
<ul className="profile-passkey-list">
|
||||||
{profile.credentials.map((cred) => (
|
{profile.credentials.map((cred) => (
|
||||||
<li key={cred.id} className="profile-passkey-item">
|
<li key={cred.id} className="profile-passkey-item">
|
||||||
<div>
|
<div className="profile-passkey-main">
|
||||||
|
<span className="profile-passkey-label">
|
||||||
|
{cred.label || t('profile.passkey_unnamed')}
|
||||||
|
</span>
|
||||||
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||||
{cred.transports.length > 0 && (
|
{cred.transports.length > 0 && (
|
||||||
<span className="profile-passkey-transports">
|
<span className="profile-passkey-transports">
|
||||||
{cred.transports.join(', ')}
|
{cred.transports.join(', ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="profile-passkey-rename">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={passkeyLabels[cred.id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRenamePasskey(cred.id)}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
>
|
||||||
|
{t('profile.passkey_rename_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon danger"
|
className="btn-icon danger"
|
||||||
onClick={() => void handleRemovePasskey(cred.id)}
|
onClick={() => void handleRemovePasskey(cred.id)}
|
||||||
disabled={passkeyBusy || profile.credentials.length <= 1}
|
disabled={passkeyBusy}
|
||||||
title={
|
title={t('profile.remove_passkey_btn')}
|
||||||
profile.credentials.length <= 1
|
|
||||||
? t('profile.remove_passkey_last')
|
|
||||||
: t('profile.remove_passkey_btn')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -404,6 +581,22 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="profile-add-passkey">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-new-passkey-label"
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={newPasskeyLabel}
|
||||||
|
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-actions mt-4">
|
<div className="form-actions mt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -295,7 +295,8 @@
|
|||||||
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||||
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||||
"remove_passkey_btn": "Passkey entfernen",
|
"remove_passkey_btn": "Passkey entfernen",
|
||||||
"remove_passkey_last": "Der letzte Passkey kann nicht entfernt werden.",
|
"remove_passkey_last_title": "Letzter Passkey",
|
||||||
|
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||||
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||||
"remove_passkey_confirm_title": "Passkey entfernen?",
|
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||||
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||||
@@ -319,6 +320,31 @@
|
|||||||
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||||
"remove_pin_confirm_yes": "PIN entfernen",
|
"remove_pin_confirm_yes": "PIN entfernen",
|
||||||
"remove_pin_confirm_no": "Abbrechen",
|
"remove_pin_confirm_no": "Abbrechen",
|
||||||
|
"security_title": "Sicherheits-Checkliste",
|
||||||
|
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||||
|
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||||
|
"security_passkeys_missing": "Kein Passkey registriert",
|
||||||
|
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||||
|
"security_prf_missing": "PRF nicht eingerichtet",
|
||||||
|
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||||
|
"security_pin_missing": "Kein lokaler PIN",
|
||||||
|
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||||
|
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf — sie können nicht erneut angezeigt werden.",
|
||||||
|
"device_title": "Dieses Gerät",
|
||||||
|
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||||
|
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||||
|
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||||
|
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||||
|
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||||
|
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||||
|
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||||
|
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||||
|
"device_forget_confirm_yes": "Entfernen",
|
||||||
|
"passkey_label": "Name für neuen Passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Name speichern",
|
||||||
|
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||||
|
"passkey_unnamed": "Unbenannter Passkey",
|
||||||
"stats_title": "Statistiken",
|
"stats_title": "Statistiken",
|
||||||
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||||
"stats_logbooks": "Logbücher",
|
"stats_logbooks": "Logbücher",
|
||||||
@@ -398,6 +424,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||||
"delete_account_confirm_no": "Abbrechen",
|
"delete_account_confirm_no": "Abbrechen",
|
||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
"tour_title": "App-Tour",
|
"tour_title": "App-Tour",
|
||||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||||
|
|||||||
@@ -295,7 +295,8 @@
|
|||||||
"add_passkey_success": "Passkey added successfully.",
|
"add_passkey_success": "Passkey added successfully.",
|
||||||
"add_passkey_failed": "Could not add passkey.",
|
"add_passkey_failed": "Could not add passkey.",
|
||||||
"remove_passkey_btn": "Remove passkey",
|
"remove_passkey_btn": "Remove passkey",
|
||||||
"remove_passkey_last": "The last passkey cannot be removed.",
|
"remove_passkey_last_title": "Last passkey",
|
||||||
|
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||||
"remove_passkey_failed": "Could not remove passkey.",
|
"remove_passkey_failed": "Could not remove passkey.",
|
||||||
"remove_passkey_confirm_title": "Remove passkey?",
|
"remove_passkey_confirm_title": "Remove passkey?",
|
||||||
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||||
@@ -319,6 +320,31 @@
|
|||||||
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||||
"remove_pin_confirm_yes": "Remove PIN",
|
"remove_pin_confirm_yes": "Remove PIN",
|
||||||
"remove_pin_confirm_no": "Cancel",
|
"remove_pin_confirm_no": "Cancel",
|
||||||
|
"security_title": "Security checklist",
|
||||||
|
"security_desc": "Overview of the most important protections for your account.",
|
||||||
|
"security_passkeys_ok": "At least one passkey registered",
|
||||||
|
"security_passkeys_missing": "No passkey registered",
|
||||||
|
"security_prf_ok": "PRF key derivation active",
|
||||||
|
"security_prf_missing": "PRF not configured",
|
||||||
|
"security_pin_ok": "Local PIN on this device",
|
||||||
|
"security_pin_missing": "No local PIN",
|
||||||
|
"security_recovery_ok": "Recovery phrase configured",
|
||||||
|
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device — they cannot be shown again.",
|
||||||
|
"device_title": "This device",
|
||||||
|
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||||
|
"device_sync_pending": "{{count}} pending sync items",
|
||||||
|
"device_sync_ok": "All local changes synced",
|
||||||
|
"device_remembered": "Account saved for quick login on this device",
|
||||||
|
"device_not_remembered": "Account not in the quick-login list",
|
||||||
|
"device_forget_btn": "Forget account on this device",
|
||||||
|
"device_forget_confirm_title": "Remove quick login?",
|
||||||
|
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||||
|
"device_forget_confirm_yes": "Remove",
|
||||||
|
"passkey_label": "Name for new passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Save name",
|
||||||
|
"passkey_rename_success": "Passkey name saved.",
|
||||||
|
"passkey_unnamed": "Unnamed passkey",
|
||||||
"stats_title": "Statistics",
|
"stats_title": "Statistics",
|
||||||
"stats_subtitle": "Across all your logbooks on this device",
|
"stats_subtitle": "Across all your logbooks on this device",
|
||||||
"stats_logbooks": "Logbooks",
|
"stats_logbooks": "Logbooks",
|
||||||
@@ -398,6 +424,7 @@
|
|||||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||||
"delete_account_confirm_no": "Cancel",
|
"delete_account_confirm_no": "Cancel",
|
||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
"tour_title": "App tour",
|
"tour_title": "App tour",
|
||||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
|
|
||||||
export interface UserProfileCredential {
|
export interface UserProfileCredential {
|
||||||
id: string
|
id: string
|
||||||
|
label: string | null
|
||||||
credentialIdPreview: string
|
credentialIdPreview: string
|
||||||
transports: string[]
|
transports: string[]
|
||||||
}
|
}
|
||||||
@@ -579,7 +580,7 @@ async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuf
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPasskey(): Promise<void> {
|
export async function addPasskey(label?: string): Promise<void> {
|
||||||
await reauthWithPasskey()
|
await reauthWithPasskey()
|
||||||
|
|
||||||
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||||
@@ -613,7 +614,11 @@ export async function addPasskey(): Promise<void> {
|
|||||||
|
|
||||||
await apiJson(`${API_BASE}/add-credential-verify`, {
|
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ credentialResponse, challenge: options.challenge })
|
body: JSON.stringify({
|
||||||
|
credentialResponse,
|
||||||
|
challenge: options.challenge,
|
||||||
|
...(label?.trim() ? { label: label.trim() } : {})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const masterKey = getActiveMasterKey()
|
const masterKey = getActiveMasterKey()
|
||||||
@@ -639,3 +644,10 @@ export async function removePasskey(credentialDbId: string): Promise<void> {
|
|||||||
throw new Error(body.error || 'Failed to remove passkey')
|
throw new Error(body.error || 'Failed to remove passkey')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||||
|
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ label })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ model Credential {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
credentialId String @unique
|
credentialId String @unique
|
||||||
|
label String?
|
||||||
publicKey Bytes
|
publicKey Bytes
|
||||||
counter BigInt
|
counter BigInt
|
||||||
transports String[] // WebAuthn transports list
|
transports String[] // WebAuthn transports list
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ function previewCredentialId(credentialId: string): string {
|
|||||||
return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}`
|
return `${credentialId.slice(0, 8)}…${credentialId.slice(-8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCredentialLabel(label: unknown): string | null {
|
||||||
|
if (typeof label !== 'string') return null
|
||||||
|
const trimmed = label.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
return trimmed.slice(0, 64)
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -416,6 +423,7 @@ router.get('/profile', requireUser, async (req: any, res) => {
|
|||||||
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
|
hasPrfEncryption: user.encryptedMasterKeyPrf != null,
|
||||||
credentials: user.credentials.map((cred) => ({
|
credentials: user.credentials.map((cred) => ({
|
||||||
id: cred.id,
|
id: cred.id,
|
||||||
|
label: cred.label,
|
||||||
credentialIdPreview: previewCredentialId(cred.credentialId),
|
credentialIdPreview: previewCredentialId(cred.credentialId),
|
||||||
transports: cred.transports
|
transports: cred.transports
|
||||||
})),
|
})),
|
||||||
@@ -479,6 +487,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
|||||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = normalizeCredentialLabel(req.body.label)
|
||||||
|
|
||||||
const challengeUserId = addCredentialChallenges.get(challenge)
|
const challengeUserId = addCredentialChallenges.get(challenge)
|
||||||
if (!challengeUserId) {
|
if (!challengeUserId) {
|
||||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||||
@@ -524,6 +534,7 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
credentialId,
|
credentialId,
|
||||||
|
label,
|
||||||
publicKey: Buffer.from(credentialPublicKey),
|
publicKey: Buffer.from(credentialPublicKey),
|
||||||
counter: BigInt(counter),
|
counter: BigInt(counter),
|
||||||
transports: credentialResponse.response.transports || []
|
transports: credentialResponse.response.transports || []
|
||||||
@@ -534,6 +545,7 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
|||||||
verified: true,
|
verified: true,
|
||||||
credential: {
|
credential: {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
|
label: credential.label,
|
||||||
credentialIdPreview: previewCredentialId(credential.credentialId),
|
credentialIdPreview: previewCredentialId(credential.credentialId),
|
||||||
transports: credential.transports
|
transports: credential.transports
|
||||||
}
|
}
|
||||||
@@ -544,6 +556,38 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.patch('/credentials/:id', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const label = normalizeCredentialLabel(req.body?.label)
|
||||||
|
|
||||||
|
const credential = await prisma.credential.findUnique({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!credential || credential.userId !== req.userId) {
|
||||||
|
return res.status(404).json({ error: 'Credential not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.credential.update({
|
||||||
|
where: { id },
|
||||||
|
data: { label }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
credential: {
|
||||||
|
id: updated.id,
|
||||||
|
label: updated.label,
|
||||||
|
credentialIdPreview: previewCredentialId(updated.credentialId),
|
||||||
|
transports: updated.transports
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating credential label:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
|
|||||||
Reference in New Issue
Block a user