This commit is contained in:
Manchik
2026-04-25 04:17:13 +03:00
parent c82866b58a
commit 9b7001407f
23 changed files with 1225 additions and 258 deletions
+7
View File
@@ -60,6 +60,13 @@ export const api = {
request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }),
generate2FAByAccount: (account_id: number) =>
request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }),
getConfirmations: (account_id: number) =>
request<{ success: boolean; confirmations: import("./types").SteamConfirmation[] }>(`/actions/confirmations/${account_id}`),
respondConfirmations: (account_id: number, ids: string[], nonces: string[], accept: boolean) =>
request<{ success: boolean }>(`/actions/confirmations/${account_id}/respond`, {
method: "POST",
body: JSON.stringify({ ids, nonces, accept }),
}),
// Proxies
getProxies: () => request<Proxy[]>("/proxies"),
+13
View File
@@ -258,3 +258,16 @@ export interface Toast {
type: ToastType;
message: string;
}
export interface SteamConfirmation {
id: string;
nonce: string;
type: number;
type_name: string;
creator_id: string;
headline: string;
summary: string[];
accept: string;
cancel: string;
icon: string;
}
@@ -5,7 +5,9 @@ import { api } from "@/api/client";
import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore";
import { IconZap } from "@/components/shared/Icons";
import { copyText } from "@/lib/clipboard";
import { useT } from "@/lib/i18n";
import { ConfirmationsModal } from "./ConfirmationsModal";
const ROW_ACTION_KEYS = [
{ action: "validate", labelKey: "action.validate" },
@@ -43,6 +45,7 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
const [paramValue, setParamValue] = useState("");
const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null);
const [showConfirmations, setShowConfirmations] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
@@ -107,6 +110,26 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
const handleGenerate2FA = async () => {
setOpen(false);
if (!account.shared_secret) {
addToast("error", t("twofa.error", { error: "No shared_secret" }));
return;
}
try {
const { code } = await api.generate2FA(account.shared_secret);
await copyText(code);
addToast("success", t("twofa.copied", { code }));
} catch (e: unknown) {
addToast("error", t("twofa.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleOpenConfirmations = () => {
setOpen(false);
setShowConfirmations(true);
};
const handleRemoveProxy = async () => {
setOpen(false);
try {
@@ -159,6 +182,21 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
);
})}
<hr className="border-dark-600 my-1" />
<button
onClick={handleGenerate2FA}
disabled={!account.shared_secret}
className={`action-menu-item ${!account.shared_secret ? "opacity-40 cursor-not-allowed" : ""}`}
>
{t("action.generate2fa")}
</button>
<button
onClick={handleOpenConfirmations}
disabled={!account.identity_secret}
className={`action-menu-item ${!account.identity_secret ? "opacity-40 cursor-not-allowed" : ""}`}
>
{t("action.confirmations")}
</button>
<hr className="border-dark-600 my-1" />
<button onClick={handleRemoveProxy} className="action-menu-item">
{t("proxy.removeProxy")}
</button>
@@ -214,6 +252,10 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
</div>
</div>
)}
{showConfirmations && (
<ConfirmationsModal account={account} onClose={() => setShowConfirmations(false)} />
)}
</>
);
}
+93 -12
View File
@@ -1,5 +1,9 @@
import { useState } from "react";
import { useState, useRef } from "react";
import type { AccountCreate } from "@/api/types";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useAccountStore } from "@/stores/accountStore";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
@@ -9,19 +13,52 @@ interface Props {
export function AddModal({ onSave, onClose }: Props) {
const t = useT();
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [emailPassword, setEmailPassword] = useState("");
const [proxy, setProxy] = useState("");
const [notes, setNotes] = useState("");
const [mafile, setMafile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
const parseLoginPass = (value: string) => {
const sep = value.includes("|") ? "|" : ":";
const parts = value.split(sep);
if (parts.length >= 2) {
setLogin(parts[0]);
setPassword(parts[1]);
if (parts.length >= 3) setEmail(parts[2]);
if (parts.length >= 4) setEmailPassword(parts[3]);
} else {
setLogin(value);
}
};
const handleSave = async () => {
if (!login || !password) return;
onSave({
login,
password,
email: email || undefined,
proxy: proxy || undefined,
});
setSaving(true);
try {
onSave({
login,
password,
email: email || undefined,
email_password: emailPassword || undefined,
proxy: proxy || undefined,
notes: notes || undefined,
});
if (mafile) {
await api.uploadMafiles([mafile]);
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
return (
@@ -29,11 +66,55 @@ export function AddModal({ onSave, onClose }: Props) {
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3">
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input
autoFocus
value={login}
onChange={(e) => setLogin(e.target.value)}
onPaste={(e) => {
const text = e.clipboardData.getData("text").trim();
if (text.includes(":") || text.includes("|")) {
e.preventDefault();
parseLoginPass(text);
}
}}
placeholder={t("ph.login") + " (login:pass)"}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
<div className="grid grid-cols-2 gap-2">
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder={t("ph.emailPassword")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notesOptional")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
</div>
<div
onClick={() => fileRef.current?.click()}
className="border border-dashed border-dark-500 rounded p-3 text-center cursor-pointer transition-colors hover:border-accent flex items-center justify-center gap-2"
>
<IconUpload size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">
{mafile ? mafile.name : t("import.attachMafile")}
</span>
{mafile && (
<button
onClick={(e) => { e.stopPropagation(); setMafile(null); }}
className="text-xs text-red-400 hover:text-red-300 ml-1"
>
</button>
)}
</div>
<input ref={fileRef} type="file" accept=".mafile" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}} />
<button onClick={handleSave} disabled={!login || !password || saving} className="btn-primary w-full">
{saving ? t("import.uploading") : t("btn.add")}
</button>
</div>
</div>
</div>
@@ -0,0 +1,191 @@
import { useState, useEffect, useCallback } from "react";
import { api } from "@/api/client";
import type { Account, SteamConfirmation } from "@/api/types";
import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n";
import { IconRefresh, IconCheck, IconX } from "@/components/shared/Icons";
const TYPE_ICONS: Record<number, string> = {
0: "❓",
1: "🧪",
2: "🔄",
3: "🏪",
4: "⚙️",
5: "📱",
6: "🔑",
};
interface Props {
account: Account;
onClose: () => void;
}
export function ConfirmationsModal({ account, onClose }: Props) {
const t = useT();
const addToast = useUiStore((s) => s.addToast);
const [confirmations, setConfirmations] = useState<SteamConfirmation[]>([]);
const [loading, setLoading] = useState(true);
const [responding, setResponding] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const fetchConfs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getConfirmations(account.id);
setConfirmations(data.confirmations);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [account.id]);
useEffect(() => {
fetchConfs();
}, [fetchConfs]);
const handleRespond = async (ids: string[], nonces: string[], accept: boolean) => {
const newSet = new Set(responding);
ids.forEach((id) => newSet.add(id));
setResponding(newSet);
try {
const { success } = await api.respondConfirmations(account.id, ids, nonces, accept);
if (success) {
const action = accept ? t("confirm.accepted") : t("confirm.denied");
addToast("success", `${action} (${ids.length})`);
setConfirmations((prev) => prev.filter((c) => !ids.includes(c.id)));
} else {
addToast("error", t("confirm.respondFailed"));
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
} finally {
setResponding((prev) => {
const s = new Set(prev);
ids.forEach((id) => s.delete(id));
return s;
});
}
};
const handleAcceptAll = () => {
if (confirmations.length === 0) return;
handleRespond(
confirmations.map((c) => c.id),
confirmations.map((c) => c.nonce),
true,
);
};
const handleDenyAll = () => {
if (confirmations.length === 0) return;
handleRespond(
confirmations.map((c) => c.id),
confirmations.map((c) => c.nonce),
false,
);
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div
className="bg-dark-800 border border-dark-600 rounded-lg shadow-xl p-5 w-[560px] max-h-[80vh] flex flex-col"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-200">
{t("confirm.title")} {account.login}
</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 transition-colors">
<IconX />
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">
<IconRefresh className="animate-spin mr-2 w-4 h-4" />
{t("confirm.loading")}
</div>
)}
{error && (
<div className="bg-red-900/30 border border-red-700 rounded p-3 text-sm text-red-300 mb-3">
{error}
</div>
)}
{!loading && !error && confirmations.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">{t("confirm.empty")}</p>
)}
{!loading && confirmations.length > 0 && (
<>
<div className="flex gap-2 mb-3">
<button onClick={handleAcceptAll} className="btn-primary text-xs flex items-center gap-1">
<IconCheck className="w-3 h-3" />
{t("confirm.acceptAll")} ({confirmations.length})
</button>
<button onClick={handleDenyAll} className="btn-secondary text-xs flex items-center gap-1">
<IconX className="w-3 h-3" />
{t("confirm.denyAll")}
</button>
<button onClick={fetchConfs} className="btn-secondary text-xs flex items-center gap-1 ml-auto">
<IconRefresh className="w-3 h-3" />
{t("confirm.refresh")}
</button>
</div>
<div className="overflow-y-auto space-y-2 flex-1 min-h-0">
{confirmations.map((conf) => {
const busy = responding.has(conf.id);
return (
<div
key={conf.id}
className={`bg-dark-700 border border-dark-600 rounded p-3 flex items-start gap-3 ${busy ? "opacity-50" : ""}`}
>
{conf.icon ? (
<img src={conf.icon} alt="" className="w-8 h-8 rounded flex-shrink-0 mt-0.5" />
) : (
<span className="text-xl flex-shrink-0 mt-0.5">{TYPE_ICONS[conf.type] ?? "❓"}</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-dark-600 text-gray-400">
{conf.type_name}
</span>
<span className="text-sm text-gray-200 truncate">{conf.headline}</span>
</div>
{conf.summary.length > 0 && (
<p className="text-xs text-gray-400 truncate">{conf.summary.join(" • ")}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
disabled={busy}
onClick={() => handleRespond([conf.id], [conf.nonce], true)}
className="p-1.5 rounded bg-green-700/60 hover:bg-green-600/80 text-green-200 transition-colors"
title={conf.accept}
>
<IconCheck className="w-3.5 h-3.5" />
</button>
<button
disabled={busy}
onClick={() => handleRespond([conf.id], [conf.nonce], false)}
className="p-1.5 rounded bg-red-700/60 hover:bg-red-600/80 text-red-200 transition-colors"
title={conf.cancel}
>
<IconX className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
);
}
@@ -47,7 +47,9 @@ export function ImportModal({ onClose }: Props) {
<p className="font-medium text-gray-300">{t("import.formats")} <span className="text-gray-500 font-normal">({t("import.delimiter")} <code className="text-accent">:</code> {t("import.or")} <code className="text-accent">|</code>)</span></p>
<p className="font-mono text-xs">login:pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}:notes</p>
<p className="font-mono text-xs">login:pass:email:email_pass</p>
<p className="font-mono text-xs">login:pass:email:email_pass:notes</p>
</div>
<div
onDragOver={(e) => e.preventDefault()}
+204 -181
View File
@@ -18,6 +18,12 @@ const NAME_VARS = [
{ value: "{steamid}", label: "steamid" },
];
const FORMAT_OPTIONS: { value: MafileExportRequest["format"]; icon: string }[] = [
{ value: "flat_mafiles", icon: "📄" },
{ value: "per_account_folder", icon: "📁" },
{ value: "single_file", icon: "📦" },
];
interface Props {
accountIds: number[];
onExport: (req: MafileExportRequest) => void;
@@ -29,12 +35,10 @@ export function ExportSettings({ accountIds, onExport }: Props) {
const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS));
const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder");
// Naming templates
const [folderNameTemplate, setFolderNameTemplate] = useState("{username}");
const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile");
const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt");
// .txt options
const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false);
const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false);
const [skipFolders, setSkipFolders] = useState(false);
@@ -68,203 +72,222 @@ export function ExportSettings({ accountIds, onExport }: Props) {
};
return (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4 space-y-4">
<h4 className="font-semibold text-sm"><IconPackage size={14} className="inline mr-1" />{t("mafile.exportSettings")}</h4>
{/* Format */}
<div>
<p className="text-xs text-gray-400 mb-1">{t("mafile.exportFormat")}</p>
<select
value={format}
onChange={(e) => {
const val = e.target.value as MafileExportRequest["format"];
setFormat(val);
if (val === "single_file") {
setIncludeGlobalTxt(false);
setSkipFolders(false);
}
}}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm"
>
<option value="flat_mafiles">{t("mafile.flatFiles")}</option>
<option value="per_account_folder">{t("mafile.perAccountFolder")}</option>
<option value="single_file">{t("mafile.singleFile")}</option>
</select>
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
<IconPackage size={14} className="text-accent" />
<h4 className="font-semibold text-sm">{t("mafile.exportSettings")}</h4>
</div>
{/* Naming templates */}
{format !== "single_file" && (
<div className="space-y-3 border border-dark-600 rounded-lg p-3">
<p className="text-xs text-gray-400 font-medium">
{t("mafile.variables")} <code className="text-accent">{"{username}"}</code>, <code className="text-accent">{"{steamid}"}</code>
</p>
{format === "per_account_folder" && (
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.folderName")}</label>
<div className="flex gap-1">
<input
value={folderNameTemplate}
onChange={(e) => setFolderNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setFolderNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
title={t("mafile.insert", { value: v.value })}
>
{v.label}
</button>
))}
</div>
</div>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.mafileName")}</label>
<div className="flex gap-1">
<input
value={mafileNameTemplate}
onChange={(e) => setMafileNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setMafileNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
title={t("mafile.insert", { value: v.value })}
>
{v.label}
</button>
))}
</div>
<div className="p-4 space-y-4">
{/* Format selector — card-style buttons */}
<div>
<p className="text-xs text-gray-400 mb-2">{t("mafile.exportFormat")}</p>
<div className="grid grid-cols-3 gap-2">
{FORMAT_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setFormat(opt.value);
if (opt.value === "single_file") {
setIncludeGlobalTxt(false);
setSkipFolders(false);
}
}}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border text-xs transition-all cursor-pointer ${
format === opt.value
? "border-accent bg-accent/10 text-white"
: "border-dark-600 bg-dark-700 text-gray-400 hover:border-dark-500 hover:text-gray-300"
}`}
>
<span className="text-base">{opt.icon}</span>
<span>{t(`mafile.${opt.value}` as any)}</span>
</button>
))}
</div>
</div>
)}
{/* .txt options */}
<div className="space-y-2 border border-dark-600 rounded-lg p-3">
<p className="text-xs text-gray-400 font-medium"><IconFileText size={12} className="inline mr-1" />{t("mafile.txtSettings")}</p>
{/* Naming templates */}
{format !== "single_file" && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeGlobalTxt}
onChange={(e) => {
setIncludeGlobalTxt(e.target.checked);
if (!e.target.checked) setSkipFolders(false);
}}
/>
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code> {t("mafile.withAllAccounts")}
</label>
{format === "per_account_folder" && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeTxtPerFolder}
onChange={(e) => setIncludeTxtPerFolder(e.target.checked)}
<details className="group" open>
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.namingSection")}
<span className="text-gray-500 ml-1">
<code className="text-accent/70">{"{username}"}</code> <code className="text-accent/70">{"{steamid}"}</code>
</span>
</summary>
<div className="mt-3 space-y-3 pl-4 border-l border-dark-600">
{format === "per_account_folder" && (
<TemplateInput
label={t("mafile.folderName")}
value={folderNameTemplate}
onChange={setFolderNameTemplate}
vars={NAME_VARS}
insertVar={insertVar}
/>
{t("mafile.addTxtPerFolder")}
</label>
)}
<label className={`flex items-center gap-2 text-sm ${includeGlobalTxt ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}>
<input
type="checkbox"
checked={skipFolders}
disabled={!includeGlobalTxt}
onChange={(e) => setSkipFolders(e.target.checked)}
)}
<TemplateInput
label={t("mafile.mafileName")}
value={mafileNameTemplate}
onChange={setMafileNameTemplate}
vars={NAME_VARS}
insertVar={insertVar}
/>
{t("mafile.skipFolders")}
</label>
</>
</div>
</details>
)}
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
<>
{format === "per_account_folder" && includeTxtPerFolder && (
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtFolderName")}</label>
<div className="flex gap-1">
<input
{/* .txt options */}
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
<IconFileText size={12} className="inline" />
{t("mafile.txtSettings")}
</summary>
<div className="mt-3 space-y-2.5 pl-4 border-l border-dark-600">
{format !== "single_file" && (
<>
<ToggleOption checked={includeGlobalTxt} onChange={(v) => { setIncludeGlobalTxt(v); if (!v) setSkipFolders(false); }}>
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code>
</ToggleOption>
{format === "per_account_folder" && (
<ToggleOption checked={includeTxtPerFolder} onChange={setIncludeTxtPerFolder}>
{t("mafile.addTxtPerFolder")}
</ToggleOption>
)}
<ToggleOption checked={skipFolders} onChange={setSkipFolders} disabled={!includeGlobalTxt}>
{t("mafile.skipFolders")}
</ToggleOption>
</>
)}
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
<div className="space-y-3 pt-1">
{format === "per_account_folder" && includeTxtPerFolder && (
<TemplateInput
label={t("mafile.txtFolderName")}
value={txtNameTemplate}
onChange={(e) => setTxtNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
onChange={setTxtNameTemplate}
vars={NAME_VARS}
insertVar={insertVar}
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setTxtNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
>
{v.label}
</button>
))}
)}
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtLineFormat")}</label>
<input
value={txtFormat}
onChange={(e) => setTxtFormat(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
/>
<p className="text-[11px] text-gray-500 mt-1.5 leading-relaxed">
{t("mafile.variables")} <code className="text-accent/70">{"{login}"}</code> <code className="text-accent/70">{"{password}"}</code> <code className="text-accent/70">{"{email}"}</code> <code className="text-accent/70">{"{email_password}"}</code> <code className="text-accent/70">{"{steam_id}"}</code> <code className="text-accent/70">{"{proxy}"}</code> <code className="text-accent/70">{"{mafile}"}</code>
</p>
</div>
</div>
)}
</div>
</details>
<div>
<label className="text-xs text-gray-400 block mb-1">
{t("mafile.txtLineFormat")}
{/* Field selection */}
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.mafileFields")}
<span className="text-gray-500 text-[11px]">{fields.size}/{MAFILE_FIELDS.length}</span>
</summary>
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
{MAFILE_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} className="accent-accent" />
<span className="font-mono text-[11px]">{f}</span>
</label>
<input
value={txtFormat}
onChange={(e) => setTxtFormat(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
<p className="text-xs text-gray-500 mt-1">
{t("mafile.variables")} <code className="text-accent">{"{login}"}</code> <code className="text-accent">{"{password}"}</code> <code className="text-accent">{"{email}"}</code> <code className="text-accent">{"{email_password}"}</code> <code className="text-accent">{"{steam_id}"}</code> <code className="text-accent">{"{proxy}"}</code> <code className="text-accent">{"{mafile}"}</code>
</p>
</div>
</>
)}
))}
</div>
</details>
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.sessionFields")}
<span className="text-gray-500 text-[11px]">{sessionFields.size}/{SESSION_FIELDS.length}</span>
</summary>
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
{SESSION_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} className="accent-accent" />
<span className="font-mono text-[11px]">{f}</span>
</label>
))}
</div>
</details>
{/* Export button */}
<button
onClick={handleExport}
disabled={accountIds.length === 0}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all ${
accountIds.length === 0
? "bg-dark-700 text-gray-500 cursor-not-allowed"
: "bg-accent hover:bg-accent-hover text-white cursor-pointer"
}`}
>
<IconDownload size={14} />
{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
</button>
</div>
{/* Field selection */}
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.mafileFields")} ({fields.size}/{MAFILE_FIELDS.length})
</summary>
<div className="flex flex-wrap gap-2 mt-2">
{MAFILE_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} />
{f}
</label>
))}
</div>
</details>
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.sessionFields")} ({sessionFields.size}/{SESSION_FIELDS.length})
</summary>
<div className="flex flex-wrap gap-2 mt-2">
{SESSION_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} />
{f}
</label>
))}
</div>
</details>
<button
onClick={handleExport}
disabled={accountIds.length === 0}
className={accountIds.length === 0 ? "btn-primary opacity-50 cursor-not-allowed" : "btn-primary"}
>
<IconDownload size={14} className="inline mr-1" />{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
</button>
</div>
);
}
function TemplateInput({ label, value, onChange, vars, insertVar }: {
label: string;
value: string;
onChange: (v: string) => void;
vars: { value: string; label: string }[];
insertVar: (setter: (fn: (prev: string) => string) => void, varName: string) => void;
}) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
<div className="flex gap-1.5">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
/>
{vars.map((v) => (
<button
key={v.value}
onClick={() => insertVar((fn) => onChange(fn(value)), v.value)}
className="px-2 py-1 text-[11px] rounded bg-dark-700 border border-dark-600 text-gray-400 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
>
{v.label}
</button>
))}
</div>
</div>
);
}
function ToggleOption({ checked, onChange, disabled, children }: {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<label className={`flex items-center gap-2.5 text-sm cursor-pointer select-none ${disabled ? "opacity-40 cursor-not-allowed" : "hover:text-gray-200"} transition-colors`}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="accent-accent"
/>
<span className="text-xs">{children}</span>
</label>
);
}
+54 -27
View File
@@ -5,7 +5,7 @@ import type { MafileExportRequest } from "@/api/types";
import { ExportSettings } from "./ExportSettings";
import { useT } from "@/lib/i18n";
import { IconClipboard } from "@/components/shared/Icons";
import { IconClipboard, IconPackage } from "@/components/shared/Icons";
export function MafileTab() {
const t = useT();
@@ -13,7 +13,6 @@ export function MafileTab() {
const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds);
const clearMafileManager = useAccountStore((s) => s.clearMafileManager);
const addToast = useUiStore((s) => s.addToast);
// Bug #3: only show explicitly sent accounts
const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id));
const handleExport = async (req: MafileExportRequest) => {
@@ -31,37 +30,65 @@ export function MafileTab() {
}
};
const withMafile = sentAccounts.filter((a) => a.mafile_path).length;
return (
<div className="space-y-4 h-full overflow-y-auto pr-1">
{/* Sent accounts list — Bug #3: only sent accounts shown */}
{sentAccounts.length > 0 && (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">
<IconClipboard size={14} className="inline mr-1" />{t("mafile.sentAccounts")} ({sentAccounts.length})
</h3>
<button onClick={clearMafileManager} className="btn-danger-outline text-xs">{t("mafile.clearBtn")}</button>
<div className="h-full overflow-y-auto pr-1">
<div className="max-w-2xl mx-auto space-y-5 py-2">
{/* Empty state */}
{sentAccounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-14 h-14 rounded-2xl bg-dark-700 flex items-center justify-center mb-4">
<IconPackage size={24} className="text-gray-500" />
</div>
<p className="text-gray-400 text-sm mb-1">{t("mafile.emptyTitle")}</p>
<p className="text-gray-500 text-xs max-w-xs">{t("mafile.emptyHint")}</p>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{sentAccounts.map((a) => (
<div key={a.id} className="flex items-center gap-2 text-xs text-gray-300 min-w-0">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />}
<span className="truncate">{a.login}</span>
<span className="text-gray-500 truncate">{a.steam_id || ""}</span>
<span className={a.mafile_path ? "text-green-400" : "text-gray-500"}>
{a.mafile_path ? "✓ mafile" : "—"}
)}
{/* Account list */}
{sentAccounts.length > 0 && (
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-600">
<div className="flex items-center gap-2">
<IconClipboard size={14} className="text-accent" />
<h3 className="font-semibold text-sm">{t("mafile.sentAccounts")}</h3>
<span className="text-xs text-gray-500">
{sentAccounts.length} {t("mafile.accountsCount")} · {withMafile} {t("mafile.withMafile")}
</span>
</div>
))}
<button onClick={clearMafileManager} className="text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer">
{t("mafile.clearBtn")}
</button>
</div>
<div className="max-h-56 overflow-y-auto divide-y divide-dark-700">
{sentAccounts.map((a) => (
<div key={a.id} className="flex items-center gap-3 px-4 py-2 hover:bg-dark-700/50 transition-colors">
{a.avatar_url ? (
<img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />
) : (
<div className="w-6 h-6 rounded bg-dark-600 shrink-0" />
)}
<span className="text-sm truncate flex-1 min-w-0">{a.login}</span>
{a.steam_id && <span className="text-xs text-gray-500 font-mono hidden sm:block">{a.steam_id}</span>}
<span className={`text-xs px-1.5 py-0.5 rounded ${a.mafile_path ? "bg-green-500/10 text-green-400" : "bg-dark-600 text-gray-500"}`}>
{a.mafile_path ? "mafile" : "—"}
</span>
</div>
))}
</div>
</div>
</div>
)}
)}
{/* Export settings — Bug #6: flexible export */}
<ExportSettings
accountIds={[...mafileManagerIds]}
onExport={handleExport}
/>
{/* Export settings */}
{sentAccounts.length > 0 && (
<ExportSettings
accountIds={[...mafileManagerIds]}
onExport={handleExport}
/>
)}
</div>
</div>
);
}
+42
View File
@@ -76,6 +76,8 @@ const translations: Translations = {
"action.enableAutoAccept": "Вкл. автопринятие",
"action.disableAutoAccept": "Выкл. авто-принятие входа",
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
"action.generate2fa": "Сгенерировать 2FA код",
"action.confirmations": "Подтверждения",
// -- Action prompts --
"prompt.newPassword": "Введите новый пароль",
"prompt.newEmail": "Введите новый email",
@@ -99,6 +101,15 @@ const translations: Translations = {
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
"confirm.yes": "Да",
"confirm.title": "Подтверждения",
"confirm.loading": "Загрузка подтверждений...",
"confirm.empty": "Нет ожидающих подтверждений",
"confirm.acceptAll": "Принять все",
"confirm.denyAll": "Отклонить все",
"confirm.refresh": "Обновить",
"confirm.accepted": "Принято",
"confirm.denied": "Отклонено",
"confirm.respondFailed": "Не удалось обработать подтверждение",
// -- Buttons --
"btn.import": "Импорт",
"btn.add": "Добавить",
@@ -196,6 +207,7 @@ const translations: Translations = {
"ph.notes": "Заметки",
"ph.notesOptional": "Заметки (необязательно)",
"ph.loginOptional": "Логин (необязательно)",
"ph.emailPassword": "Пароль от email",
// -- Empty states --
"empty.accounts": "Нет аккаунтов. Импортируйте файл",
"empty.logpass": "Аккаунтов нет. Импортируйте файл.",
@@ -216,6 +228,7 @@ const translations: Translations = {
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
"import.attachMafile": "Прикрепить .mafile (необязательно)",
"import.selected": "Выбран: {name}",
"import.uploading": "Загрузка...",
"import.importBtn": "Импортировать",
@@ -279,6 +292,14 @@ const translations: Translations = {
"mafile.sentAccounts": "Отправленные аккаунты",
"mafile.clearBtn": "Очистить",
"mafile.insert": "Вставить {value}",
"mafile.emptyTitle": "Нет выбранных аккаунтов",
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
"mafile.accountsCount": "акк.",
"mafile.withMafile": "с mafile",
"mafile.namingSection": "Шаблоны имён",
"mafile.flat_mafiles": "Файлы",
"mafile.per_account_folder": "Папки",
"mafile.single_file": "Один файл",
// -- Settings drawer --
"settings.display": "Отображение",
"settings.hidePasswords": "Скрывать пароли (спойлер)",
@@ -350,6 +371,8 @@ const translations: Translations = {
"action.enableAutoAccept": "Enable auto-accept",
"action.disableAutoAccept": "Disable login auto-accept",
"action.enableAutoAcceptLogin": "Enable login auto-accept",
"action.generate2fa": "Generate 2FA code",
"action.confirmations": "Confirmations",
// -- Action prompts --
"prompt.newPassword": "Enter new password",
"prompt.newEmail": "Enter new email",
@@ -373,6 +396,15 @@ const translations: Translations = {
"confirm.clearProxies": "Clear proxies from all accounts?",
"confirm.executeBulk": "Execute «{action}» for {count} accounts?",
"confirm.yes": "Yes",
"confirm.title": "Confirmations",
"confirm.loading": "Loading confirmations...",
"confirm.empty": "No pending confirmations",
"confirm.acceptAll": "Accept all",
"confirm.denyAll": "Deny all",
"confirm.refresh": "Refresh",
"confirm.accepted": "Accepted",
"confirm.denied": "Denied",
"confirm.respondFailed": "Failed to process confirmation",
// -- Buttons --
"btn.import": "Import",
"btn.add": "Add",
@@ -470,6 +502,7 @@ const translations: Translations = {
"ph.notes": "Notes",
"ph.notesOptional": "Notes (optional)",
"ph.loginOptional": "Login (optional)",
"ph.emailPassword": "Email password",
// -- Empty states --
"empty.accounts": "No accounts. Import a file",
"empty.logpass": "No accounts. Import a file.",
@@ -490,6 +523,7 @@ const translations: Translations = {
"import.dragTxt": "Drag a .txt file or click to select",
"import.dragTxtCsv": "Drag a .txt / .csv file or click to select",
"import.dragMafile": "Drag .mafile files or click to select",
"import.attachMafile": "Attach .mafile (optional)",
"import.selected": "Selected: {name}",
"import.uploading": "Uploading...",
"import.importBtn": "Import",
@@ -553,6 +587,14 @@ const translations: Translations = {
"mafile.sentAccounts": "Sent Accounts",
"mafile.clearBtn": "Clear",
"mafile.insert": "Insert {value}",
"mafile.emptyTitle": "No accounts selected",
"mafile.emptyHint": "Select accounts in the table and send them here for mafile export",
"mafile.accountsCount": "accs",
"mafile.withMafile": "with mafile",
"mafile.namingSection": "Naming templates",
"mafile.flat_mafiles": "Files",
"mafile.per_account_folder": "Folders",
"mafile.single_file": "Single file",
// -- Settings drawer --
"settings.display": "Display",
"settings.hidePasswords": "Hide passwords (spoiler)",