app / src /components /accounts /DeviceFingerprintDialog.tsx
AZILS's picture
Upload 323 files
a21c316 verified
import { createPortal } from 'react-dom';
import { useEffect, useState } from 'react';
import { Wand2, RotateCcw, FolderOpen, Trash2, X } from 'lucide-react';
import { Account, DeviceProfile, DeviceProfileVersion } from '../../types/account';
import * as accountService from '../../services/accountService';
import { useTranslation } from 'react-i18next';
import { isTauri } from '../../utils/env';
interface DeviceFingerprintDialogProps {
account: Account | null;
onClose: () => void;
}
export default function DeviceFingerprintDialog({ account, onClose }: DeviceFingerprintDialogProps) {
const { t } = useTranslation();
const [deviceProfiles, setDeviceProfiles] = useState<{ current_storage?: DeviceProfile; history?: DeviceProfileVersion[]; baseline?: DeviceProfile } | null>(null);
const [loadingDevice, setLoadingDevice] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [confirmProfile, setConfirmProfile] = useState<DeviceProfile | null>(null);
const [confirmType, setConfirmType] = useState<'generate' | 'restoreOriginal' | null>(null);
const fetchDevice = async (target?: Account | null) => {
if (!target) {
setDeviceProfiles(null);
return;
}
setLoadingDevice(true);
try {
const res = await accountService.getDeviceProfiles(target.id);
setDeviceProfiles(res);
} catch (e: any) {
const errorMsg = typeof e === 'string' ? e : e.message || '';
const translated = errorMsg === 'storage_json_not_found'
? t('accounts.device_fingerprint_dialog.storage_json_not_found')
: (typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.failed_to_load_device_info'));
setActionMessage(translated);
} finally {
setLoadingDevice(false);
}
};
useEffect(() => {
fetchDevice(account);
}, [account]);
const handleGeneratePreview = async () => {
setActionLoading('preview');
try {
const profile = await accountService.previewGenerateProfile();
setConfirmProfile(profile);
setConfirmType('generate');
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.generation_failed'));
} finally {
setActionLoading(null);
}
};
const handleConfirmGenerate = async () => {
if (!account || !confirmProfile) return;
setActionLoading('generate');
try {
await accountService.bindDeviceProfileWithProfile(account.id, confirmProfile);
setActionMessage(t('accounts.device_fingerprint_dialog.generated_and_bound'));
setConfirmProfile(null);
setConfirmType(null);
await fetchDevice(account); // Refresh history
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.binding_failed'));
} finally {
setActionLoading(null);
}
};
const handleRestoreOriginalConfirm = () => {
if (!deviceProfiles?.baseline) {
setActionMessage(t('accounts.device_fingerprint_dialog.original_fingerprint_not_found'));
return;
}
setConfirmProfile(deviceProfiles.baseline);
setConfirmType('restoreOriginal');
};
const handleRestoreOriginal = async () => {
if (!account) return;
setActionLoading('restore');
try {
const msg = await accountService.restoreOriginalDevice();
setActionMessage(msg || t('accounts.device_fingerprint_dialog.restored'));
setConfirmProfile(null);
setConfirmType(null);
await fetchDevice(account);
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.restoration_failed'));
} finally {
setActionLoading(null);
}
};
const handleRestoreVersion = async (versionId: string) => {
if (!account) return;
setActionLoading(`restore-${versionId}`);
try {
await accountService.restoreDeviceVersion(account.id, versionId);
setActionMessage(t('accounts.device_fingerprint_dialog.restored'));
await fetchDevice(account);
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.restoration_failed'));
} finally {
setActionLoading(null);
}
};
const handleDeleteVersion = async (versionId: string, isCurrent?: boolean) => {
if (!account || isCurrent) return;
setActionLoading(`delete-${versionId}`);
try {
await accountService.deleteDeviceVersion(account.id, versionId);
setActionMessage(t('accounts.device_fingerprint_dialog.deleted'));
await fetchDevice(account);
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.deletion_failed'));
} finally {
setActionLoading(null);
}
};
const handleOpenFolder = async () => {
setActionLoading('open-folder');
try {
await accountService.openDeviceFolder();
setActionMessage(t('accounts.device_fingerprint_dialog.directory_opened'));
} catch (e: any) {
setActionMessage(typeof e === 'string' ? e : t('accounts.device_fingerprint_dialog.directory_open_failed'));
} finally {
setActionLoading(null);
}
};
const renderProfile = (profile?: DeviceProfile) => {
if (!profile) return <span className="text-xs text-gray-400">{t('common.empty') || '空'}</span>;
return (
<div className="grid grid-cols-1 gap-2 text-xs font-mono text-gray-600 dark:text-gray-300">
<div><span className="font-semibold">machineId:</span> {profile.machine_id}</div>
<div><span className="font-semibold">macMachineId:</span> {profile.mac_machine_id}</div>
<div><span className="font-semibold">devDeviceId:</span> {profile.dev_device_id}</div>
<div><span className="font-semibold">sqmId:</span> {profile.sqm_id}</div>
</div>
);
};
if (!account) return null;
return createPortal(
<div className="modal modal-open z-[120]">
<div data-tauri-drag-region className="fixed top-0 left-0 right-0 h-8 z-[130]" />
<div className="modal-box relative max-w-3xl bg-white dark:bg-base-100 shadow-2xl rounded-2xl p-0 overflow-hidden">
<div className="px-6 py-5 border-b border-gray-100 dark:border-base-200 bg-gray-50/50 dark:bg-base-200/50 flex justify-between items-center">
<div className="flex items-center gap-3">
<h3 className="font-bold text-lg text-gray-900 dark:text-base-content">{t('accounts.device_fingerprint_dialog.title')}</h3>
<div className="px-2.5 py-0.5 rounded-full bg-gray-100 dark:bg-base-200 border border-gray-200 dark:border-base-300 text-xs font-mono text-gray-500 dark:text-gray-400">
{account.email}
</div>
</div>
<button
onClick={onClose}
className="btn btn-sm btn-circle btn-ghost text-gray-400 hover:bg-gray-100 dark:hover:bg-base-200 hover:text-gray-600 dark:hover:text-base-content transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 space-y-3 max-h-[70vh] overflow-y-auto">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-200">{t('accounts.device_fingerprint_dialog.operations')}</div>
<div className="flex gap-2 flex-wrap">
<button className="btn btn-xs btn-outline" disabled={loadingDevice || actionLoading === 'preview'} onClick={handleGeneratePreview}>
<Wand2 size={14} className="mr-1" />{t('accounts.device_fingerprint_dialog.generate_and_bind')}
</button>
<button className="btn btn-xs btn-outline btn-error" disabled={loadingDevice || actionLoading === 'restore'} onClick={handleRestoreOriginalConfirm}>
<RotateCcw size={14} className="mr-1" />{t('accounts.device_fingerprint_dialog.restore_original')}
</button>
{isTauri() && (
<button className="btn btn-xs btn-outline" disabled={actionLoading === 'open-folder'} onClick={handleOpenFolder}>
<FolderOpen size={14} className="mr-1" />{t('accounts.device_fingerprint_dialog.open_storage_directory')}
</button>
)}
</div>
</div>
{actionMessage && <div className="text-xs text-blue-600 dark:text-blue-300">{actionMessage}</div>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100 shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t('accounts.device_fingerprint_dialog.current_storage')}</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300 border border-blue-100 dark:border-blue-400/40">{t('accounts.device_fingerprint_dialog.effective')}</span>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mb-2">{t('accounts.device_fingerprint_dialog.current_storage_desc')}</p>
{loadingDevice ? <div className="text-xs text-gray-400">{t('accounts.device_fingerprint_dialog.loading')}</div> : renderProfile(deviceProfiles?.current_storage)}
</div>
<div className="p-4 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100 shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="text-xs font-semibold text-gray-600 dark:text-gray-300">{t('accounts.device_fingerprint_dialog.account_binding')}</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300 border border-amber-100 dark:border-amber-400/40">{t('accounts.device_fingerprint_dialog.pending_application')}</span>
</div>
<p className="text-[10px] text-gray-400 dark:text-gray-500 mb-2">{t('accounts.device_fingerprint_dialog.account_binding_desc')}</p>
{/* Bound fingerprint = the one with is_current in current history */}
{loadingDevice ? (
<div className="text-xs text-gray-400">{t('accounts.device_fingerprint_dialog.loading')}</div>
) : (
renderProfile(deviceProfiles?.history?.find(h => h.is_current)?.profile)
)}
</div>
</div>
<div className="p-3 rounded-xl border border-gray-100 dark:border-base-200 bg-white dark:bg-base-100">
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-2">{t('accounts.device_fingerprint_dialog.historical_fingerprints')}</div>
{loadingDevice ? (
<div className="text-xs text-gray-400">{t('accounts.device_fingerprint_dialog.loading')}</div>
) : (
<div className="space-y-2">
{deviceProfiles?.history && deviceProfiles.history.map(v => (
<HistoryRow
id={v.id}
key={v.id}
label={v.label || v.id}
createdAt={v.created_at}
profile={v.profile}
isCurrent={v.is_current}
onRestore={() => handleRestoreVersion(v.id)}
onDelete={() => handleDeleteVersion(v.id, v.is_current)}
loadingKey={actionLoading}
/>
))}
{(!deviceProfiles?.history || deviceProfiles.history.length === 0) && !deviceProfiles?.baseline && (
<div className="text-xs text-gray-400">{t('accounts.device_fingerprint_dialog.no_history')}</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="modal-backdrop bg-black/40 backdrop-blur-sm" onClick={onClose}></div>
{confirmProfile && confirmType && (
<ConfirmDialog
profile={confirmProfile}
type={confirmType}
onCancel={() => {
if (actionLoading) return;
setConfirmProfile(null);
setConfirmType(null);
}}
onConfirm={confirmType === 'generate' ? handleConfirmGenerate : handleRestoreOriginal}
loading={!!actionLoading}
/>
)}
</div>,
document.body
);
}
interface HistoryRowProps {
id?: string;
label: string;
createdAt: number;
profile: DeviceProfile;
onRestore: () => void;
onDelete?: () => void;
isCurrent?: boolean;
loadingKey?: string | null;
}
function HistoryRow({ id, label, createdAt, profile, onRestore, onDelete, isCurrent, loadingKey }: HistoryRowProps) {
const { t } = useTranslation();
const key = id || label;
return (
<div className="flex items-start justify-between p-2 rounded-lg border border-gray-100 dark:border-base-200 hover:border-indigo-200 dark:hover:border-indigo-500/40 transition-colors">
<div className="text-[11px] text-gray-600 dark:text-gray-300 flex-1">
<div className="font-semibold">{label}{isCurrent && <span className="ml-2 text-[10px] text-blue-500">{t('accounts.device_fingerprint_dialog.current')}</span>}</div>
{createdAt > 0 && <div className="text-[10px] text-gray-400">{new Date(createdAt * 1000).toLocaleString()}</div>}
<div className="mt-1 text-[10px] font-mono text-gray-500">
<div>machineId: {profile.machine_id}</div>
<div>macMachineId: {profile.mac_machine_id}</div>
<div>devDeviceId: {profile.dev_device_id}</div>
<div>sqmId: {profile.sqm_id}</div>
</div>
</div>
<div className="flex gap-2 ml-2">
<button className="btn btn-xs btn-outline" disabled={loadingKey === `restore-${key}` || isCurrent} onClick={onRestore} title={t('accounts.device_fingerprint_dialog.restore')}>{t('accounts.device_fingerprint_dialog.restore')}</button>
{!isCurrent && onDelete && (
<button className="btn btn-xs btn-outline btn-error" disabled={loadingKey === `delete-${key}`} onClick={onDelete} title={t('accounts.device_fingerprint_dialog.delete_version')}>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
}
function ConfirmDialog({ profile, type, onConfirm, onCancel, loading }: { profile: DeviceProfile; type: 'generate' | 'restoreOriginal'; onConfirm: () => void; onCancel: () => void; loading?: boolean }) {
const { t } = useTranslation();
const title = type === 'generate' ? t('accounts.device_fingerprint_dialog.confirm_generate_title') : t('accounts.device_fingerprint_dialog.confirm_restore_title');
const desc =
type === 'generate'
? t('accounts.device_fingerprint_dialog.confirm_generate_desc')
: t('accounts.device_fingerprint_dialog.confirm_restore_desc');
return createPortal(
<div className="modal modal-open z-[140]">
<div className="modal-box max-w-sm bg-white dark:bg-base-100 rounded-2xl shadow-2xl p-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-500 dark:bg-blue-500/10 dark:text-blue-300">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 9v4" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 17h.01" strokeLinecap="round" strokeLinejoin="round" />
<path d="M10 2h4l8 8v4l-8 8h-4l-8-8v-4z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="font-bold text-lg text-gray-900 dark:text-base-content mb-1">{title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{desc}</p>
<div className="text-xs font-mono text-gray-600 dark:text-gray-300 bg-gray-50 dark:bg-base-200/60 border border-gray-100 dark:border-base-200 rounded-lg p-3 text-left space-y-1">
<div><span className="font-semibold">machineId:</span> {profile.machine_id}</div>
<div><span className="font-semibold">macMachineId:</span> {profile.mac_machine_id}</div>
<div><span className="font-semibold">devDeviceId:</span> {profile.dev_device_id}</div>
<div><span className="font-semibold">sqmId:</span> {profile.sqm_id}</div>
</div>
<div className="mt-5 flex gap-3 justify-center">
<button className="btn btn-sm min-w-[100px]" onClick={onCancel} disabled={!!loading}>{t('accounts.device_fingerprint_dialog.cancel')}</button>
<button className="btn btn-sm btn-primary min-w-[100px]" onClick={onConfirm} disabled={!!loading}>{loading ? t('accounts.device_fingerprint_dialog.processing') : t('accounts.device_fingerprint_dialog.confirm')}</button>
</div>
</div>
<div className="modal-backdrop bg-black/30" onClick={onCancel}></div>
</div>,
document.body
);
}