mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking
This commit is contained in:
@@ -398,6 +398,13 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!!(selectedSend.decNotes || '').trim() && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>Notes</h4>
|
||||||
|
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
FileKey2,
|
FileKey2,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderOpen,
|
|
||||||
FolderX,
|
FolderX,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -46,7 +45,13 @@ interface VaultPageProps {
|
|||||||
onCreateFolder: (name: string) => Promise<void>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
|
type SidebarFilter =
|
||||||
|
| { kind: 'all' }
|
||||||
|
| { kind: 'favorite' }
|
||||||
|
| { kind: 'trash' }
|
||||||
|
| { kind: 'type'; value: TypeFilter }
|
||||||
|
| { kind: 'folder'; folderId: string | null };
|
||||||
|
|
||||||
interface TypeOption {
|
interface TypeOption {
|
||||||
type: number;
|
type: number;
|
||||||
@@ -241,12 +246,6 @@ function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTypeFilter(cipher: Cipher, typeFilter: TypeFilter): boolean {
|
|
||||||
if (typeFilter === 'all') return true;
|
|
||||||
if (typeFilter === 'favorite') return !!cipher.favorite;
|
|
||||||
return cipherTypeKey(Number(cipher.type || 1)) === typeFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskSecret(value: string): string {
|
function maskSecret(value: string): string {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
|
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
|
||||||
@@ -257,6 +256,17 @@ function formatTotp(code: string): string {
|
|||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHistoryTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
|
const TOTP_RING_RADIUS = 14;
|
||||||
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const uri = firstCipherUri(cipher);
|
const uri = firstCipherUri(cipher);
|
||||||
const host = hostFromUri(uri);
|
const host = hostFromUri(uri);
|
||||||
@@ -295,8 +305,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchComposing, setSearchComposing] = useState(false);
|
const [searchComposing, setSearchComposing] = useState(false);
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [folderFilter, setFolderFilter] = useState<string>('all');
|
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -371,16 +380,28 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
return props.ciphers.filter((cipher) => {
|
return props.ciphers.filter((cipher) => {
|
||||||
if (!matchesTypeFilter(cipher, typeFilter)) return false;
|
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
||||||
if (folderFilter === 'none' && cipher.folderId) return false;
|
if (sidebarFilter.kind === 'trash') {
|
||||||
if (folderFilter !== 'none' && folderFilter !== 'all' && cipher.folderId !== folderFilter) return false;
|
if (!isDeleted) return false;
|
||||||
|
} else {
|
||||||
|
if (isDeleted) return false;
|
||||||
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||||
|
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
||||||
|
if (sidebarFilter.kind === 'folder') {
|
||||||
|
if (sidebarFilter.folderId === null) {
|
||||||
|
if (cipher.folderId) return false;
|
||||||
|
} else if (cipher.folderId !== sidebarFilter.folderId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const name = (cipher.decName || '').toLowerCase();
|
const name = (cipher.decName || '').toLowerCase();
|
||||||
const username = (cipher.login?.decUsername || '').toLowerCase();
|
const username = (cipher.login?.decUsername || '').toLowerCase();
|
||||||
const uri = firstCipherUri(cipher).toLowerCase();
|
const uri = firstCipherUri(cipher).toLowerCase();
|
||||||
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
||||||
});
|
});
|
||||||
}, [props.ciphers, folderFilter, typeFilter, searchQuery]);
|
}, [props.ciphers, sidebarFilter, searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
@@ -654,26 +675,32 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="vault-grid">
|
<div className="vault-grid">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
<div className="sidebar-title">Types</div>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
|
||||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">All Items</span>
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">All Items</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'favorite' ? 'active' : ''}`} onClick={() => setTypeFilter('favorite')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
|
||||||
<Star size={14} className="tree-icon" /> <span className="tree-label">Favorites</span>
|
<Star size={14} className="tree-icon" /> <span className="tree-label">Favorites</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'login' ? 'active' : ''}`} onClick={() => setTypeFilter('login')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
|
||||||
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">Trash</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">Type</div>
|
||||||
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'login' })}>
|
||||||
<Globe size={14} className="tree-icon" /> <span className="tree-label">Login</span>
|
<Globe size={14} className="tree-icon" /> <span className="tree-label">Login</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'card' ? 'active' : ''}`} onClick={() => setTypeFilter('card')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'card' })}>
|
||||||
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">Card</span>
|
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">Card</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'identity' ? 'active' : ''}`} onClick={() => setTypeFilter('identity')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'identity' })}>
|
||||||
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">Identity</span>
|
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">Identity</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'note' ? 'active' : ''}`} onClick={() => setTypeFilter('note')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'note' })}>
|
||||||
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">Note</span>
|
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">Note</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'ssh' ? 'active' : ''}`} onClick={() => setTypeFilter('ssh')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'ssh' })}>
|
||||||
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">SSH Key</span>
|
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">SSH Key</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -685,18 +712,15 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<FolderPlus size={14} />
|
<FolderPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={`tree-btn ${folderFilter === 'all' ? 'active' : ''}`} onClick={() => setFolderFilter('all')}>
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'folder', folderId: null })}>
|
||||||
<FolderOpen size={14} className="tree-icon" /> <span className="tree-label">All</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" className={`tree-btn ${folderFilter === 'none' ? 'active' : ''}`} onClick={() => setFolderFilter('none')}>
|
|
||||||
<FolderX size={14} className="tree-icon" /> <span className="tree-label">No Folder</span>
|
<FolderX size={14} className="tree-icon" /> <span className="tree-label">No Folder</span>
|
||||||
</button>
|
</button>
|
||||||
{props.folders.map((folder) => (
|
{props.folders.map((folder) => (
|
||||||
<button
|
<button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`tree-btn ${folderFilter === folder.id ? 'active' : ''}`}
|
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
|
||||||
onClick={() => setFolderFilter(folder.id)}
|
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
|
||||||
>
|
>
|
||||||
<FolderIcon size={14} className="tree-icon" />
|
<FolderIcon size={14} className="tree-icon" />
|
||||||
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
||||||
@@ -1112,8 +1136,33 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">TOTP</span>
|
<span className="kv-label">TOTP</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main">
|
||||||
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
|
<div className="totp-inline">
|
||||||
<span className="detail-sub">Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}</span>
|
<strong>{totpLive ? formatTotp(totpLive.code) : '------'}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
|
||||||
|
aria-label={`Refresh in ${totpLive ? totpLive.remain : 0}s`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||||
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
||||||
|
<circle
|
||||||
|
className="totp-ring-progress"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r={TOTP_RING_RADIUS}
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
|
strokeDashoffset: String(
|
||||||
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
|
TOTP_RING_CIRCUMFERENCE *
|
||||||
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{totpLive ? totpLive.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions">
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
|
||||||
@@ -1183,10 +1232,12 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card">
|
{!!(selectedCipher.decNotes || '').trim() && (
|
||||||
<h4>Notes</h4>
|
<div className="card">
|
||||||
<div className="notes">{selectedCipher.decNotes || ''}</div>
|
<h4>Notes</h4>
|
||||||
</div>
|
<div className="notes">{selectedCipher.decNotes || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -1244,6 +1295,14 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>项目历史记录</h4>
|
||||||
|
<div className="detail-sub">最后编辑于: {formatHistoryTime(selectedCipher.revisionDate)}</div>
|
||||||
|
<div className="detail-sub">创建于: {formatHistoryTime(selectedCipher.creationDate)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={startEdit}>
|
<button type="button" className="btn btn-secondary" onClick={startEdit}>
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export async function createFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
|
||||||
const resp = await authedFetch('/api/ciphers');
|
const resp = await authedFetch('/api/ciphers?deleted=true');
|
||||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
const body = await parseJson<ListResponse<Cipher>>(resp);
|
||||||
return body?.data || [];
|
return body?.data || [];
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export interface Cipher {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
|
creationDate?: string;
|
||||||
|
revisionDate?: string;
|
||||||
|
deletedDate?: string | null;
|
||||||
login?: CipherLogin | null;
|
login?: CipherLogin | null;
|
||||||
card?: CipherCard | null;
|
card?: CipherCard | null;
|
||||||
identity?: CipherIdentity | null;
|
identity?: CipherIdentity | null;
|
||||||
|
|||||||
+56
-4
@@ -27,6 +27,9 @@ body,
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
zoom: 1.25;
|
||||||
|
}
|
||||||
.loading-screen {
|
.loading-screen {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -268,8 +271,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
height: calc(100vh - 40px);
|
height: calc(80vh - 40px);
|
||||||
max-width: 1800px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background: #f5f7fb;
|
background: #f5f7fb;
|
||||||
border: 1px solid #d5dce7;
|
border: 1px solid #d5dce7;
|
||||||
@@ -404,7 +407,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.vault-grid {
|
.vault-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(420px, 46%) minmax(520px, 1fr);
|
grid-template-columns: 240px minmax(420px, 46%) minmax(575px, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -516,6 +519,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
max-width: 540px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -692,7 +696,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.kv-row {
|
.kv-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(88px, 140px) minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-bottom: 1px solid #ecf0f5;
|
border-bottom: 1px solid #ecf0f5;
|
||||||
@@ -723,6 +727,54 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-timer {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-ring {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-ring-track,
|
||||||
|
.totp-ring-progress {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-ring-track {
|
||||||
|
stroke: #d9e2ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-ring-progress {
|
||||||
|
stroke: #2563eb;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-timer-value {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.value-ellipsis {
|
.value-ellipsis {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user