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
+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">