mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 07:16:11 +02:00
feat: teams plan
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user