mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-30 15:48:19 +02:00
feat: teams plan
This commit is contained in:
+27
-14
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user