mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 16:49:39 +07:00
feat: add team member role change & removal, add uptime server stats, etc
This commit is contained in:
parent
b574f83e74
commit
33dda374c7
@ -13,6 +13,7 @@ import { useAuthStore } from "@/stores/auth";
|
|||||||
import { PortalProvider } from "tamagui";
|
import { PortalProvider } from "tamagui";
|
||||||
import { useServer } from "@/stores/app";
|
import { useServer } from "@/stores/app";
|
||||||
import queryClient from "@/lib/queryClient";
|
import queryClient from "@/lib/queryClient";
|
||||||
|
import DialogMessageProvider from "@/components/containers/dialog-message";
|
||||||
|
|
||||||
type Props = PropsWithChildren;
|
type Props = PropsWithChildren;
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ const Providers = ({ children }: Props) => {
|
|||||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
||||||
<Theme name="blue">
|
<Theme name="blue">
|
||||||
<PortalProvider shouldAddRootHost>{children}</PortalProvider>
|
<PortalProvider shouldAddRootHost>{children}</PortalProvider>
|
||||||
|
<DialogMessageProvider />
|
||||||
</Theme>
|
</Theme>
|
||||||
</TamaguiProvider>
|
</TamaguiProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
36
frontend/components/containers/dialog-message.tsx
Normal file
36
frontend/components/containers/dialog-message.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import React from "react";
|
||||||
|
import Modal from "../ui/modal";
|
||||||
|
import { dialogStore } from "@/hooks/useDialog";
|
||||||
|
import { Button, XStack } from "tamagui";
|
||||||
|
|
||||||
|
const DialogMessageProvider = () => {
|
||||||
|
const { data, onClose } = dialogStore.use();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
disclosure={dialogStore}
|
||||||
|
title={data?.title}
|
||||||
|
description={data?.description}
|
||||||
|
height="auto"
|
||||||
|
>
|
||||||
|
<XStack p="$4" gap="$4">
|
||||||
|
<Button flex={1} onPress={data?.onCancel} bg="$colorTransparent">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
onPress={() => {
|
||||||
|
data?.onConfirm?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DialogMessageProvider;
|
@ -1,7 +1,8 @@
|
|||||||
import { View, Text, XStack, Separator } from "tamagui";
|
import { View, Text, XStack, Separator, ScrollView } from "tamagui";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useWebSocket } from "@/hooks/useWebsocket";
|
import { useWebSocket } from "@/hooks/useWebsocket";
|
||||||
import Icons from "../ui/icons";
|
import Icons from "../ui/icons";
|
||||||
|
import { formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -12,6 +13,7 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
const [memory, setMemory] = useState({ total: 0, used: 0, available: 0 });
|
const [memory, setMemory] = useState({ total: 0, used: 0, available: 0 });
|
||||||
const [disk, setDisk] = useState({ total: "0", used: "0", percent: "0%" });
|
const [disk, setDisk] = useState({ total: "0", used: "0", percent: "0%" });
|
||||||
const [network, setNetwork] = useState({ tx: 0, rx: 0 });
|
const [network, setNetwork] = useState({ tx: 0, rx: 0 });
|
||||||
|
const [uptime, setUptime] = useState(0);
|
||||||
|
|
||||||
const { isConnected } = useWebSocket(url, {
|
const { isConnected } = useWebSocket(url, {
|
||||||
onMessage: (msg) => {
|
onMessage: (msg) => {
|
||||||
@ -44,6 +46,10 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
rx: parseInt(values[1]) || 0,
|
rx: parseInt(values[1]) || 0,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "\x05":
|
||||||
|
setUptime(parseInt(value) || 0);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -53,7 +59,15 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XStack gap="$1" p="$2" alignItems="center">
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "$1",
|
||||||
|
padding: "$2",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<XStack gap="$1" alignItems="center" minWidth={48}>
|
<XStack gap="$1" alignItems="center" minWidth={48}>
|
||||||
<Icons name="desktop-tower" size={16} />
|
<Icons name="desktop-tower" size={16} />
|
||||||
<Text fontSize="$2" aria-label="CPU">
|
<Text fontSize="$2" aria-label="CPU">
|
||||||
@ -61,21 +75,18 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
<Icons ml="$2" name="memory" size={16} />
|
||||||
<Icons name="memory" size={16} />
|
|
||||||
<Text fontSize="$2" aria-label="Memory">
|
<Text fontSize="$2" aria-label="Memory">
|
||||||
{memory.used} MB / {memory.total} MB (
|
{memory.used} MB / {memory.total} MB (
|
||||||
{Math.round((memory.used / memory.total) * 100) || 0}%)
|
{Math.round((memory.used / memory.total) * 100) || 0}%)
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
<Icons ml="$2" name="harddisk" size={16} />
|
||||||
<Icons name="harddisk" size={16} />
|
|
||||||
<Text fontSize="$2" aria-label="Disk">
|
<Text fontSize="$2" aria-label="Disk">
|
||||||
{disk.used} / {disk.total} ({disk.percent})
|
{disk.used} / {disk.total} ({disk.percent})
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
<Icons ml="$2" name="download" size={16} />
|
||||||
<Icons name="download" size={16} />
|
|
||||||
<Text fontSize="$2" aria-label="Network Received">
|
<Text fontSize="$2" aria-label="Network Received">
|
||||||
{network.rx} MB
|
{network.rx} MB
|
||||||
</Text>
|
</Text>
|
||||||
@ -83,7 +94,12 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
<Text fontSize="$2" aria-label="Network Sent">
|
<Text fontSize="$2" aria-label="Network Sent">
|
||||||
{network.tx} MB
|
{network.tx} MB
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
|
||||||
|
<Icons ml="$2" name="clock" size={16} />
|
||||||
|
<Text fontSize="$2" aria-label="Uptime">
|
||||||
|
{formatDuration(uptime)}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ const ThemeSwitcher = ({ iconSize = 18, ...props }: Props) => {
|
|||||||
size={iconSize}
|
size={iconSize}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={id} flex={1} cursor="pointer">
|
<Label htmlFor={id} flex={1} cursor="pointer">
|
||||||
{`${theme === "light" ? "Dark" : "Light"} Mode`}
|
Dark Mode
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id={id}
|
id={id}
|
||||||
|
@ -9,6 +9,7 @@ type ModalProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
maxHeight?: number | string;
|
maxHeight?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,6 +19,7 @@ const Modal = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
width = 512,
|
width = 512,
|
||||||
|
height = "90%",
|
||||||
maxHeight = 600,
|
maxHeight = 600,
|
||||||
}: ModalProps) => {
|
}: ModalProps) => {
|
||||||
const { open, onOpenChange } = disclosure.use();
|
const { open, onOpenChange } = disclosure.use();
|
||||||
@ -65,7 +67,7 @@ const Modal = ({
|
|||||||
p="$1"
|
p="$1"
|
||||||
width="90%"
|
width="90%"
|
||||||
maxWidth={width}
|
maxWidth={width}
|
||||||
height="90%"
|
height={height}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
>
|
>
|
||||||
<View p="$4">
|
<View p="$4">
|
||||||
|
14
frontend/hooks/useDialog.ts
Normal file
14
frontend/hooks/useDialog.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { createDisclosure } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type DialogData = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dialogStore = createDisclosure<DialogData>();
|
||||||
|
|
||||||
|
export const showDialog = (data: DialogData) => {
|
||||||
|
dialogStore.onOpen(data);
|
||||||
|
};
|
@ -36,3 +36,20 @@ export const isHostnameOrIP = (value?: string | null) => {
|
|||||||
|
|
||||||
export const hostnameShape = (message: string = "Invalid hostname") =>
|
export const hostnameShape = (message: string = "Invalid hostname") =>
|
||||||
z.string().refine(isHostnameOrIP, { message });
|
z.string().refine(isHostnameOrIP, { message });
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
const days = Math.floor(seconds / (24 * 3600));
|
||||||
|
seconds %= 24 * 3600;
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
seconds %= 3600;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
seconds = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
|
||||||
|
if (hours > 0) parts.push(`${hours} hr${hours > 1 ? "s" : ""}`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes} min`);
|
||||||
|
if (seconds > 0) parts.push(`${seconds} sec`);
|
||||||
|
|
||||||
|
return parts.join(" ") || "0 seconds";
|
||||||
|
};
|
||||||
|
75
frontend/pages/team/components/change-role-form.tsx
Normal file
75
frontend/pages/team/components/change-role-form.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import Icons from "@/components/ui/icons";
|
||||||
|
import Modal from "@/components/ui/modal";
|
||||||
|
import { useZForm } from "@/hooks/useZForm";
|
||||||
|
import { createDisclosure } from "@/lib/utils";
|
||||||
|
import React from "react";
|
||||||
|
import { ScrollView, XStack } from "tamagui";
|
||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { ErrorAlert } from "@/components/ui/alert";
|
||||||
|
import Button from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
SetRoleSchema,
|
||||||
|
setRoleSchema,
|
||||||
|
teamMemberRoles,
|
||||||
|
} from "../schema/team-form";
|
||||||
|
import { SelectField } from "@/components/ui/select";
|
||||||
|
import { useSetRoleMutation } from "../hooks/query";
|
||||||
|
|
||||||
|
export const changeRoleModal = createDisclosure<SetRoleSchema>();
|
||||||
|
|
||||||
|
const ChangeRoleForm = () => {
|
||||||
|
const { data } = changeRoleModal.use();
|
||||||
|
const form = useZForm(setRoleSchema, data);
|
||||||
|
const setRole = useSetRoleMutation(data?.teamId || "");
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
setRole.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
changeRoleModal.onClose();
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
disclosure={changeRoleModal}
|
||||||
|
title="Change Role"
|
||||||
|
description="Change team member role."
|
||||||
|
maxHeight={280}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||||
|
<ErrorAlert error={setRole.error} />
|
||||||
|
|
||||||
|
<FormField label="Role">
|
||||||
|
<SelectField
|
||||||
|
items={teamMemberRoles}
|
||||||
|
form={form}
|
||||||
|
name="role"
|
||||||
|
placeholder="Select Role..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<XStack p="$4" gap="$4">
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
onPress={changeRoleModal.onClose}
|
||||||
|
bg="$colorTransparent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
icon={<Icons name="account-plus" size={18} />}
|
||||||
|
onPress={onSubmit}
|
||||||
|
isLoading={setRole.isPending}
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangeRoleForm;
|
@ -3,6 +3,10 @@ import { Avatar, Button, ListItem, View, YGroup } from "tamagui";
|
|||||||
import MenuButton from "@/components/ui/menu-button";
|
import MenuButton from "@/components/ui/menu-button";
|
||||||
import Icons from "@/components/ui/icons";
|
import Icons from "@/components/ui/icons";
|
||||||
import SearchInput from "@/components/ui/search-input";
|
import SearchInput from "@/components/ui/search-input";
|
||||||
|
import { useTeamId } from "@/stores/auth";
|
||||||
|
import { changeRoleModal } from "./change-role-form";
|
||||||
|
import { useRemoveMemberMutation } from "../hooks/query";
|
||||||
|
import { showDialog } from "@/hooks/useDialog";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
members?: any[];
|
members?: any[];
|
||||||
@ -10,7 +14,17 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MemberList = ({ members, allowWrite }: Props) => {
|
const MemberList = ({ members, allowWrite }: Props) => {
|
||||||
|
const teamId = useTeamId();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const remove = useRemoveMemberMutation(teamId);
|
||||||
|
|
||||||
|
const onRemove = (member: any) => {
|
||||||
|
showDialog({
|
||||||
|
title: "Remove Member",
|
||||||
|
description: "Are you sure you want to remove this member?",
|
||||||
|
onConfirm: () => remove.mutate(member.userId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const memberList = useMemo(() => {
|
const memberList = useMemo(() => {
|
||||||
let items = members || [];
|
let items = members || [];
|
||||||
@ -51,7 +65,12 @@ const MemberList = ({ members, allowWrite }: Props) => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
iconAfter={
|
iconAfter={
|
||||||
allowWrite ? <MemberActionButton member={member} /> : undefined
|
allowWrite ? (
|
||||||
|
<MemberActionButton
|
||||||
|
member={member}
|
||||||
|
onRemove={() => onRemove(member)}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</YGroup.Item>
|
</YGroup.Item>
|
||||||
@ -63,9 +82,10 @@ const MemberList = ({ members, allowWrite }: Props) => {
|
|||||||
|
|
||||||
type MemberActionButtonProps = {
|
type MemberActionButtonProps = {
|
||||||
member: any;
|
member: any;
|
||||||
|
onRemove: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
const MemberActionButton = ({ member, onRemove }: MemberActionButtonProps) => (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
size="$1"
|
size="$1"
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
@ -77,10 +97,24 @@ const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MenuButton.Item icon={<Icons name="account-key" size={16} />}>
|
<MenuButton.Item
|
||||||
|
icon={<Icons name="account-key" size={16} />}
|
||||||
|
onPress={() =>
|
||||||
|
changeRoleModal.onOpen({
|
||||||
|
teamId: member.teamId,
|
||||||
|
userId: member.userId,
|
||||||
|
role: member.role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
Change Role
|
Change Role
|
||||||
</MenuButton.Item>
|
</MenuButton.Item>
|
||||||
<MenuButton.Item color="$red10" icon={<Icons name="trash-can" size={16} />}>
|
|
||||||
|
<MenuButton.Item
|
||||||
|
color="$red10"
|
||||||
|
icon={<Icons name="trash-can" size={16} />}
|
||||||
|
onPress={onRemove}
|
||||||
|
>
|
||||||
Remove Member
|
Remove Member
|
||||||
</MenuButton.Item>
|
</MenuButton.Item>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { InviteSchema, TeamFormSchema } from "../schema/team-form";
|
import {
|
||||||
|
InviteSchema,
|
||||||
|
SetRoleSchema,
|
||||||
|
TeamFormSchema,
|
||||||
|
} from "../schema/team-form";
|
||||||
import queryClient from "@/lib/queryClient";
|
import queryClient from "@/lib/queryClient";
|
||||||
import { setTeam, useTeamId } from "@/stores/auth";
|
import { setTeam, useTeamId } from "@/stores/auth";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@ -28,7 +32,6 @@ export const useSaveTeam = () => {
|
|||||||
? api(`/teams/${body.id}`, { method: "PUT", body })
|
? api(`/teams/${body.id}`, { method: "PUT", body })
|
||||||
: api(`/teams`, { method: "POST", body });
|
: api(`/teams`, { method: "POST", body });
|
||||||
},
|
},
|
||||||
onError: (e) => console.error(e),
|
|
||||||
onSuccess: (res, body) => {
|
onSuccess: (res, body) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["teams"] });
|
queryClient.invalidateQueries({ queryKey: ["teams"] });
|
||||||
|
|
||||||
@ -43,9 +46,31 @@ export const useSaveTeam = () => {
|
|||||||
export const useInviteMutation = (teamId: string | null) => {
|
export const useInviteMutation = (teamId: string | null) => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: InviteSchema) => {
|
mutationFn: async (body: InviteSchema) => {
|
||||||
return api(`/teams/${teamId}/invite`, { method: "POST", body });
|
return api(`/teams/${teamId}/members`, { method: "POST", body });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSetRoleMutation = (teamId: string | null) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: SetRoleSchema) => {
|
||||||
|
const url = `/teams/${teamId}/members/${body.userId}/role`;
|
||||||
|
return api(url, { method: "PUT", body });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveMemberMutation = (teamId: string | null) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
return api(`/teams/${teamId}/members/${id}`, { method: "DELETE" });
|
||||||
},
|
},
|
||||||
onError: (e) => console.error(e),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||||
},
|
},
|
||||||
|
@ -20,6 +20,7 @@ import tamaguiConfig from "@/tamagui.config";
|
|||||||
import MemberList from "./components/member-list";
|
import MemberList from "./components/member-list";
|
||||||
import { useUser } from "@/hooks/useUser";
|
import { useUser } from "@/hooks/useUser";
|
||||||
import InviteForm, { inviteFormModal } from "./components/invite-form";
|
import InviteForm, { inviteFormModal } from "./components/invite-form";
|
||||||
|
import ChangeRoleForm from "./components/change-role-form";
|
||||||
|
|
||||||
export default function TeamPage() {
|
export default function TeamPage() {
|
||||||
const teamId = useTeamId();
|
const teamId = useTeamId();
|
||||||
@ -71,6 +72,7 @@ export default function TeamPage() {
|
|||||||
<MemberList members={data?.members} allowWrite={canWrite} />
|
<MemberList members={data?.members} allowWrite={canWrite} />
|
||||||
|
|
||||||
<InviteForm />
|
<InviteForm />
|
||||||
|
<ChangeRoleForm />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -9,10 +9,12 @@ export const teamFormSchema = z.object({
|
|||||||
|
|
||||||
export type TeamFormSchema = z.infer<typeof teamFormSchema>;
|
export type TeamFormSchema = z.infer<typeof teamFormSchema>;
|
||||||
|
|
||||||
|
const teamRoles = ["owner", "admin", "member"] as const;
|
||||||
|
|
||||||
export const inviteSchema = z.object({
|
export const inviteSchema = z.object({
|
||||||
teamId: z.string().ulid(),
|
teamId: z.string().ulid(),
|
||||||
username: z.string().min(1, { message: "Username/email is required" }),
|
username: z.string().min(1, { message: "Username/email is required" }),
|
||||||
role: z.enum(["owner", "admin", "member"], {
|
role: z.enum(teamRoles, {
|
||||||
errorMap: () => ({ message: "Role is required" }),
|
errorMap: () => ({ message: "Role is required" }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -24,3 +26,13 @@ export const teamMemberRoles: SelectItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export type InviteSchema = z.infer<typeof inviteSchema>;
|
export type InviteSchema = z.infer<typeof inviteSchema>;
|
||||||
|
|
||||||
|
export const setRoleSchema = z.object({
|
||||||
|
teamId: z.string().ulid(),
|
||||||
|
userId: z.string().ulid(),
|
||||||
|
role: z.enum(teamRoles, {
|
||||||
|
errorMap: () => ({ message: "Role is required" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SetRoleSchema = z.infer<typeof setRoleSchema>;
|
||||||
|
@ -5,21 +5,15 @@ import (
|
|||||||
"rul.sh/vaulterm/app/hosts"
|
"rul.sh/vaulterm/app/hosts"
|
||||||
"rul.sh/vaulterm/app/keychains"
|
"rul.sh/vaulterm/app/keychains"
|
||||||
"rul.sh/vaulterm/app/teams"
|
"rul.sh/vaulterm/app/teams"
|
||||||
|
"rul.sh/vaulterm/app/teams/members"
|
||||||
"rul.sh/vaulterm/app/ws"
|
"rul.sh/vaulterm/app/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitRouter(app *fiber.App) {
|
func InitRouter(app *fiber.App) {
|
||||||
// App route list
|
// App route list
|
||||||
routes := []Router{
|
hosts.Router(app)
|
||||||
hosts.Router,
|
keychains.Router(app)
|
||||||
keychains.Router,
|
teams := teams.Router(app)
|
||||||
teams.Router,
|
members.Router(teams)
|
||||||
ws.Router,
|
ws.Router(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, route := range routes {
|
|
||||||
route(app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Router func(app fiber.Router)
|
|
||||||
|
39
server/app/teams/members/repository.go
Normal file
39
server/app/teams/members/repository.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package members
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"rul.sh/vaulterm/db"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
|
"rul.sh/vaulterm/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TeamMembers struct {
|
||||||
|
db *gorm.DB
|
||||||
|
User *utils.UserContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(r *TeamMembers) *TeamMembers {
|
||||||
|
if r == nil {
|
||||||
|
r = &TeamMembers{}
|
||||||
|
}
|
||||||
|
r.db = db.Get()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeamMembers) Add(data *models.TeamMembers) error {
|
||||||
|
ret := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(data)
|
||||||
|
return ret.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeamMembers) SetRole(data *models.TeamMembers) error {
|
||||||
|
ret := r.db.
|
||||||
|
Where("team_id = ? AND user_id = ?", data.TeamID, data.UserID).
|
||||||
|
Updates(&models.TeamMembers{Role: data.Role})
|
||||||
|
return ret.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TeamMembers) Remove(data *models.TeamMembers) error {
|
||||||
|
ret := r.db.Delete(&models.TeamMembers{TeamID: data.TeamID, UserID: data.UserID})
|
||||||
|
return ret.Error
|
||||||
|
}
|
140
server/app/teams/members/router.go
Normal file
140
server/app/teams/members/router.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package members
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"rul.sh/vaulterm/app/teams"
|
||||||
|
"rul.sh/vaulterm/app/users"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
|
"rul.sh/vaulterm/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Router(app fiber.Router) {
|
||||||
|
router := app.Group("/:id/members")
|
||||||
|
|
||||||
|
// router.Get("/", getAll)
|
||||||
|
router.Post("/", invite)
|
||||||
|
router.Put("/:userId/role", setRole)
|
||||||
|
router.Delete("/:userId", remove)
|
||||||
|
}
|
||||||
|
|
||||||
|
// func getAll(c *fiber.Ctx) error {
|
||||||
|
// user := utils.GetUser(c)
|
||||||
|
// repo := NewRepository(&TeamMembers{User: user})
|
||||||
|
|
||||||
|
// rows, err := repo.GetAll()
|
||||||
|
// if err != nil {
|
||||||
|
// return utils.ResponseError(c, err, 500)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return c.JSON(fiber.Map{"rows": rows})
|
||||||
|
// }
|
||||||
|
|
||||||
|
func invite(c *fiber.Ctx) error {
|
||||||
|
var body InviteSchema
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
teamRepo := teams.NewRepository(&teams.Teams{User: user})
|
||||||
|
repo := NewRepository(&TeamMembers{User: user})
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
exist, _ := teamRepo.Exists(id)
|
||||||
|
if !exist {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
if !user.TeamCanWrite(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := users.NewRepository(&users.Users{User: user})
|
||||||
|
userData, _ := userRepo.Find(body.Username)
|
||||||
|
if userData.ID == "" {
|
||||||
|
return utils.ResponseError(c, errors.New("user not found"), 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Add(&models.TeamMembers{TeamID: id, UserID: userData.ID, Role: body.Role})
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRole(c *fiber.Ctx) error {
|
||||||
|
var body PutRoleSchema
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
teamRepo := teams.NewRepository(&teams.Teams{User: user})
|
||||||
|
repo := NewRepository(&TeamMembers{User: user})
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
userId := c.Params("userId")
|
||||||
|
|
||||||
|
exist, _ := teamRepo.Exists(id)
|
||||||
|
if !exist {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
if !user.TeamCanWrite(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := users.NewRepository(nil)
|
||||||
|
userData, _ := userRepo.Get(userId)
|
||||||
|
if userData == nil || userData.ID == "" {
|
||||||
|
return utils.ResponseError(c, errors.New("user not found"), 404)
|
||||||
|
}
|
||||||
|
if !userData.IsInTeam(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("user not in team"), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.SetRole(&models.TeamMembers{TeamID: id, UserID: userData.ID, Role: body.Role})
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(c *fiber.Ctx) error {
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
teamRepo := teams.NewRepository(&teams.Teams{User: user})
|
||||||
|
repo := NewRepository(&TeamMembers{User: user})
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
userId := c.Params("userId")
|
||||||
|
|
||||||
|
exist, _ := teamRepo.Exists(id)
|
||||||
|
if !exist {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
if !user.TeamCanWrite(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := users.NewRepository(&users.Users{User: user})
|
||||||
|
userData, _ := userRepo.Get(userId)
|
||||||
|
if userData.ID == "" {
|
||||||
|
return utils.ResponseError(c, errors.New("user not found"), 404)
|
||||||
|
}
|
||||||
|
userRole := userData.GetTeamRole(&id)
|
||||||
|
if userRole == "" {
|
||||||
|
return utils.ResponseError(c, errors.New("user not in team"), 400)
|
||||||
|
}
|
||||||
|
if userRole == models.TeamRoleOwner {
|
||||||
|
return utils.ResponseError(c, errors.New("cannot remove owner"), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Remove(&models.TeamMembers{TeamID: id, UserID: userData.ID})
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(true)
|
||||||
|
}
|
11
server/app/teams/members/schema.go
Normal file
11
server/app/teams/members/schema.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package members
|
||||||
|
|
||||||
|
type InviteSchema struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PutRoleSchema struct {
|
||||||
|
UserID string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
@ -2,7 +2,6 @@ package teams
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"rul.sh/vaulterm/db"
|
"rul.sh/vaulterm/db"
|
||||||
"rul.sh/vaulterm/models"
|
"rul.sh/vaulterm/models"
|
||||||
"rul.sh/vaulterm/utils"
|
"rul.sh/vaulterm/utils"
|
||||||
@ -94,14 +93,3 @@ func (r *Teams) Delete(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Teams) Invite(teamId string, userId string, role string) error {
|
|
||||||
ret := r.db.
|
|
||||||
Clauses(clause.OnConflict{DoNothing: true}).
|
|
||||||
Create(&models.TeamMembers{
|
|
||||||
TeamID: teamId,
|
|
||||||
UserID: userId,
|
|
||||||
Role: role,
|
|
||||||
})
|
|
||||||
return ret.Error
|
|
||||||
}
|
|
||||||
|
@ -5,12 +5,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"rul.sh/vaulterm/app/users"
|
|
||||||
"rul.sh/vaulterm/models"
|
"rul.sh/vaulterm/models"
|
||||||
"rul.sh/vaulterm/utils"
|
"rul.sh/vaulterm/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Router(app fiber.Router) {
|
func Router(app fiber.Router) fiber.Router {
|
||||||
router := app.Group("/teams")
|
router := app.Group("/teams")
|
||||||
|
|
||||||
router.Get("/", getAll)
|
router.Get("/", getAll)
|
||||||
@ -18,7 +17,8 @@ func Router(app fiber.Router) {
|
|||||||
router.Post("/", create)
|
router.Post("/", create)
|
||||||
router.Put("/:id", update)
|
router.Put("/:id", update)
|
||||||
router.Delete("/:id", delete)
|
router.Delete("/:id", delete)
|
||||||
router.Post("/:id/invite", invite)
|
|
||||||
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAll(c *fiber.Ctx) error {
|
func getAll(c *fiber.Ctx) error {
|
||||||
@ -116,34 +116,3 @@ func delete(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(true)
|
return c.JSON(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invite(c *fiber.Ctx) error {
|
|
||||||
var body InviteTeamSchema
|
|
||||||
if err := c.BodyParser(&body); err != nil {
|
|
||||||
return utils.ResponseError(c, err, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
user := utils.GetUser(c)
|
|
||||||
repo := NewRepository(&Teams{User: user})
|
|
||||||
|
|
||||||
id := c.Params("id")
|
|
||||||
exist, _ := repo.Exists(id)
|
|
||||||
if !exist {
|
|
||||||
return utils.ResponseError(c, errors.New("team not found"), 404)
|
|
||||||
}
|
|
||||||
if !user.TeamCanWrite(&id) {
|
|
||||||
return utils.ResponseError(c, errors.New("no access"), 403)
|
|
||||||
}
|
|
||||||
|
|
||||||
userRepo := users.NewRepository(&users.Users{User: user})
|
|
||||||
userData, _ := userRepo.Find(body.Username)
|
|
||||||
if userData.ID == "" {
|
|
||||||
return utils.ResponseError(c, errors.New("user not found"), 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := repo.Invite(id, userData.ID, body.Role); err != nil {
|
|
||||||
return utils.ResponseError(c, err, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(true)
|
|
||||||
}
|
|
||||||
|
@ -9,8 +9,3 @@ type GetOptions struct {
|
|||||||
ID string
|
ID string
|
||||||
WithMembers bool
|
WithMembers bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type InviteTeamSchema struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
}
|
|
||||||
|
@ -26,3 +26,10 @@ func (r *Users) Find(username string) (*models.User, error) {
|
|||||||
|
|
||||||
return &user, ret.Error
|
return &user, ret.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Users) Get(id string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
ret := r.db.Preload("Teams").Where("id = ?", id).First(&user)
|
||||||
|
|
||||||
|
return &user, ret.Error
|
||||||
|
}
|
||||||
|
@ -32,11 +32,12 @@ func HandleSSHStats(c *websocket.Conn, client *lib.SSHClient) error {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
wg.Add(4)
|
wg.Add(5)
|
||||||
go getCPUUsage(client, wg, msgCh)
|
go getCPUUsage(client, wg, msgCh)
|
||||||
go getMemoryUsage(client, wg, msgCh)
|
go getMemoryUsage(client, wg, msgCh)
|
||||||
go getDiskUsage(client, wg, msgCh)
|
go getDiskUsage(client, wg, msgCh)
|
||||||
go getNetworkUsage(client, wg, msgCh)
|
go getNetworkUsage(client, wg, msgCh)
|
||||||
|
go getUptime(client, wg, msgCh)
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,7 +64,8 @@ func HandleSSHStats(c *websocket.Conn, client *lib.SSHClient) error {
|
|||||||
func getCPUUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string) {
|
func getCPUUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
cpuData, err := client.Exec("cat /proc/stat | grep '^cpu '")
|
cmd := "cat /proc/stat | grep '^cpu '"
|
||||||
|
cpuData, err := client.Exec(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -73,8 +75,7 @@ func getCPUUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string
|
|||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
|
cpuData, err = client.Exec(cmd)
|
||||||
cpuData, err = client.Exec("cat /proc/stat | grep '^cpu '")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -207,3 +208,26 @@ func parseNetwork(data string) (int, int) {
|
|||||||
|
|
||||||
return txBytes, rxBytes
|
return txBytes, rxBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUptime(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Try to read uptime from /proc/uptime
|
||||||
|
data, err := client.Exec("cat /proc/uptime")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data = strings.TrimSpace(data)
|
||||||
|
uptimeParts := strings.Split(data, " ")
|
||||||
|
if len(uptimeParts) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uptimeSeconds, err := strconv.ParseFloat(uptimeParts[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result <- fmt.Sprintf("\x05%d", int(uptimeSeconds))
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package term
|
package term
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -10,67 +11,75 @@ import (
|
|||||||
"rul.sh/vaulterm/lib"
|
"rul.sh/vaulterm/lib"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error {
|
func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) ([]byte, error) {
|
||||||
if err := client.Connect(); err != nil {
|
if err := client.Connect(); err != nil {
|
||||||
log.Printf("error connecting to SSH: %v", err)
|
log.Printf("error connecting to SSH: %v", err)
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|
||||||
shell, err := client.StartPtyShell()
|
shell, err := client.StartPtyShell()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error starting SSH shell: %v", err)
|
log.Printf("error starting SSH shell: %v", err)
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
session := shell.Session
|
session := shell.Session
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
|
|
||||||
// Goroutine to send SSH stdout to WebSocket
|
sessionCapture, sessionCaptureWriter := io.Pipe()
|
||||||
|
defer sessionCapture.Close()
|
||||||
|
sessionLog := []byte{}
|
||||||
|
|
||||||
|
// Capture SSH session output
|
||||||
|
go func() {
|
||||||
|
reader := bufio.NewReader(sessionCapture)
|
||||||
|
for {
|
||||||
|
b, err := reader.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sessionLog = append(sessionLog, b...)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Pass SSH stdout to WebSocket
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
for {
|
for {
|
||||||
n, err := shell.Stdout.Read(buf)
|
n, err := shell.Stdout.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF {
|
|
||||||
log.Printf("error reading from SSH stdout: %v", err)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
sessionCaptureWriter.Write(buf[:n])
|
||||||
if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||||
log.Printf("error writing to websocket: %v", err)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Goroutine to handle SSH stderr
|
// Pass SSH stderr to WebSocket
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
for {
|
for {
|
||||||
n, err := shell.Stderr.Read(buf)
|
n, err := shell.Stderr.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF {
|
|
||||||
log.Printf("error reading from SSH stderr: %v", err)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
sessionCaptureWriter.Write(buf[:n])
|
||||||
if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||||
log.Printf("error writing to websocket: %v", err)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Handle WebSocket to SSH data streaming
|
// Handle user input
|
||||||
go func() {
|
go func() {
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
_, msg, err := c.ReadMessage()
|
_, msg, err := c.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error reading from websocket: %v", err)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,15 +95,13 @@ func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error {
|
|||||||
|
|
||||||
shell.Stdin.Write(msg)
|
shell.Stdin.Write(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("SSH session closed")
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for the SSH session to close
|
// Wait for the SSH session to close
|
||||||
if err := session.Wait(); err != nil {
|
if err := session.Wait(); err != nil {
|
||||||
log.Printf("SSH session ended with error: %v", err)
|
log.Printf("SSH session ended with error: %v", err)
|
||||||
return err
|
return sessionLog, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return sessionLog, nil
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,11 @@ package term
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/contrib/websocket"
|
"github.com/gofiber/contrib/websocket"
|
||||||
"rul.sh/vaulterm/app/hosts"
|
"rul.sh/vaulterm/app/hosts"
|
||||||
|
"rul.sh/vaulterm/db"
|
||||||
"rul.sh/vaulterm/lib"
|
"rul.sh/vaulterm/lib"
|
||||||
"rul.sh/vaulterm/models"
|
"rul.sh/vaulterm/models"
|
||||||
"rul.sh/vaulterm/utils"
|
"rul.sh/vaulterm/utils"
|
||||||
@ -23,9 +25,19 @@ func HandleTerm(c *websocket.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log := ""
|
||||||
|
term := &models.TermSession{
|
||||||
|
UserID: user.ID,
|
||||||
|
HostID: hostId,
|
||||||
|
Reason: c.Query("reason"),
|
||||||
|
}
|
||||||
|
if err := db.Get().Create(term).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch data.Host.Type {
|
switch data.Host.Type {
|
||||||
case "ssh":
|
case "ssh":
|
||||||
sshHandler(c, data)
|
sshHandler(c, data, &log)
|
||||||
case "pve":
|
case "pve":
|
||||||
pveHandler(c, data)
|
pveHandler(c, data)
|
||||||
case "incus":
|
case "incus":
|
||||||
@ -33,9 +45,18 @@ func HandleTerm(c *websocket.Conn) {
|
|||||||
default:
|
default:
|
||||||
c.WriteMessage(websocket.TextMessage, []byte("Invalid host type"))
|
c.WriteMessage(websocket.TextMessage, []byte("Invalid host type"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// save session log
|
||||||
|
endsAt := time.Now()
|
||||||
|
db.Get().
|
||||||
|
Where("id = ?", term.ID).
|
||||||
|
Updates(&models.TermSession{
|
||||||
|
EndsAt: &endsAt,
|
||||||
|
Log: log,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
func sshHandler(c *websocket.Conn, data *models.HostDecrypted, log *string) {
|
||||||
cfg := lib.NewSSHClient(&lib.SSHClientConfig{
|
cfg := lib.NewSSHClient(&lib.SSHClientConfig{
|
||||||
HostName: data.Host.Host,
|
HostName: data.Host.Host,
|
||||||
Port: data.Port,
|
Port: data.Port,
|
||||||
@ -43,9 +64,15 @@ func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
|||||||
AltKey: data.AltKey,
|
AltKey: data.AltKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := NewSSHWebsocketSession(c, cfg); err != nil {
|
out, err := NewSSHWebsocketSession(c, cfg)
|
||||||
|
if err != nil {
|
||||||
c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
|
c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copy output
|
||||||
|
if log != nil {
|
||||||
|
*log = string(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pveHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
func pveHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
||||||
|
@ -11,4 +11,5 @@ var Models = []interface{}{
|
|||||||
&models.Host{},
|
&models.Host{},
|
||||||
&models.Team{},
|
&models.Team{},
|
||||||
&models.TeamMembers{},
|
&models.TeamMembers{},
|
||||||
|
&models.TermSession{},
|
||||||
}
|
}
|
||||||
|
19
server/models/term_session.go
Normal file
19
server/models/term_session.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type TermSession struct {
|
||||||
|
Model
|
||||||
|
|
||||||
|
UserID string `json:"userId" gorm:"type:varchar(26)"`
|
||||||
|
User User `json:"user" gorm:"foreignKey:UserID"`
|
||||||
|
HostID string `json:"hostId" gorm:"type:varchar(26)"`
|
||||||
|
Host Host `json:"host" gorm:"foreignKey:HostID"`
|
||||||
|
|
||||||
|
Reason string `json:"reason" gorm:"type:varchar(255)"`
|
||||||
|
Log string `json:"log" gorm:"type:text"`
|
||||||
|
|
||||||
|
EndsAt *time.Time `json:"endsAt"`
|
||||||
|
|
||||||
|
Timestamps
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user