mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add TOTP codes page and related components for displaying verification codes
This commit is contained in:
@@ -142,6 +142,17 @@ function handleNwFavicon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BITWARDEN_DEFAULT_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
return bytesToHex(new Uint8Array(digest));
|
||||||
|
}
|
||||||
|
|
||||||
function isValidIconHostname(hostname: string): boolean {
|
function isValidIconHostname(hostname: string): boolean {
|
||||||
if (!hostname) return false;
|
if (!hostname) return false;
|
||||||
if (hostname.length > 253) return false;
|
if (hostname.length > 253) return false;
|
||||||
@@ -192,6 +203,9 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom
|
|||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const body = await resp.arrayBuffer();
|
const body = await resp.arrayBuffer();
|
||||||
|
if (body.byteLength === 500 && (await sha256Hex(body)) === BITWARDEN_DEFAULT_ICON_SHA256) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
const iconResponse = new Response(body, {
|
const iconResponse = new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
+10
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ArrowUpDown, Cloud, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, Clock3, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import ToastHost from '@/components/ToastHost';
|
import ToastHost from '@/components/ToastHost';
|
||||||
@@ -15,6 +15,7 @@ import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
|||||||
import AdminPage from '@/components/AdminPage';
|
import AdminPage from '@/components/AdminPage';
|
||||||
import HelpPage from '@/components/HelpPage';
|
import HelpPage from '@/components/HelpPage';
|
||||||
import ImportPage from '@/components/ImportPage';
|
import ImportPage from '@/components/ImportPage';
|
||||||
|
import TotpCodesPage from '@/components/TotpCodesPage';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import {
|
import {
|
||||||
changeMasterPassword,
|
changeMasterPassword,
|
||||||
@@ -1669,9 +1670,13 @@ export default function App() {
|
|||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<aside className="app-side">
|
<aside className="app-side">
|
||||||
<Link href="/vault" className={`side-link ${location === '/vault' ? 'active' : ''}`}>
|
<Link href="/vault" className={`side-link ${location === '/vault' ? 'active' : ''}`}>
|
||||||
<Vault size={16} />
|
<KeyRound size={16} />
|
||||||
<span>{t('nav_my_vault')}</span>
|
<span>{t('nav_my_vault')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/vault/totp" className={`side-link ${location === '/vault/totp' ? 'active' : ''}`}>
|
||||||
|
<Clock3 size={16} />
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
</Link>
|
||||||
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
|
<Link href="/sends" className={`side-link ${location === '/sends' ? 'active' : ''}`}>
|
||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
@@ -1713,6 +1718,9 @@ export default function App() {
|
|||||||
onNotify={pushToast}
|
onNotify={pushToast}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/vault/totp">
|
||||||
|
<TotpCodesPage ciphers={decryptedCiphers} loading={ciphersQuery.isFetching} onNotify={pushToast} />
|
||||||
|
</Route>
|
||||||
<Route path="/vault">
|
<Route path="/vault">
|
||||||
<VaultPage
|
<VaultPage
|
||||||
ciphers={decryptedCiphers}
|
ciphers={decryptedCiphers}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
|
||||||
|
interface TotpCodesPageProps {
|
||||||
|
ciphers: Cipher[];
|
||||||
|
loading: boolean;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
|
const TOTP_RING_RADIUS = 14;
|
||||||
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
|
function formatTotp(code: string): string {
|
||||||
|
if (!code || code.length < 6) return code;
|
||||||
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCipherUri(cipher: Cipher): string {
|
||||||
|
const uris = cipher.login?.uris || [];
|
||||||
|
for (const uri of uris) {
|
||||||
|
const raw = uri.decUri || uri.uri || '';
|
||||||
|
if (raw.trim()) return raw.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromUri(uri: string): string {
|
||||||
|
if (!uri.trim()) return '';
|
||||||
|
try {
|
||||||
|
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||||
|
return new URL(normalized).hostname || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
const uri = firstCipherUri(cipher);
|
||||||
|
const host = hostFromUri(uri);
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
|
||||||
|
if (host && !errored) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="list-icon"
|
||||||
|
src={`/icons/${host}/icon.png?v=2`}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<Globe size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
|
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||||
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
props.onNotify('success', t('txt_code_copied'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totpItems = useMemo(
|
||||||
|
() =>
|
||||||
|
props.ciphers
|
||||||
|
.filter((cipher) => {
|
||||||
|
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||||
|
return !isDeleted && !!cipher.login?.decTotp;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||||
|
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
}),
|
||||||
|
[props.ciphers]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!totpItems.length) {
|
||||||
|
setTotpMap({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stopped = false;
|
||||||
|
let timer = 0;
|
||||||
|
const tick = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
totpItems.map(async (cipher) => {
|
||||||
|
try {
|
||||||
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
|
return [cipher.id, next] as const;
|
||||||
|
} catch {
|
||||||
|
return [cipher.id, null] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!stopped) setTotpMap(Object.fromEntries(entries));
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
timer = window.setInterval(() => void tick(), 1000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [totpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = listRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const gap = 10;
|
||||||
|
const minCardWidth = 320;
|
||||||
|
const maxColumns = 4;
|
||||||
|
|
||||||
|
const updateColumns = () => {
|
||||||
|
const width = element.clientWidth;
|
||||||
|
if (!width) return;
|
||||||
|
const next = Math.max(1, Math.min(maxColumns, Math.floor((width + gap) / (minCardWidth + gap))));
|
||||||
|
setColumnCount(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateColumns();
|
||||||
|
const observer = new ResizeObserver(() => updateColumns());
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="totp-codes-page">
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3 className="detail-title">{t('txt_verification_code')}</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="totp-codes-list"
|
||||||
|
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||||
|
>
|
||||||
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
|
{totpItems.map((cipher) => {
|
||||||
|
const live = totpMap[cipher.id] || null;
|
||||||
|
const name = cipher.decName || cipher.name || t('txt_no_name');
|
||||||
|
const username = cipher.login?.decUsername || '';
|
||||||
|
return (
|
||||||
|
<div key={cipher.id} className="totp-code-row">
|
||||||
|
<div className="totp-code-info">
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<TotpListIcon cipher={cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-meta">
|
||||||
|
<div className="totp-code-name" title={name}>{name}</div>
|
||||||
|
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-main">
|
||||||
|
<strong>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
||||||
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
|
||||||
|
>
|
||||||
|
<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, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{live ? live.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -314,7 +314,7 @@ function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="list-icon"
|
className="list-icon"
|
||||||
src={`/icons/${host}/icon.png`}
|
src={`/icons/${host}/icon.png?v=2`}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setErrored(true)}
|
onError={() => setErrored(true)}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_confirm_master_password: "Confirm Master Password",
|
txt_confirm_master_password: "Confirm Master Password",
|
||||||
txt_confirm_password: "Confirm Password",
|
txt_confirm_password: "Confirm Password",
|
||||||
txt_copy: "Copy",
|
txt_copy: "Copy",
|
||||||
|
txt_code_copied: "Code copied",
|
||||||
txt_copy_code: "Copy Code",
|
txt_copy_code: "Copy Code",
|
||||||
txt_copy_link: "Copy Link",
|
txt_copy_link: "Copy Link",
|
||||||
txt_copy_secret: "Copy Secret",
|
txt_copy_secret: "Copy Secret",
|
||||||
@@ -229,6 +230,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_no_devices_found: "No devices found.",
|
txt_no_devices_found: "No devices found.",
|
||||||
txt_no_folder: "No Folder",
|
txt_no_folder: "No Folder",
|
||||||
txt_no_items: "No items",
|
txt_no_items: "No items",
|
||||||
|
txt_no_username: "(No username)",
|
||||||
|
txt_no_verification_codes: "No verification codes",
|
||||||
txt_no_name: "(No Name)",
|
txt_no_name: "(No Name)",
|
||||||
txt_no_sends: "No sends",
|
txt_no_sends: "No sends",
|
||||||
txt_nodewarden_send: "NodeWarden Send",
|
txt_nodewarden_send: "NodeWarden Send",
|
||||||
@@ -413,6 +416,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_confirm: '确认',
|
txt_confirm: '确认',
|
||||||
txt_move: '移动',
|
txt_move: '移动',
|
||||||
txt_copy: '复制',
|
txt_copy: '复制',
|
||||||
|
txt_code_copied: '验证码已复制',
|
||||||
txt_copy_link: '复制链接',
|
txt_copy_link: '复制链接',
|
||||||
txt_select_all: '全选',
|
txt_select_all: '全选',
|
||||||
txt_delete_selected: '删除所选',
|
txt_delete_selected: '删除所选',
|
||||||
@@ -423,6 +427,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_folders: '文件夹',
|
txt_folders: '文件夹',
|
||||||
txt_no_folder: '无文件夹',
|
txt_no_folder: '无文件夹',
|
||||||
txt_no_items: '没有项目',
|
txt_no_items: '没有项目',
|
||||||
|
txt_no_username: '无用户名',
|
||||||
|
txt_no_verification_codes: '没有验证码',
|
||||||
txt_no_sends: '没有发送',
|
txt_no_sends: '没有发送',
|
||||||
txt_select_an_item: '请选择一个项目',
|
txt_select_an_item: '请选择一个项目',
|
||||||
txt_login: '登录',
|
txt_login: '登录',
|
||||||
|
|||||||
+93
-5
@@ -324,11 +324,6 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.small {
|
|
||||||
height: 34px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
@@ -914,6 +909,88 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-codes-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-codes-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(var(--totp-columns, 1), minmax(320px, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-main strong {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-name,
|
||||||
|
.totp-code-username {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-code-username {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totp-copy-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.value-ellipsis {
|
.value-ellipsis {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1559,9 +1636,16 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
border-bottom: 1px solid #d9e0ea;
|
border-bottom: 1px solid #d9e0ea;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
align-self: start;
|
||||||
|
height: fit-content;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-side > .side-link {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.side-spacer {
|
.side-spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1583,6 +1667,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-copy-btn {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.import-export-feature-grid,
|
.import-export-feature-grid,
|
||||||
.import-export-panels {
|
.import-export-panels {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user