mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 16:49:39 +07:00
feat: team page
This commit is contained in:
parent
7a00992ff9
commit
71dfbd5db3
@ -55,6 +55,18 @@ export default function Layout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="team"
|
||||||
|
options={
|
||||||
|
{
|
||||||
|
title: "Team",
|
||||||
|
hidden: !teamId,
|
||||||
|
drawerIcon: ({ size, color }) => (
|
||||||
|
<Icons name="account-group" size={size} color={color} />
|
||||||
|
),
|
||||||
|
} as DrawerNavigationOptions
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
3
frontend/app/(drawer)/team.tsx
Normal file
3
frontend/app/(drawer)/team.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import TeamPage from "@/pages/team/page";
|
||||||
|
|
||||||
|
export default TeamPage;
|
@ -56,27 +56,33 @@ const ServerStatsBar = ({ url }: Props) => {
|
|||||||
<XStack gap="$1" p="$2" alignItems="center">
|
<XStack gap="$1" p="$2" alignItems="center">
|
||||||
<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">{Math.round(cpu)}%</Text>
|
<Text fontSize="$2" aria-label="CPU">
|
||||||
|
{Math.round(cpu)}%
|
||||||
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||||
<Icons name="memory" size={16} />
|
<Icons name="memory" size={16} />
|
||||||
<Text fontSize="$2">
|
<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" />
|
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||||
<Icons name="harddisk" size={16} />
|
<Icons name="harddisk" size={16} />
|
||||||
<Text fontSize="$2">
|
<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" />
|
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||||
<Icons name="download" size={16} />
|
<Icons name="download" size={16} />
|
||||||
<Text fontSize="$2">{network.rx} MB</Text>
|
<Text fontSize="$2" aria-label="Network Received">
|
||||||
|
{network.rx} MB
|
||||||
|
</Text>
|
||||||
<Icons name="upload" size={16} />
|
<Icons name="upload" size={16} />
|
||||||
<Text fontSize="$2">{network.tx} MB</Text>
|
<Text fontSize="$2" aria-label="Network Sent">
|
||||||
|
{network.tx} MB
|
||||||
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -13,6 +13,7 @@ import MenuButton from "../ui/menu-button";
|
|||||||
import Icons from "../ui/icons";
|
import Icons from "../ui/icons";
|
||||||
import { logout, setTeam, useTeamId } from "@/stores/auth";
|
import { logout, setTeam, useTeamId } from "@/stores/auth";
|
||||||
import { useUser } from "@/hooks/useUser";
|
import { useUser } from "@/hooks/useUser";
|
||||||
|
import TeamForm, { teamFormModal } from "@/pages/team/components/team-form";
|
||||||
|
|
||||||
const UserMenuButton = () => {
|
const UserMenuButton = () => {
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
@ -20,6 +21,7 @@ const UserMenuButton = () => {
|
|||||||
const team = user?.teams?.find((t: any) => t.id === teamId);
|
const team = user?.teams?.find((t: any) => t.id === teamId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
size="$1"
|
size="$1"
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
@ -57,6 +59,8 @@ const UserMenuButton = () => {
|
|||||||
title="Logout"
|
title="Logout"
|
||||||
/>
|
/>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
<TeamForm />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,6 +111,7 @@ const TeamsMenu = () => {
|
|||||||
<MenuButton.Item
|
<MenuButton.Item
|
||||||
icon={<Icons name="plus" size={16} />}
|
icon={<Icons name="plus" size={16} />}
|
||||||
title="Create Team"
|
title="Create Team"
|
||||||
|
onPress={() => teamFormModal.onOpen({ icon: "🍃", name: "" })}
|
||||||
/>
|
/>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
);
|
);
|
||||||
|
@ -21,7 +21,7 @@ const MenuButtonFrame = ({
|
|||||||
...props
|
...props
|
||||||
}: MenuButtonProps) => {
|
}: MenuButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<Popover {...props}>
|
<Popover size="$1" {...props}>
|
||||||
<Popover.Trigger asChild={asChild}>{trigger}</Popover.Trigger>
|
<Popover.Trigger asChild={asChild}>{trigger}</Popover.Trigger>
|
||||||
|
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
|
@ -9,6 +9,7 @@ type ModalProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
|
maxHeight?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Modal = ({
|
const Modal = ({
|
||||||
@ -17,6 +18,7 @@ const Modal = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
width = 512,
|
width = 512,
|
||||||
|
maxHeight = 600,
|
||||||
}: ModalProps) => {
|
}: ModalProps) => {
|
||||||
const { open, onOpenChange } = disclosure.use();
|
const { open, onOpenChange } = disclosure.use();
|
||||||
|
|
||||||
@ -64,7 +66,7 @@ const Modal = ({
|
|||||||
width="90%"
|
width="90%"
|
||||||
maxWidth={width}
|
maxWidth={width}
|
||||||
height="90%"
|
height="90%"
|
||||||
maxHeight={600}
|
maxHeight={maxHeight}
|
||||||
>
|
>
|
||||||
<View p="$4">
|
<View p="$4">
|
||||||
<Dialog.Title>{title}</Dialog.Title>
|
<Dialog.Title>{title}</Dialog.Title>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { getCurrentServer } from "@/stores/app";
|
import { getCurrentServer } from "@/stores/app";
|
||||||
import authStore from "@/stores/auth";
|
import authStore, { logout } from "@/stores/auth";
|
||||||
import { ofetch } from "ofetch";
|
import { ofetch } from "ofetch";
|
||||||
|
|
||||||
const api = ofetch.create({
|
const api = ofetch.create({
|
||||||
@ -23,7 +23,7 @@ const api = ofetch.create({
|
|||||||
},
|
},
|
||||||
onResponseError: (error) => {
|
onResponseError: (error) => {
|
||||||
if (error.response.status === 401 && !!authStore.getState().token) {
|
if (error.response.status === 401 && !!authStore.getState().token) {
|
||||||
authStore.setState({ token: null });
|
logout();
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { View, Text, Spinner } from "tamagui";
|
import { View, Text, Spinner } from "tamagui";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import api from "@/lib/api";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import SearchInput from "@/components/ui/search-input";
|
import SearchInput from "@/components/ui/search-input";
|
||||||
import { useTermSession } from "@/stores/terminal-sessions";
|
import { useTermSession } from "@/stores/terminal-sessions";
|
||||||
|
@ -6,22 +6,21 @@ import HostForm, { hostFormModal } from "./components/form";
|
|||||||
import Icons from "@/components/ui/icons";
|
import Icons from "@/components/ui/icons";
|
||||||
import { initialValues } from "./schema/form";
|
import { initialValues } from "./schema/form";
|
||||||
import KeyForm from "../keychains/components/form";
|
import KeyForm from "../keychains/components/form";
|
||||||
|
import { useUser } from "@/hooks/useUser";
|
||||||
|
import { useTeamId } from "@/stores/auth";
|
||||||
|
|
||||||
export default function HostsPage() {
|
export default function HostsPage() {
|
||||||
|
const teamId = useTeamId();
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Drawer.Screen
|
<Drawer.Screen
|
||||||
options={{
|
options={{
|
||||||
headerRight: () => (
|
headerRight:
|
||||||
<Button
|
!teamId || user?.teamCanWrite(teamId)
|
||||||
bg="$colorTransparent"
|
? () => <AddButton />
|
||||||
icon={<Icons name="plus" size={24} />}
|
: undefined,
|
||||||
onPress={() => hostFormModal.onOpen(initialValues)}
|
|
||||||
$gtSm={{ mr: "$3" }}
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -31,3 +30,14 @@ export default function HostsPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AddButton = () => (
|
||||||
|
<Button
|
||||||
|
bg="$colorTransparent"
|
||||||
|
icon={<Icons name="plus" size={24} />}
|
||||||
|
onPress={() => hostFormModal.onOpen(initialValues)}
|
||||||
|
$gtSm={{ mr: "$3" }}
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
33
frontend/pages/team/components/header-actions.tsx
Normal file
33
frontend/pages/team/components/header-actions.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
import MenuButton from "@/components/ui/menu-button";
|
||||||
|
import { Button } from "tamagui";
|
||||||
|
import Icons from "@/components/ui/icons";
|
||||||
|
import { teamFormModal } from "./team-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
team: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HeaderActions({ team }: Props) {
|
||||||
|
return (
|
||||||
|
<MenuButton
|
||||||
|
placement="bottom-end"
|
||||||
|
width={200}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
circular
|
||||||
|
bg="$colorTransparent"
|
||||||
|
mr="$2"
|
||||||
|
icon={<Icons name="dots-vertical" size={20} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuButton.Item
|
||||||
|
icon={<Icons name="pencil" size={16} />}
|
||||||
|
onPress={() => teamFormModal.onOpen(team)}
|
||||||
|
>
|
||||||
|
Update Team
|
||||||
|
</MenuButton.Item>
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
}
|
84
frontend/pages/team/components/invite-form.tsx
Normal file
84
frontend/pages/team/components/invite-form.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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 { InputField } from "@/components/ui/input";
|
||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { useInviteMutation } from "../hooks/query";
|
||||||
|
import { ErrorAlert } from "@/components/ui/alert";
|
||||||
|
import Button from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
InviteSchema,
|
||||||
|
inviteSchema,
|
||||||
|
teamMemberRoles,
|
||||||
|
} from "../schema/team-form";
|
||||||
|
import { SelectField } from "@/components/ui/select";
|
||||||
|
|
||||||
|
export const inviteFormModal = createDisclosure<InviteSchema>();
|
||||||
|
|
||||||
|
const InviteForm = () => {
|
||||||
|
const { data } = inviteFormModal.use();
|
||||||
|
const form = useZForm(inviteSchema, data);
|
||||||
|
const invite = useInviteMutation(data?.teamId || "");
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
invite.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
inviteFormModal.onClose();
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
disclosure={inviteFormModal}
|
||||||
|
title="Invite"
|
||||||
|
description="Add new team member."
|
||||||
|
maxHeight={360}
|
||||||
|
>
|
||||||
|
<ErrorAlert mx="$4" mb="$4" error={invite.error} />
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||||
|
<FormField label="Username/Email">
|
||||||
|
<InputField
|
||||||
|
f={1}
|
||||||
|
form={form}
|
||||||
|
name="username"
|
||||||
|
placeholder="john.doe"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Role">
|
||||||
|
<SelectField
|
||||||
|
items={teamMemberRoles}
|
||||||
|
form={form}
|
||||||
|
name="role"
|
||||||
|
placeholder="Select Role..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<XStack p="$4" gap="$4">
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
onPress={inviteFormModal.onClose}
|
||||||
|
bg="$colorTransparent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
icon={<Icons name="account-plus" size={18} />}
|
||||||
|
onPress={onSubmit}
|
||||||
|
isLoading={invite.isPending}
|
||||||
|
>
|
||||||
|
Invite User
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InviteForm;
|
89
frontend/pages/team/components/member-list.tsx
Normal file
89
frontend/pages/team/components/member-list.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Avatar, Button, ListItem, View, YGroup } from "tamagui";
|
||||||
|
import MenuButton from "@/components/ui/menu-button";
|
||||||
|
import Icons from "@/components/ui/icons";
|
||||||
|
import SearchInput from "@/components/ui/search-input";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
members?: any[];
|
||||||
|
allowWrite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemberList = ({ members, allowWrite }: Props) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const memberList = useMemo(() => {
|
||||||
|
let items = members || [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
items = items.filter((item: any) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.user?.name.toLowerCase().includes(q) ||
|
||||||
|
item.user?.username.toLowerCase().includes(q) ||
|
||||||
|
item.user?.email.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [members, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View mt="$4">
|
||||||
|
<SearchInput
|
||||||
|
placeholder="Search member.."
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<YGroup bordered mt="$4">
|
||||||
|
{memberList?.map((member: any) => (
|
||||||
|
<YGroup.Item key={member.userId}>
|
||||||
|
<ListItem
|
||||||
|
hoverTheme
|
||||||
|
title={member.user?.name}
|
||||||
|
subTitle={member.role}
|
||||||
|
pr="$2"
|
||||||
|
icon={
|
||||||
|
<Avatar size="$3" circular>
|
||||||
|
<Avatar.Fallback bg="$blue5" />
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
iconAfter={
|
||||||
|
allowWrite ? <MemberActionButton member={member} /> : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</YGroup.Item>
|
||||||
|
))}
|
||||||
|
</YGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemberActionButtonProps = {
|
||||||
|
member: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
||||||
|
<MenuButton
|
||||||
|
size="$1"
|
||||||
|
placement="bottom-end"
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
icon={<Icons name="dots-vertical" size={20} />}
|
||||||
|
circular
|
||||||
|
bg="$colorTransparent"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuButton.Item icon={<Icons name="account-key" size={16} />}>
|
||||||
|
Change Role
|
||||||
|
</MenuButton.Item>
|
||||||
|
<MenuButton.Item color="$red10" icon={<Icons name="trash-can" size={16} />}>
|
||||||
|
Remove Member
|
||||||
|
</MenuButton.Item>
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MemberList;
|
73
frontend/pages/team/components/team-form.tsx
Normal file
73
frontend/pages/team/components/team-form.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
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 { InputField } from "@/components/ui/input";
|
||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { useSaveTeam } from "../hooks/query";
|
||||||
|
import { ErrorAlert } from "@/components/ui/alert";
|
||||||
|
import Button from "@/components/ui/button";
|
||||||
|
import { TeamFormSchema, teamFormSchema } from "../schema/team-form";
|
||||||
|
|
||||||
|
export const teamFormModal = createDisclosure<TeamFormSchema>();
|
||||||
|
|
||||||
|
const TeamForm = () => {
|
||||||
|
const { data } = teamFormModal.use();
|
||||||
|
const form = useZForm(teamFormSchema, data);
|
||||||
|
const isEditing = data?.id != null;
|
||||||
|
|
||||||
|
const saveMutation = useSaveTeam();
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
|
saveMutation.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
teamFormModal.onClose();
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
disclosure={teamFormModal}
|
||||||
|
title="Team"
|
||||||
|
description={`${isEditing ? "Edit" : "Add new"} team.`}
|
||||||
|
maxHeight={320}
|
||||||
|
>
|
||||||
|
<ErrorAlert mx="$4" mb="$4" error={saveMutation.error} />
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||||
|
<FormField label="Name">
|
||||||
|
<InputField
|
||||||
|
f={1}
|
||||||
|
form={form}
|
||||||
|
name="name"
|
||||||
|
placeholder="Team Name..."
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Icon">
|
||||||
|
<InputField form={form} name="icon" placeholder="Icon" />
|
||||||
|
</FormField>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<XStack p="$4" gap="$4">
|
||||||
|
<Button flex={1} onPress={teamFormModal.onClose} bg="$colorTransparent">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
flex={1}
|
||||||
|
icon={<Icons name="content-save" size={18} />}
|
||||||
|
onPress={onSubmit}
|
||||||
|
isLoading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamForm;
|
53
frontend/pages/team/hooks/query.ts
Normal file
53
frontend/pages/team/hooks/query.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import api from "@/lib/api";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { InviteSchema, TeamFormSchema } from "../schema/team-form";
|
||||||
|
import queryClient from "@/lib/queryClient";
|
||||||
|
import { setTeam, useTeamId } from "@/stores/auth";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
|
||||||
|
export const useTeams = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["teams"],
|
||||||
|
queryFn: () => api("/teams"),
|
||||||
|
select: (i) => i.rows,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTeam = () => {
|
||||||
|
const teamId = useTeamId();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["teams", teamId],
|
||||||
|
queryFn: () => api(`/teams/${teamId}`),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSaveTeam = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TeamFormSchema) => {
|
||||||
|
return body.id
|
||||||
|
? api(`/teams/${body.id}`, { method: "PUT", body })
|
||||||
|
: api(`/teams`, { method: "POST", body });
|
||||||
|
},
|
||||||
|
onError: (e) => console.error(e),
|
||||||
|
onSuccess: (res, body) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["teams"] });
|
||||||
|
|
||||||
|
if (!body.id && res.id) {
|
||||||
|
setTeam(res.id);
|
||||||
|
router.push("/team");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useInviteMutation = (teamId: string | null) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: InviteSchema) => {
|
||||||
|
return api(`/teams/${teamId}/invite`, { method: "POST", body });
|
||||||
|
},
|
||||||
|
onError: (e) => console.error(e),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
77
frontend/pages/team/page.tsx
Normal file
77
frontend/pages/team/page.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
ListItem,
|
||||||
|
YGroup,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
XStack,
|
||||||
|
} from "tamagui";
|
||||||
|
import React from "react";
|
||||||
|
import { useTeam } from "./hooks/query";
|
||||||
|
import Drawer from "expo-router/drawer";
|
||||||
|
import { useTeamId } from "@/stores/auth";
|
||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import HeaderActions from "./components/header-actions";
|
||||||
|
import Icons from "@/components/ui/icons";
|
||||||
|
import tamaguiConfig from "@/tamagui.config";
|
||||||
|
import MemberList from "./components/member-list";
|
||||||
|
import { useUser } from "@/hooks/useUser";
|
||||||
|
import InviteForm, { inviteFormModal } from "./components/invite-form";
|
||||||
|
|
||||||
|
export default function TeamPage() {
|
||||||
|
const teamId = useTeamId();
|
||||||
|
const { isPending, data } = useTeam();
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
if (!teamId || (!isPending && !data)) {
|
||||||
|
return <Redirect href="/" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canWrite = user?.teamCanWrite(teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer.Screen
|
||||||
|
options={{
|
||||||
|
headerTitle: data ? `${data.icon} ${data.name}` : undefined,
|
||||||
|
headerRight: () => <HeaderActions team={data} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
padding: "$4",
|
||||||
|
maxWidth: tamaguiConfig.media.xs.maxWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XStack alignItems="flex-end">
|
||||||
|
<Text fontSize="$8" f={1} mb="$2">
|
||||||
|
Team Members
|
||||||
|
</Text>
|
||||||
|
{canWrite && (
|
||||||
|
<Button
|
||||||
|
icon={<Icons name="account-plus" size={16} />}
|
||||||
|
onPress={() =>
|
||||||
|
inviteFormModal.onOpen({ teamId, username: "", role: "member" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</XStack>
|
||||||
|
<Text>
|
||||||
|
{canWrite
|
||||||
|
? "Manage or view team members here"
|
||||||
|
: "View your team members here"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<MemberList members={data?.members} allowWrite={canWrite} />
|
||||||
|
|
||||||
|
<InviteForm />
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
26
frontend/pages/team/schema/team-form.ts
Normal file
26
frontend/pages/team/schema/team-form.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { SelectItem } from "@/components/ui/select";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const teamFormSchema = z.object({
|
||||||
|
id: z.string().ulid().nullish(),
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
icon: z.string().emoji("Icon is not valid."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TeamFormSchema = z.infer<typeof teamFormSchema>;
|
||||||
|
|
||||||
|
export const inviteSchema = z.object({
|
||||||
|
teamId: z.string().ulid(),
|
||||||
|
username: z.string().min(1, { message: "Username/email is required" }),
|
||||||
|
role: z.enum(["owner", "admin", "member"], {
|
||||||
|
errorMap: () => ({ message: "Role is required" }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamMemberRoles: SelectItem[] = [
|
||||||
|
{ label: "Owner", value: "owner" },
|
||||||
|
{ label: "Admin", value: "admin" },
|
||||||
|
{ label: "Member", value: "member" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type InviteSchema = z.infer<typeof inviteSchema>;
|
@ -56,10 +56,6 @@ func (r *Hosts) Exists(id string) (bool, error) {
|
|||||||
return count > 0, ret.Error
|
return count > 0, ret.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Hosts) Delete(id string) error {
|
|
||||||
return r.db.Delete(&models.Host{Model: models.Model{ID: id}}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Hosts) Create(item *models.Host) error {
|
func (r *Hosts) Create(item *models.Host) error {
|
||||||
return r.db.Create(item).Error
|
return r.db.Create(item).Error
|
||||||
}
|
}
|
||||||
@ -67,3 +63,7 @@ func (r *Hosts) Create(item *models.Host) error {
|
|||||||
func (r *Hosts) Update(id string, item *models.Host) error {
|
func (r *Hosts) Update(id string, item *models.Host) error {
|
||||||
return r.db.Where("id = ?", id).Updates(item).Error
|
return r.db.Where("id = ?", id).Updates(item).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Hosts) Delete(id string) error {
|
||||||
|
return r.db.Delete(&models.Host{Model: models.Model{ID: id}}).Error
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"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/ws"
|
"rul.sh/vaulterm/app/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ func InitRouter(app *fiber.App) {
|
|||||||
routes := []Router{
|
routes := []Router{
|
||||||
hosts.Router,
|
hosts.Router,
|
||||||
keychains.Router,
|
keychains.Router,
|
||||||
|
teams.Router,
|
||||||
ws.Router,
|
ws.Router,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ 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"
|
||||||
@ -22,17 +23,50 @@ func NewRepository(r *Teams) *Teams {
|
|||||||
|
|
||||||
func (r *Teams) GetAll() ([]*models.Team, error) {
|
func (r *Teams) GetAll() ([]*models.Team, error) {
|
||||||
var rows []*models.Team
|
var rows []*models.Team
|
||||||
ret := r.db.Order("created_at DESC").Find(&rows)
|
query := r.db.Order("created_at ASC")
|
||||||
|
|
||||||
|
if !r.User.IsAdmin() {
|
||||||
|
query = query.
|
||||||
|
Joins("JOIN team_members ON team_members.team_id = teams.id").
|
||||||
|
Where("team_members.user_id = ?", r.User.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := query.Find(&rows)
|
||||||
return rows, ret.Error
|
return rows, ret.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Teams) Create(data *models.Team) error {
|
func (r *Teams) Create(data *models.Team) error {
|
||||||
return r.db.Create(data).Error
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(data).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.User.ID != "" {
|
||||||
|
ret := tx.Create(&models.TeamMembers{
|
||||||
|
UserID: r.User.ID,
|
||||||
|
TeamID: data.ID,
|
||||||
|
Role: models.TeamRoleOwner,
|
||||||
|
})
|
||||||
|
if ret.Error != nil {
|
||||||
|
return ret.Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Teams) Get(id string) (*models.Team, error) {
|
func (r *Teams) Get(opt GetOptions) (*models.Team, error) {
|
||||||
|
query := r.db.Where("teams.id = ?", opt.ID)
|
||||||
|
|
||||||
|
if opt.WithMembers {
|
||||||
|
query = query.Preload("Members.User", func(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Select("users.id", "users.name", "users.username", "users.email")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var data models.Team
|
var data models.Team
|
||||||
if err := r.db.Where("id = ?", id).First(&data).Error; err != nil {
|
if err := query.First(&data).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,3 +82,26 @@ func (r *Teams) Exists(id string) (bool, error) {
|
|||||||
func (r *Teams) Update(id string, item *models.Team) error {
|
func (r *Teams) Update(id string, item *models.Team) error {
|
||||||
return r.db.Where("id = ?", id).Updates(item).Error
|
return r.db.Where("id = ?", id).Updates(item).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Teams) Delete(id string) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("team_id = ?", id).Delete(&models.TeamMembers{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", id).Delete(&models.Team{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
149
server/app/teams/router.go
Normal file
149
server/app/teams/router.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package teams
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"rul.sh/vaulterm/app/users"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
|
"rul.sh/vaulterm/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Router(app fiber.Router) {
|
||||||
|
router := app.Group("/teams")
|
||||||
|
|
||||||
|
router.Get("/", getAll)
|
||||||
|
router.Get("/:id", getById)
|
||||||
|
router.Post("/", create)
|
||||||
|
router.Put("/:id", update)
|
||||||
|
router.Delete("/:id", delete)
|
||||||
|
router.Post("/:id/invite", invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAll(c *fiber.Ctx) error {
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
repo := NewRepository(&Teams{User: user})
|
||||||
|
|
||||||
|
rows, err := repo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"rows": rows})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getById(c *fiber.Ctx) error {
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
repo := NewRepository(&Teams{User: user})
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
data, _ := repo.Get(GetOptions{ID: id, WithMembers: true})
|
||||||
|
if data == nil || !user.IsInTeam(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(c *fiber.Ctx) error {
|
||||||
|
var body CreateTeamSchema
|
||||||
|
if err := c.BodyParser(&body); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
repo := NewRepository(&Teams{User: user})
|
||||||
|
|
||||||
|
item := &models.Team{
|
||||||
|
Name: body.Name,
|
||||||
|
Icon: body.Icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Create(item); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(c *fiber.Ctx) error {
|
||||||
|
var body CreateTeamSchema
|
||||||
|
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")
|
||||||
|
data, _ := repo.Get(GetOptions{ID: id})
|
||||||
|
if data == nil {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
if !user.TeamCanWrite(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &models.Team{
|
||||||
|
Name: body.Name,
|
||||||
|
Icon: body.Icon,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Update(id, item); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(c *fiber.Ctx) error {
|
||||||
|
user := utils.GetUser(c)
|
||||||
|
repo := NewRepository(&Teams{User: user})
|
||||||
|
|
||||||
|
id := c.Params("id")
|
||||||
|
data, _ := repo.Get(GetOptions{ID: id})
|
||||||
|
if data == nil {
|
||||||
|
return utils.ResponseError(c, errors.New("team not found"), 404)
|
||||||
|
}
|
||||||
|
if !user.TeamCanWrite(&id) {
|
||||||
|
return utils.ResponseError(c, errors.New("no access"), 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Delete(id); err != nil {
|
||||||
|
return utils.ResponseError(c, err, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
16
server/app/teams/schema.go
Normal file
16
server/app/teams/schema.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package teams
|
||||||
|
|
||||||
|
type CreateTeamSchema struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOptions struct {
|
||||||
|
ID string
|
||||||
|
WithMembers bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteTeamSchema struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
28
server/app/users/repository.go
Normal file
28
server/app/users/repository.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"rul.sh/vaulterm/db"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
|
"rul.sh/vaulterm/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Users struct {
|
||||||
|
db *gorm.DB
|
||||||
|
User *utils.UserContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(r *Users) *Users {
|
||||||
|
if r == nil {
|
||||||
|
r = &Users{}
|
||||||
|
}
|
||||||
|
r.db = db.Get()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Users) Find(username string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
ret := r.db.Where("username = ? OR email = ?", username, username).First(&user)
|
||||||
|
|
||||||
|
return &user, ret.Error
|
||||||
|
}
|
@ -21,7 +21,7 @@ type Team struct {
|
|||||||
|
|
||||||
type TeamMembers struct {
|
type TeamMembers struct {
|
||||||
TeamID string `json:"teamId" gorm:"primarykey;type:varchar(26)"`
|
TeamID string `json:"teamId" gorm:"primarykey;type:varchar(26)"`
|
||||||
Team Team `json:"team"`
|
Team Team `json:"-"`
|
||||||
UserID string `json:"userId" gorm:"primarykey;type:varchar(26)"`
|
UserID string `json:"userId" gorm:"primarykey;type:varchar(26)"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Role string `json:"role" gorm:"type:varchar(16)"`
|
Role string `json:"role" gorm:"type:varchar(16)"`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user