mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-05 22:56:34 +02:00
feat: password protected profiles
This commit is contained in:
@@ -86,6 +86,7 @@ interface CreateProfileDialogProps {
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
launchHook?: string;
|
||||
password?: string;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -170,6 +171,11 @@ export function CreateProfileDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [enablePassword, setEnablePassword] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const PASSWORD_MIN_LEN = 8;
|
||||
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
|
||||
useState<string>();
|
||||
const [extensionGroups, setExtensionGroups] = useState<
|
||||
@@ -370,12 +376,30 @@ export function CreateProfileDialog({
|
||||
const handleCreate = async () => {
|
||||
if (!profileName.trim()) return;
|
||||
|
||||
if (enablePassword && !ephemeral) {
|
||||
if (password.length < PASSWORD_MIN_LEN) {
|
||||
setPasswordError(
|
||||
t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setPasswordError(t("profilePassword.errors.mismatch"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPasswordError(null);
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
|
||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||
const resolvedVpnId =
|
||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||
const passwordToSet =
|
||||
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
|
||||
? password
|
||||
: undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
@@ -403,6 +427,7 @@ export function CreateProfileDialog({
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
@@ -430,6 +455,7 @@ export function CreateProfileDialog({
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -455,6 +481,7 @@ export function CreateProfileDialog({
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,6 +515,10 @@ export function CreateProfileDialog({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
setEnablePassword(false);
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
setPasswordError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -718,6 +749,68 @@ export function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Option */}
|
||||
{!ephemeral && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-password"
|
||||
checked={enablePassword}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnablePassword(checked === true);
|
||||
if (checked !== true) {
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
setPasswordError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="enable-password"
|
||||
className="font-medium"
|
||||
>
|
||||
{t("createProfile.passwordProtect.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{t("createProfile.passwordProtect.description")}
|
||||
</p>
|
||||
{enablePassword && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"profilePassword.fields.newPassword",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => {
|
||||
setPasswordConfirm(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"profilePassword.fields.confirm",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedBrowser === "wayfern" ? (
|
||||
// Wayfern Configuration
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -854,6 +854,9 @@ interface ProfilesDataTableProps {
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -883,6 +886,9 @@ export function ProfilesDataTable({
|
||||
syncUnlocked = false,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1695,7 +1701,9 @@ export function ProfilesDataTable({
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const browser = profile.browser;
|
||||
const IconComponent = getProfileIcon(profile);
|
||||
const IconComponent = profile.password_protected
|
||||
? LuLock
|
||||
: getProfileIcon(profile);
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
|
||||
const isSelected = meta.isProfileSelected(profile.id);
|
||||
@@ -2732,6 +2740,9 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onLaunchWithSync={onLaunchWithSync}
|
||||
onSetPassword={onSetPassword}
|
||||
onChangePassword={onChangePassword}
|
||||
onRemovePassword={onRemovePassword}
|
||||
onDeleteProfile={(profile) => {
|
||||
setProfileForInfoDialog(null);
|
||||
setProfileToDelete(profile);
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
LuKey,
|
||||
LuLink,
|
||||
LuLock,
|
||||
LuLockOpen,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuRefreshCw,
|
||||
@@ -71,6 +74,9 @@ interface ProfileInfoDialogProps {
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (profile: BrowserProfile) => void;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
isRunning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
@@ -119,6 +125,9 @@ export function ProfileInfoDialog({
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
onLaunchWithSync,
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
crossOsUnlocked = false,
|
||||
isRunning = false,
|
||||
isDisabled = false,
|
||||
@@ -354,6 +363,40 @@ export function ProfileInfoDialog({
|
||||
},
|
||||
hidden: !onOpenLaunchHook,
|
||||
},
|
||||
{
|
||||
icon: <LuKey className="w-4 h-4" />,
|
||||
label: t("profiles.actions.setPassword"),
|
||||
onClick: () => {
|
||||
handleAction(() => onSetPassword?.(profile));
|
||||
},
|
||||
disabled: isDisabled || isRunning,
|
||||
runningBadge: isRunning,
|
||||
hidden:
|
||||
profile.password_protected === true ||
|
||||
profile.ephemeral === true ||
|
||||
!onSetPassword,
|
||||
},
|
||||
{
|
||||
icon: <LuKey className="w-4 h-4" />,
|
||||
label: t("profiles.actions.changePassword"),
|
||||
onClick: () => {
|
||||
handleAction(() => onChangePassword?.(profile));
|
||||
},
|
||||
disabled: isDisabled || isRunning,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.password_protected !== true || !onChangePassword,
|
||||
},
|
||||
{
|
||||
icon: <LuLockOpen className="w-4 h-4" />,
|
||||
label: t("profiles.actions.removePassword"),
|
||||
onClick: () => {
|
||||
handleAction(() => onRemovePassword?.(profile));
|
||||
},
|
||||
disabled: isDisabled || isRunning,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.password_protected !== true || !onRemovePassword,
|
||||
destructive: true,
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
@@ -417,6 +460,12 @@ export function ProfileInfoDialog({
|
||||
{t("profiles.ephemeralBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.password_protected && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<LuLock className="w-3 h-3" />
|
||||
{t("profiles.passwordProtectedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{showCrossOs && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<OSIcon
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
extractLockoutSeconds,
|
||||
formatLockoutDuration,
|
||||
translateBackendError,
|
||||
} from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export type PasswordDialogMode = "set" | "unlock" | "change" | "remove";
|
||||
|
||||
interface ProfilePasswordDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
mode: PasswordDialogMode;
|
||||
onSuccess?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const MIN_LEN = 8;
|
||||
|
||||
export function ProfilePasswordDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
mode,
|
||||
onSuccess,
|
||||
}: ProfilePasswordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [oldPassword, setOldPassword] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirm, setConfirm] = React.useState("");
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [lockoutSecondsRemaining, setLockoutSecondsRemaining] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
const firstInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOldPassword("");
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setIsSubmitting(false);
|
||||
setLockoutSecondsRemaining(null);
|
||||
setTimeout(() => firstInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Tick down the lockout timer
|
||||
React.useEffect(() => {
|
||||
if (lockoutSecondsRemaining == null) return;
|
||||
if (lockoutSecondsRemaining <= 0) {
|
||||
setLockoutSecondsRemaining(null);
|
||||
return;
|
||||
}
|
||||
const handle = window.setTimeout(() => {
|
||||
setLockoutSecondsRemaining((prev) => (prev == null ? null : prev - 1));
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [lockoutSecondsRemaining]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const needsConfirm = mode === "set" || mode === "change";
|
||||
const needsOldPassword = mode === "change" || mode === "remove";
|
||||
|
||||
const validate = (): string | null => {
|
||||
if (needsOldPassword && !oldPassword) {
|
||||
return t("profilePassword.errors.oldPasswordRequired");
|
||||
}
|
||||
if (mode === "set" || mode === "change") {
|
||||
if (password.length < MIN_LEN) {
|
||||
return t("profilePassword.errors.tooShort", { min: MIN_LEN });
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return t("profilePassword.errors.mismatch");
|
||||
}
|
||||
}
|
||||
if (mode === "unlock" && !password) {
|
||||
return t("profilePassword.errors.passwordRequired");
|
||||
}
|
||||
if (mode === "remove" && !oldPassword) {
|
||||
return t("profilePassword.errors.passwordRequired");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting || lockoutSecondsRemaining != null) return;
|
||||
const error = validate();
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
switch (mode) {
|
||||
case "set":
|
||||
await invoke("set_profile_password", {
|
||||
profileId: profile.id,
|
||||
password,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.set"));
|
||||
break;
|
||||
case "unlock":
|
||||
await invoke("unlock_profile", {
|
||||
profileId: profile.id,
|
||||
password,
|
||||
});
|
||||
break;
|
||||
case "change":
|
||||
await invoke("change_profile_password", {
|
||||
profileId: profile.id,
|
||||
oldPassword,
|
||||
newPassword: password,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.changed"));
|
||||
break;
|
||||
case "remove":
|
||||
await invoke("remove_profile_password", {
|
||||
profileId: profile.id,
|
||||
password: oldPassword,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.removed"));
|
||||
break;
|
||||
}
|
||||
onSuccess?.(profile);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const lockoutSeconds = extractLockoutSeconds(err);
|
||||
if (lockoutSeconds != null) {
|
||||
setLockoutSecondsRemaining(lockoutSeconds);
|
||||
} else {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const titleKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.title"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.title"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.title"
|
||||
: "profilePassword.remove.title";
|
||||
|
||||
const descriptionKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.description"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.description"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.description"
|
||||
: "profilePassword.remove.description";
|
||||
|
||||
const submitLabelKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.button"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.button"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.button"
|
||||
: "profilePassword.remove.button";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(descriptionKey, { name: profile.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(mode === "set" || mode === "change") && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||
<p className="font-medium text-warning-foreground">
|
||||
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profilePassword.warnings.forgetWarningBody")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{lockoutSecondsRemaining != null && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t("backendErrors.lockedOut", {
|
||||
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{needsOldPassword && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-old">
|
||||
{mode === "remove"
|
||||
? t("profilePassword.fields.password")
|
||||
: t("profilePassword.fields.currentPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
ref={firstInputRef}
|
||||
id="profile-pw-old"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(mode === "set" || mode === "change" || mode === "unlock") && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-new">
|
||||
{mode === "unlock"
|
||||
? t("profilePassword.fields.password")
|
||||
: t("profilePassword.fields.newPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
ref={!needsOldPassword ? firstInputRef : undefined}
|
||||
id="profile-pw-new"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete={
|
||||
mode === "unlock" ? "current-password" : "new-password"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{needsConfirm && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-confirm">
|
||||
{t("profilePassword.fields.confirm")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-pw-confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={() => void handleSubmit()}
|
||||
isLoading={isSubmitting}
|
||||
disabled={lockoutSecondsRemaining != null}
|
||||
variant={mode === "remove" ? "destructive" : "default"}
|
||||
>
|
||||
{t(submitLabelKey)}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ interface AppSettings {
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
disable_auto_updates?: boolean;
|
||||
keep_decrypted_profiles_in_ram?: boolean;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -1129,6 +1130,30 @@ export function SettingsDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
<Checkbox
|
||||
id="keep-decrypted-profiles-in-ram"
|
||||
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSetting(
|
||||
"keep_decrypted_profiles_in_ram",
|
||||
checked as boolean,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="keep-decrypted-profiles-in-ram"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.keepDecryptedProfilesInRam")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.keepDecryptedProfilesInRamDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user