feat: teams plan

This commit is contained in:
zhom
2026-03-02 15:49:26 +04:00
parent 9822ad4e3f
commit acd572ed23
30 changed files with 1223 additions and 200 deletions
+27 -14
View File
@@ -46,6 +46,7 @@ import {
dismissToast,
showErrorToast,
showSuccessToast,
showSyncProgressToast,
showToast,
} from "@/lib/toast-utils";
import type {
@@ -192,8 +193,6 @@ export default function Home() {
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
usePermissions();
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
const handleSelectGroup = useCallback((groupId: string) => {
setSelectedGroupId(groupId);
setSelectedProfiles([]);
@@ -769,9 +768,6 @@ export default function Home() {
profileId: profile.id,
syncMode: enabling ? "Regular" : "Disabled",
});
if (enabling) {
userInitiatedSyncIds.current.add(profile.id);
}
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
description: enabling
? "Profile sync has been enabled"
@@ -786,17 +782,16 @@ export default function Home() {
);
useEffect(() => {
let unlisten: (() => void) | undefined;
let unlistenStatus: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;
(async () => {
try {
unlisten = await listen<{
unlistenStatus = await listen<{
profile_id: string;
status: string;
error?: string;
}>("profile-sync-status", (event) => {
const { profile_id, status, error } = event.payload;
if (!userInitiatedSyncIds.current.has(profile_id)) return;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
@@ -806,26 +801,44 @@ export default function Home() {
type: "loading",
title: `Syncing profile '${name}'...`,
id: toastId,
duration: 30000,
duration: Number.POSITIVE_INFINITY,
onCancel: () => dismissToast(toastId),
});
} else if (status === "synced") {
dismissToast(toastId);
showSuccessToast(`Profile '${name}' synced successfully`);
userInitiatedSyncIds.current.delete(profile_id);
} else if (status === "error") {
dismissToast(toastId);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
);
userInitiatedSyncIds.current.delete(profile_id);
}
});
unlistenProgress = await listen<{
profile_id: string;
phase: string;
total_files?: number;
total_bytes?: number;
}>("profile-sync-progress", (event) => {
const { profile_id, phase, total_files, total_bytes } = event.payload;
if (phase !== "started") return;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile?.name ?? "Unknown";
showSyncProgressToast(name, total_files ?? 0, total_bytes ?? 0, {
id: toastId,
});
});
} catch (error) {
console.error("Failed to listen for sync status events:", error);
console.error("Failed to listen for sync events:", error);
}
})();
return () => {
if (unlisten) unlisten();
if (unlistenStatus) unlistenStatus();
if (unlistenProgress) unlistenProgress();
};
}, [profiles]);
+62 -26
View File
@@ -22,6 +22,7 @@ import {
LuChevronUp,
LuCookie,
LuInfo,
LuLock,
LuTrash2,
LuUsers,
} from "react-icons/lu";
@@ -58,8 +59,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useBrowserState } from "@/hooks/use-browser-state";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useTableSorting } from "@/hooks/use-table-sorting";
import { useTeamLocks } from "@/hooks/use-team-locks";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import {
getBrowserDisplayName,
@@ -193,6 +196,10 @@ type TableMeta = {
profileId: string,
country: LocationItem,
) => Promise<void>;
// Team locks
isProfileLockedByAnother: (profileId: string) => boolean;
getProfileLockEmail: (profileId: string) => string | undefined;
};
type SyncStatusDot = { color: string; tooltip: string; animate: boolean };
@@ -873,6 +880,8 @@ export function ProfilesDataTable({
const { storedProxies } = useProxyEvents();
const { vpnConfigs } = useVpnEvents();
const { user } = useCloudAuth();
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
const [proxyOverrides, setProxyOverrides] = React.useState<
Record<string, string | null>
@@ -1488,6 +1497,11 @@ export function ProfilesDataTable({
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
// Team locks
isProfileLockedByAnother: isProfileLocked,
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
}),
[
t,
@@ -1540,6 +1554,8 @@ export function ProfilesDataTable({
canCreateLocationProxy,
loadCountries,
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
],
);
@@ -1724,9 +1740,13 @@ export function ProfilesDataTable({
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const canLaunch = meta.browserState.canLaunchProfile(profile);
const tooltipContent =
meta.browserState.getLaunchTooltipContent(profile);
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
const canLaunch =
meta.browserState.canLaunchProfile(profile) && !isLockedByAnother;
const lockEmail = meta.getProfileLockEmail(profile.id);
const tooltipContent = isLockedByAnother
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
: meta.browserState.getLaunchTooltipContent(profile);
const handleProfileStop = async (profile: BrowserProfile) => {
meta.setStoppingProfiles((prev: Set<string>) =>
@@ -1890,34 +1910,50 @@ export function ProfilesDataTable({
const isStopping = meta.stoppingProfiles.has(profile.id);
const isDisabled =
isRunning || isLaunching || isStopping || isCrossOs;
const lockedEmail = meta.getProfileLockEmail(profile.id);
const isLocked = meta.isProfileLockedByAnother(profile.id);
return (
<button
type="button"
className={cn(
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (isDisabled) return;
meta.setProfileToRename(profile);
meta.setNewProfileName(profile.name);
meta.setRenameError(null);
}}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div className="flex items-center gap-1">
<button
type="button"
className={cn(
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (isDisabled) return;
meta.setProfileToRename(profile);
meta.setNewProfileName(profile.name);
meta.setRenameError(null);
}
}}
>
{display}
</button>
}}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
meta.setProfileToRename(profile);
meta.setNewProfileName(profile.name);
meta.setRenameError(null);
}
}}
>
{display}
</button>
{isLocked && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuLock className="w-3 h-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
{meta.t("sync.team.profileLocked", { email: lockedEmail })}
</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
+173 -115
View File
@@ -16,6 +16,7 @@ import {
LuPlus,
LuRefreshCw,
LuSettings,
LuShieldCheck,
LuTrash2,
LuX,
} from "react-icons/lu";
@@ -30,6 +31,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getBrowserDisplayName,
@@ -108,6 +110,8 @@ export function ProfileInfoDialog({
>(null);
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
const [newRule, setNewRule] = React.useState("");
const [bypassRulesDialogOpen, setBypassRulesDialogOpen] =
React.useState(false);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -305,6 +309,13 @@ export function ProfileInfoDialog({
});
}
if (profile.created_by_email) {
infoFields.push({
label: t("sync.team.title"),
value: t("sync.team.createdBy", { email: profile.created_by_email }),
});
}
if (profile.ephemeral) {
infoFields.push({
label: t("profileInfo.fields.ephemeral"),
@@ -383,6 +394,11 @@ export function ProfileInfoDialog({
disabled: isDisabled,
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => setBypassRulesDialogOpen(true),
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -395,124 +411,166 @@ export function ProfileInfoDialog({
const visibleActions = actions.filter((a) => !a.hidden);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info">
<div className="flex flex-col items-center gap-1 py-3">
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
<h3 className="text-lg font-semibold">{profile.name}</h3>
<p className="text-sm text-muted-foreground">
{getBrowserDisplayName(profile.browser)} {profile.version}
</p>
</div>
<div className="max-h-[300px] overflow-y-auto">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
{infoFields.map((field) => (
<React.Fragment key={field.label}>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{field.label}
</span>
<span className="text-sm">{field.value}</span>
</React.Fragment>
))}
</div>
</div>
</TabsContent>
<TabsContent value="settings">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
<div className="border-t my-2" />
<div className="flex flex-col gap-3 py-2">
<div>
<h4 className="text-sm font-medium">
{t("profileInfo.network.bypassRules")}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{t("profileInfo.network.bypassRulesDescription")}
</p>
</div>
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button
size="sm"
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
{bypassRules.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("profileInfo.network.noRules")}
</p>
) : (
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto">
{bypassRules.map((rule) => (
<div
key={rule}
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
>
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => handleRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
</button>
</div>
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="info" className="flex-1 min-h-0 flex flex-col">
<TabsList className="w-full shrink-0">
<TabsTrigger value="info" className="flex-1">
{t("profileInfo.tabs.info")}
</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">
{t("profileInfo.tabs.settings")}
</TabsTrigger>
</TabsList>
<TabsContent value="info" className="flex-1 min-h-0">
<ScrollArea className="h-full">
<div className="flex flex-col items-center gap-1 py-3">
<ProfileIcon className="w-12 h-12 text-muted-foreground" />
<h3 className="text-lg font-semibold">{profile.name}</h3>
<p className="text-sm text-muted-foreground">
{getBrowserDisplayName(profile.browser)} {profile.version}
</p>
</div>
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 py-2">
{infoFields.map((field) => (
<React.Fragment key={field.label}>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{field.label}
</span>
<span className="text-sm">{field.value}</span>
</React.Fragment>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</ScrollArea>
</TabsContent>
<TabsContent value="settings" className="flex-1 min-h-0">
<ScrollArea className="h-full">
<div className="flex flex-col py-1">
{visibleActions.map((action) => (
<button
key={action.label}
type="button"
disabled={action.disabled}
onClick={action.onClick}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
action.destructive &&
"text-destructive hover:bg-destructive/10",
)}
>
{action.icon}
<span className="flex-1 flex items-center gap-2">
{action.label}
{action.proBadge && <ProBadge />}
</span>
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ProfileBypassRulesDialog
isOpen={bypassRulesDialogOpen}
onClose={() => setBypassRulesDialogOpen(false)}
bypassRules={bypassRules}
newRule={newRule}
onNewRuleChange={setNewRule}
onAddRule={handleAddRule}
onRemoveRule={handleRemoveRule}
/>
</>
);
}
interface ProfileBypassRulesDialogProps {
isOpen: boolean;
onClose: () => void;
bypassRules: string[];
newRule: string;
onNewRuleChange: (value: string) => void;
onAddRule: () => void;
onRemoveRule: (rule: string) => void;
}
function ProfileBypassRulesDialog({
isOpen,
onClose,
bypassRules,
newRule,
onNewRuleChange,
onAddRule,
onRemoveRule,
}: ProfileBypassRulesDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 min-h-0">
<div className="flex flex-col gap-3 py-2">
<p className="text-sm text-muted-foreground">
{t("profileInfo.network.bypassRulesDescription")}
</p>
<div className="flex gap-2">
<Input
value={newRule}
onChange={(e) => onNewRuleChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onAddRule();
}}
placeholder={t("profileInfo.network.rulePlaceholder")}
className="flex-1 text-sm"
/>
<Button size="sm" onClick={onAddRule} disabled={!newRule.trim()}>
<LuPlus className="w-4 h-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
{bypassRules.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{t("profileInfo.network.noRules")}
</p>
) : (
<div className="flex flex-col gap-1.5">
{bypassRules.map((rule) => (
<div
key={rule}
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
>
<span className="font-mono text-xs truncate">{rule}</span>
<button
type="button"
onClick={() => onRemoveRule(rule)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{t("profileInfo.network.ruleTypes")}
</p>
</div>
</ScrollArea>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("common.buttons.close")}
</Button>
+25
View File
@@ -309,6 +309,31 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
</span>
</div>
)}
{user.teamName && (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.team.name")}
</span>
<span>{user.teamName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t("sync.team.role")}
</span>
<span className="capitalize">
{user.teamRole === "owner"
? t("sync.team.roleOwner")
: user.teamRole === "admin"
? t("sync.team.roleAdmin")
: t("sync.team.roleMember")}
</span>
</div>
<p className="text-xs text-muted-foreground pt-1">
{t("sync.team.manageOnWeb")}
</p>
</>
)}
</div>
<div className="flex gap-2 pt-2">
+54
View File
@@ -0,0 +1,54 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import type { ProfileLockInfo } from "@/types";
export function useTeamLocks(currentUserId?: string) {
const [locks, setLocks] = useState<ProfileLockInfo[]>([]);
const fetchLocks = useCallback(async () => {
try {
const result = await invoke<ProfileLockInfo[]>("get_team_locks");
setLocks(result);
} catch {
// Not connected to a team or not logged in
}
}, []);
useEffect(() => {
fetchLocks();
const unlistenAcquired = listen<{ profileId: string }>(
"team-lock-acquired",
() => fetchLocks(),
);
const unlistenReleased = listen<{ profileId: string }>(
"team-lock-released",
() => fetchLocks(),
);
return () => {
unlistenAcquired.then((fn) => fn());
unlistenReleased.then((fn) => fn());
};
}, [fetchLocks]);
const isProfileLocked = useCallback(
(profileId: string): boolean => {
const lock = locks.find((l) => l.profileId === profileId);
if (!lock) return false;
if (currentUserId && lock.lockedBy === currentUserId) return false;
return true;
},
[locks, currentUserId],
);
const getLockInfo = useCallback(
(profileId: string): ProfileLockInfo | undefined => {
return locks.find((l) => l.profileId === profileId);
},
[locks],
);
return { locks, isProfileLocked, getLockInfo, refetchLocks: fetchLocks };
}
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.",
"loginSuccess": "Successfully logged in!",
"logoutSuccess": "Successfully logged out."
},
"team": {
"title": "Team",
"name": "Team Name",
"role": "Role",
"roleOwner": "Owner",
"roleAdmin": "Admin",
"roleMember": "Member",
"manageOnWeb": "Manage team on the web dashboard",
"profileLocked": "In use by {{email}}",
"profileLockedShort": "In use",
"cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}",
"createdBy": "Created by {{email}}"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "Verifying {{browser}} {{version}}",
"syncing": "Syncing...",
"syncingProfile": "Syncing profile '{{name}}'...",
"syncingProfileWithProgress": "{{count}} files ({{size}})",
"updatingVersions": "Updating browser versions..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "Proxy Bypass Rules",
"bypassRulesTitle": "Proxy Bypass Rules",
"bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.",
"addRule": "Add Rule",
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.",
"loginSuccess": "¡Sesión iniciada exitosamente!",
"logoutSuccess": "Sesión cerrada exitosamente."
},
"team": {
"title": "Equipo",
"name": "Nombre del Equipo",
"role": "Rol",
"roleOwner": "Propietario",
"roleAdmin": "Administrador",
"roleMember": "Miembro",
"manageOnWeb": "Gestionar equipo en el panel web",
"profileLocked": "En uso por {{email}}",
"profileLockedShort": "En uso",
"cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}",
"createdBy": "Creado por {{email}}"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} archivos ({{size}})",
"updatingVersions": "Actualizando versiones de navegadores..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "Reglas de Omisión de Proxy",
"bypassRulesTitle": "Reglas de Omisión de Proxy",
"bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.",
"addRule": "Agregar Regla",
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.",
"loginSuccess": "Connexion réussie !",
"logoutSuccess": "Déconnexion réussie."
},
"team": {
"title": "Équipe",
"name": "Nom de l'équipe",
"role": "Rôle",
"roleOwner": "Propriétaire",
"roleAdmin": "Administrateur",
"roleMember": "Membre",
"manageOnWeb": "Gérer l'équipe sur le tableau de bord web",
"profileLocked": "Utilisé par {{email}}",
"profileLockedShort": "En cours d'utilisation",
"cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}",
"createdBy": "Créé par {{email}}"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "Vérification de {{browser}} {{version}}",
"syncing": "Synchronisation...",
"syncingProfile": "Synchronisation du profil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} fichiers ({{size}})",
"updatingVersions": "Mise à jour des versions de navigateurs..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "Règles de Contournement du Proxy",
"bypassRulesTitle": "Règles de Contournement du Proxy",
"bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.",
"addRule": "Ajouter une Règle",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。",
"loginSuccess": "ログインに成功しました!",
"logoutSuccess": "ログアウトしました。"
},
"team": {
"title": "チーム",
"name": "チーム名",
"role": "役割",
"roleOwner": "オーナー",
"roleAdmin": "管理者",
"roleMember": "メンバー",
"manageOnWeb": "Webダッシュボードでチームを管理",
"profileLocked": "{{email}} が使用中",
"profileLockedShort": "使用中",
"cannotLaunchLocked": "起動できません — {{email}} がプロファイルを使用中です",
"createdBy": "{{email}} が作成"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "{{browser}} {{version}} を確認中",
"syncing": "同期中...",
"syncingProfile": "プロファイル '{{name}}' を同期中...",
"syncingProfileWithProgress": "{{count}} ファイル ({{size}})",
"updatingVersions": "ブラウザバージョンを更新中..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "プロキシバイパスルール",
"bypassRulesTitle": "プロキシバイパスルール",
"bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。",
"addRule": "ルールを追加",
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.",
"loginSuccess": "Login realizado com sucesso!",
"logoutSuccess": "Logout realizado com sucesso."
},
"team": {
"title": "Equipe",
"name": "Nome da Equipe",
"role": "Função",
"roleOwner": "Proprietário",
"roleAdmin": "Administrador",
"roleMember": "Membro",
"manageOnWeb": "Gerenciar equipe no painel web",
"profileLocked": "Em uso por {{email}}",
"profileLockedShort": "Em uso",
"cannotLaunchLocked": "Não é possível iniciar — o perfil está em uso por {{email}}",
"createdBy": "Criado por {{email}}"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "Verificando {{browser}} {{version}}",
"syncing": "Sincronizando...",
"syncingProfile": "Sincronizando perfil '{{name}}'...",
"syncingProfileWithProgress": "{{count}} arquivos ({{size}})",
"updatingVersions": "Atualizando versões de navegadores..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "Regras de Bypass de Proxy",
"bypassRulesTitle": "Regras de Bypass de Proxy",
"bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.",
"addRule": "Adicionar Regra",
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.",
"loginSuccess": "Вход выполнен успешно!",
"logoutSuccess": "Выход выполнен успешно."
},
"team": {
"title": "Команда",
"name": "Название команды",
"role": "Роль",
"roleOwner": "Владелец",
"roleAdmin": "Администратор",
"roleMember": "Участник",
"manageOnWeb": "Управление командой в веб-панели",
"profileLocked": "Используется {{email}}",
"profileLockedShort": "Используется",
"cannotLaunchLocked": "Невозможно запустить — профиль используется {{email}}",
"createdBy": "Создано {{email}}"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "Проверка {{browser}} {{version}}",
"syncing": "Синхронизация...",
"syncingProfile": "Синхронизация профиля '{{name}}'...",
"syncingProfileWithProgress": "{{count}} файлов ({{size}})",
"updatingVersions": "Обновление версий браузеров..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "Правила обхода прокси",
"bypassRulesTitle": "Правила обхода прокси",
"bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.",
"addRule": "Добавить правило",
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
+15
View File
@@ -340,6 +340,19 @@
"logoutConfirm": "您确定要退出登录吗?云同步将会停止。",
"loginSuccess": "登录成功!",
"logoutSuccess": "已成功退出登录。"
},
"team": {
"title": "团队",
"name": "团队名称",
"role": "角色",
"roleOwner": "所有者",
"roleAdmin": "管理员",
"roleMember": "成员",
"manageOnWeb": "在网页控制台管理团队",
"profileLocked": "{{email}} 正在使用中",
"profileLockedShort": "使用中",
"cannotLaunchLocked": "无法启动 — 配置文件正被 {{email}} 使用",
"createdBy": "由 {{email}} 创建"
}
},
"integrations": {
@@ -504,6 +517,7 @@
"verifying": "正在验证 {{browser}} {{version}}",
"syncing": "同步中...",
"syncingProfile": "正在同步配置文件 '{{name}}'...",
"syncingProfileWithProgress": "{{count}} 个文件 ({{size}})",
"updatingVersions": "正在更新浏览器版本..."
}
},
@@ -707,6 +721,7 @@
},
"network": {
"bypassRules": "代理绕过规则",
"bypassRulesTitle": "代理绕过规则",
"bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。",
"addRule": "添加规则",
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
+32
View File
@@ -232,6 +232,38 @@ export function dismissToast(id: string) {
sonnerToast.dismiss(id);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** i;
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}
export function showSyncProgressToast(
profileName: string,
totalFiles: number,
totalBytes: number,
options?: { id?: string },
) {
const description = `${totalFiles} files (${formatBytes(totalBytes)})`;
return showToast({
type: "loading",
title: `Syncing profile '${profileName}'...`,
description,
id: options?.id,
duration: Number.POSITIVE_INFINITY,
onCancel: () => {
if (options?.id) {
dismissToast(options.id);
}
},
});
}
export function showUnifiedVersionUpdateToast(
title: string,
options?: {
+13
View File
@@ -33,6 +33,8 @@ export interface BrowserProfile {
ephemeral?: boolean;
extension_group_id?: string;
proxy_bypass_rules?: string[];
created_by_id?: string;
created_by_email?: string;
}
export interface Extension {
@@ -77,6 +79,17 @@ export interface CloudUser {
proxyBandwidthLimitMb: number;
proxyBandwidthUsedMb: number;
proxyBandwidthExtraMb: number;
teamId?: string;
teamName?: string;
teamRole?: string;
}
export interface ProfileLockInfo {
profileId: string;
lockedBy: string;
lockedByEmail: string;
lockedAt: string;
expiresAt?: string;
}
export interface CloudAuthState {