Skip to content
36 changes: 19 additions & 17 deletions src/components/pages/manage-accounts/account-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,18 @@ const AccountListItem = ({
}
}}
aria-label={`${t('accounts.manage.menu')} ${account.name}`}
className={`flex w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-card/80 px-3 py-2 ${
className={`flex w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-card/80 pl-2 pr-3 py-2 ${
isOver ? 'ring-2 ring-primary/40' : ''
} ${isDragging ? 'opacity-60' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">
{account.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{account.watchOnly && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-700 dark:text-amber-200">
<EyeIcon className="h-2.5 w-2.5" />
Expand All @@ -93,18 +95,18 @@ const AccountListItem = ({
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">
{truncateString(account.identity, { leading: 5, trailing: 5 })}
</span>
<span className="text-[11px] font-semibold text-foreground">
{isVisible
? balance !== undefined
? formatBalanceCompact(balance)
: '--'
: HIDDEN_BALANCE}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="truncate">
{truncateString(account.identity, { leading: 5, trailing: 5 })}
</span>
<span className="text-[11px] font-semibold text-foreground">
{isVisible
? balance !== undefined
? formatBalanceCompact(balance)
: '--'
: HIDDEN_BALANCE}
</span>
</div>
</div>
<div className="flex items-center gap-1">
Expand Down
6 changes: 3 additions & 3 deletions src/components/ui/input-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
role="group"
className={cn(
'group/input-group border-input bg-muted/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
'h-12 min-w-0 has-[>textarea]:h-auto',

// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=inline-start]]:[&>input]:pl-3',
'has-[>[data-align=inline-end]]:[&>input]:pr-3',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',

Expand Down
4 changes: 4 additions & 0 deletions src/lib/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const MIN_LOCK_TIMEOUT_MINUTES = 0
const MAX_LOCK_TIMEOUT_MINUTES = 120
const ACTIVITY_TOUCH_THROTTLE_MS = 1_000
let lastActivityTouchAt = 0
let sessionUnlockedMemory = false

type ChromeStorageSession = {
get: (keys: string[], callback: (items: Record<string, unknown>) => void) => void
Expand All @@ -28,6 +29,7 @@ const getChromeSessionStorage = (): ChromeStorageSession | null => {
}

const setSessionUnlockedFlag = (value: boolean) => {
sessionUnlockedMemory = value
const sessionStorage = getChromeSessionStorage()
if (!sessionStorage) return

Expand Down Expand Up @@ -155,6 +157,8 @@ const normalizeLockTimeoutOption = (minutes: number) => {
}

const readSessionUnlockedFlag = async () => {
if (sessionUnlockedMemory) return true

const sessionStorage = getChromeSessionStorage()
if (!sessionStorage) return true

Expand Down
1 change: 1 addition & 0 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "Die ausgewählte Datei ist zu groß für eine gültige Tresor-Datei.",
"selectFile": "Wähle eine Tresor-Datei aus.",
"passphraseRequired": "Passwort ist erforderlich.",
"invalidSourcePassphrase": "Ungültiges Quell-Tresor-Passwort.",
"importFailed": "Web-Wallet-Tresor konnte nicht importiert werden.",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alez04 This key is not longer reference in the code. Please remove it here and from all 8 locale files (en, es, de, fr, ru, tr, vi, zh).

"noEntries": "Tresor importiert, aber keine Einträge gefunden.",
"generic": "Tresor konnte nicht importiert werden."
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "The selected file is too large to be a valid vault file.",
"selectFile": "Select a vault file.",
"passphraseRequired": "Password is required.",
"invalidSourcePassphrase": "Invalid source vault password.",
"importFailed": "Failed to import web wallet vault.",
"noEntries": "Vault imported but no entries were found.",
"generic": "Failed to import vault."
Expand Down
1 change: 1 addition & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "El archivo seleccionado es demasiado grande para ser un vault válido.",
"selectFile": "Selecciona un archivo vault.",
"passphraseRequired": "Se requiere contraseña.",
"invalidSourcePassphrase": "Contraseña del vault de origen inválida.",
"importFailed": "No se pudo importar el vault de la billetera web.",
"noEntries": "Vault importado pero no se encontraron entradas.",
"generic": "No se pudo importar el vault."
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "Le fichier sélectionné est trop volumineux pour être un fichier vault valide.",
"selectFile": "Sélectionnez un fichier vault.",
"passphraseRequired": "Le mot de passe est requis.",
"invalidSourcePassphrase": "Mot de passe du vault source invalide.",
"importFailed": "Échec de l'importation du vault du portefeuille web.",
"noEntries": "Vault importé mais aucune entrée trouvée.",
"generic": "Échec de l'importation du vault."
Expand Down
1 change: 1 addition & 0 deletions src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "Выбранный файл слишком большой для допустимого файла vault.",
"selectFile": "Выберите файл vault.",
"passphraseRequired": "Необходимо ввести пароль.",
"invalidSourcePassphrase": "Неверный пароль исходного vault.",
"importFailed": "Не удалось импортировать vault веб-кошелька.",
"noEntries": "Vault импортирован, но записи не найдены.",
"generic": "Не удалось импортировать vault."
Expand Down
1 change: 1 addition & 0 deletions src/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "Seçilen dosya geçerli bir vault dosyası için çok büyük.",
"selectFile": "Bir vault dosyası seçin.",
"passphraseRequired": "Parola gereklidir.",
"invalidSourcePassphrase": "Kaynak vault parolası geçersiz.",
"importFailed": "Web cüzdanı vault dosyası içe aktarılamadı.",
"noEntries": "Vault içe aktarıldı ancak hiçbir giriş bulunamadı.",
"generic": "Vault içe aktarılamadı."
Expand Down
1 change: 1 addition & 0 deletions src/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "Tập tin đã chọn quá lớn để là tập tin vault hợp lệ.",
"selectFile": "Chọn một tập tin vault.",
"passphraseRequired": "Yêu cầu mật khẩu.",
"invalidSourcePassphrase": "Mật khẩu vault nguồn không hợp lệ.",
"importFailed": "Không thể nhập vault từ ví web.",
"noEntries": "Đã nhập vault nhưng không tìm thấy mục nào.",
"generic": "Không thể nhập vault."
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@
"fileTooLarge": "所选文件过大,不是有效的 vault 文件。",
"selectFile": "请选择 vault 文件。",
"passphraseRequired": "请输入密码。",
"invalidSourcePassphrase": "来源 vault 密码无效。",
"importFailed": "导入网页钱包 vault 失败。",
"noEntries": "vault 已导入但未找到任何条目。",
"generic": "导入 vault 失败。"
Expand Down
9 changes: 9 additions & 0 deletions src/pages/onboarding/import-seed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ const ImportSeed = ({
setStatus(t('onboarding.errors.passphraseRequired'))
return
}
if (step === 2 && variant !== 'add-address' && passphrase.length < 12) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace 12 magic number with an app constant

setStatus(t('onboarding.errors.passphraseTooShort'))
return
}
if (step === 2 && variant === 'add-address') {
if (isAccountNameTaken(name)) {
setStatus(t('accounts.manage.errors.nameDuplicate'))
Expand Down Expand Up @@ -149,6 +153,11 @@ const ImportSeed = ({
setStep(2)
return
}
if (variant !== 'add-address' && passphrase.length < 12) {
setStatus(t('onboarding.errors.passphraseTooShort'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace 12 magic number with an app constant

setStep(2)
return
}

const cachedAccounts = getCachedAccounts()
if (isAccountNameTaken(name)) {
Expand Down
116 changes: 103 additions & 13 deletions src/pages/onboarding/import-vault.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useMemo, useState } from 'react'
import {
createMemoryVaultStore,
openSeedVaultBrowser,
VaultInvalidPassphraseError,
} from '@qubic-labs/sdk'
import { ArrowLeftIcon, ArrowRightIcon, FileJsonIcon, UploadCloudIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
Expand All @@ -15,6 +20,16 @@ import FlowHeader from '@/components/onboarding/flow-header'

const TOTAL_STEPS = 3
const MAX_VAULT_FILE_SIZE = 102_400
const WEB_WALLET_CONFIG_KEY = 'wallet-config'

const isWebWalletVaultFile = (fileText: string) => {
try {
const data = JSON.parse(fileText)
return !!(data.salt && data.iv && data.cipher)
} catch {
return false
}
}

const ImportVault = () => {
const navigate = useNavigate()
Expand All @@ -34,7 +49,51 @@ const ImportVault = () => {
setFile(null)
}

const handleNext = () => {
const validateSourceVault = async (
selectedFile: File,
nextPassphrase: string,
fileText: string,
) => {
const importPassphrase = sourcePassphrase.trim() || nextPassphrase

if (isWebWalletVaultFile(fileText)) {
const previousConfig = localStorage.getItem(WEB_WALLET_CONFIG_KEY)
try {
const qubicVault = new QubicVault()
await qubicVault.importAndUnlock(true, importPassphrase, null, selectedFile, false)
return { valid: true as const }
} catch {
return { valid: false as const, reason: 'invalid' as const }
} finally {
// QubicVault persists to localStorage while unlocking, so restore the prior state.
if (previousConfig === null) {
localStorage.removeItem(WEB_WALLET_CONFIG_KEY)
} else {
localStorage.setItem(WEB_WALLET_CONFIG_KEY, previousConfig)
}
}
}

try {
const previewVault = await openSeedVaultBrowser({
store: createMemoryVaultStore('import-preview'),
passphrase: nextPassphrase,
create: true,
})
await previewVault.importEncrypted(fileText, {
mode: 'merge',
sourcePassphrase: importPassphrase,
})
return { valid: true as const }
} catch (error) {
if (error instanceof VaultInvalidPassphraseError) {
return { valid: false as const, reason: 'invalid' as const }
}
return { valid: false as const, reason: 'error' as const }
}
}

const handleNext = async () => {
setStatus(null)

if (step === 1 && !file) {
Expand All @@ -46,6 +105,31 @@ const ImportVault = () => {
setStatus(t('onboarding.importVault.errors.passphraseRequired'))
return
}
if (step === 2 && passphrase.length < 12) {
setStatus(t('onboarding.errors.passphraseTooShort'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace 12 magic number by an app constant

return
}
if (step === 2 && file) {
setIsSaving(true)
try {
const fileText = await file.text()
const result = await validateSourceVault(file, passphrase.trim(), fileText)
if (!result.valid) {
setStatus(
result.reason === 'invalid'
? t('onboarding.importVault.errors.invalidSourcePassphrase')
: t('onboarding.importVault.errors.generic'),
)
setIsSaving(false)
return
}
} catch {
setStatus(t('onboarding.importVault.errors.generic'))
setIsSaving(false)
return
}
setIsSaving(false)
}

setStep((current) => Math.min(current + 1, TOTAL_STEPS))
}
Expand Down Expand Up @@ -91,26 +175,26 @@ const ImportVault = () => {
setStep(2)
return
}
if (passphrase.length < 12) {
setStatus(t('onboarding.errors.passphraseTooShort'))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace 12 magic number by an app constant

setStep(2)
return
}

try {
setIsSaving(true)

const fileText = await file.text()
let isWebWalletVault = false
try {
const data = JSON.parse(fileText)
isWebWalletVault = !!(data.salt && data.iv && data.cipher)
} catch {
// Not valid JSON — treat as SDK format
}
const isWebWalletVault = isWebWalletVaultFile(fileText)

if (isWebWalletVault) {
const qubicVault = new QubicVault()
const importPassphrase = sourcePassphrase.trim() || passphrase.trim()

const success = await qubicVault.importAndUnlock(true, importPassphrase, null, file, false)
if (!success) {
setStatus(t('onboarding.importVault.errors.importFailed'))
try {
await qubicVault.importAndUnlock(true, importPassphrase, null, file, false)
} catch {
setStatus(t('onboarding.importVault.errors.invalidSourcePassphrase'))
setIsSaving(false)
return
}
Expand Down Expand Up @@ -178,7 +262,13 @@ const ImportVault = () => {
navigate('/home')
}
} catch (error) {
setStatus(error instanceof Error ? error.message : t('onboarding.importVault.errors.generic'))
setStatus(
error instanceof VaultInvalidPassphraseError
? t('onboarding.importVault.errors.invalidSourcePassphrase')
: error instanceof Error
? error.message
: t('onboarding.importVault.errors.generic'),
)
setIsSaving(false)
}
}
Expand Down Expand Up @@ -318,7 +408,7 @@ const ImportVault = () => {
{step === 1 ? t('common.back') : t('common.previous')}
</Button>
{step < TOTAL_STEPS ? (
<Button size="lg" onClick={handleNext} className="flex-1">
<Button size="lg" onClick={handleNext} className="flex-1" disabled={isSaving}>
{t('common.continue')}
<ArrowRightIcon className="h-5 w-5" />
</Button>
Expand Down
3 changes: 2 additions & 1 deletion src/router/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const AppRouter = () => {
const location = useLocation()
const isOnboarded = hasAccounts()
const [isLocked, setIsLocked] = useState(() => isWalletLocked())
const currentlyLocked = isOnboarded ? isWalletLocked() : isLocked
const lockTimeoutRef = useRef<number | null>(null)
const hideChrome = location.pathname === '/unlock'
const routeKey = `${location.pathname}${location.search}`
Expand Down Expand Up @@ -212,7 +213,7 @@ const AppRouter = () => {
)
}

if (isLocked && location.pathname !== '/unlock') {
if (currentlyLocked && location.pathname !== '/unlock') {
return <Navigate to="/unlock" replace />
}

Expand Down
Loading