diff --git a/frontend/app/(drawer)/_layout.tsx b/frontend/app/(drawer)/_layout.tsx index fd06a93..0bea61e 100644 --- a/frontend/app/(drawer)/_layout.tsx +++ b/frontend/app/(drawer)/_layout.tsx @@ -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> </GestureHandlerRootView> ); diff --git a/frontend/app/(drawer)/team.tsx b/frontend/app/(drawer)/team.tsx new file mode 100644 index 0000000..7a859d9 --- /dev/null +++ b/frontend/app/(drawer)/team.tsx @@ -0,0 +1,3 @@ +import TeamPage from "@/pages/team/page"; + +export default TeamPage; diff --git a/frontend/components/containers/server-stats-bar.tsx b/frontend/components/containers/server-stats-bar.tsx index 923503d..e6a64bc 100644 --- a/frontend/components/containers/server-stats-bar.tsx +++ b/frontend/components/containers/server-stats-bar.tsx @@ -56,27 +56,33 @@ const ServerStatsBar = ({ url }: Props) => { <XStack gap="$1" p="$2" alignItems="center"> <XStack gap="$1" alignItems="center" minWidth={48}> <Icons name="desktop-tower" size={16} /> - <Text fontSize="$2">{Math.round(cpu)}%</Text> + <Text fontSize="$2" aria-label="CPU"> + {Math.round(cpu)}% + </Text> </XStack> <Separator vertical h="100%" mx="$2" borderColor="$color" /> <Icons name="memory" size={16} /> - <Text fontSize="$2"> + <Text fontSize="$2" aria-label="Memory"> {memory.used} MB / {memory.total} MB ( {Math.round((memory.used / memory.total) * 100) || 0}%) </Text> <Separator vertical h="100%" mx="$2" borderColor="$color" /> <Icons name="harddisk" size={16} /> - <Text fontSize="$2"> + <Text fontSize="$2" aria-label="Disk"> {disk.used} / {disk.total} ({disk.percent}) </Text> <Separator vertical h="100%" mx="$2" borderColor="$color" /> <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} /> - <Text fontSize="$2">{network.tx} MB</Text> + <Text fontSize="$2" aria-label="Network Sent"> + {network.tx} MB + </Text> </XStack> ); }; diff --git a/frontend/components/containers/user-menu-button.tsx b/frontend/components/containers/user-menu-button.tsx index 51d6569..965def1 100644 --- a/frontend/components/containers/user-menu-button.tsx +++ b/frontend/components/containers/user-menu-button.tsx @@ -13,6 +13,7 @@ import MenuButton from "../ui/menu-button"; import Icons from "../ui/icons"; import { logout, setTeam, useTeamId } from "@/stores/auth"; import { useUser } from "@/hooks/useUser"; +import TeamForm, { teamFormModal } from "@/pages/team/components/team-form"; const UserMenuButton = () => { const user = useUser(); @@ -20,43 +21,46 @@ const UserMenuButton = () => { const team = user?.teams?.find((t: any) => t.id === teamId); return ( - <MenuButton - size="$1" - placement="bottom-end" - width={213} - trigger={ - <Button - bg="$colorTransparent" - justifyContent="flex-start" - p={0} - gap="$1" - > - <Avatar circular size="$3"> - <Avatar.Fallback bg="$blue4" /> - </Avatar> - <View flex={1} style={{ textAlign: "left" }}> - <Text numberOfLines={1}>{user?.name}</Text> - <Text numberOfLines={1} fontWeight="600" mt="$1.5"> - {team ? `${team.icon} ${team.name}` : "Personal"} - </Text> - </View> - <Icons name="chevron-down" size={16} /> - </Button> - } - > - <TeamsMenu /> - <MenuButton.Item - onPress={() => console.log("logout")} - icon={<Icons name="account" size={16} />} - title="Account" - /> - <Separator w="100%" /> - <MenuButton.Item - onPress={() => logout()} - icon={<Icons name="logout" size={16} />} - title="Logout" - /> - </MenuButton> + <> + <MenuButton + size="$1" + placement="bottom-end" + width={213} + trigger={ + <Button + bg="$colorTransparent" + justifyContent="flex-start" + p={0} + gap="$1" + > + <Avatar circular size="$3"> + <Avatar.Fallback bg="$blue4" /> + </Avatar> + <View flex={1} style={{ textAlign: "left" }}> + <Text numberOfLines={1}>{user?.name}</Text> + <Text numberOfLines={1} fontWeight="600" mt="$1.5"> + {team ? `${team.icon} ${team.name}` : "Personal"} + </Text> + </View> + <Icons name="chevron-down" size={16} /> + </Button> + } + > + <TeamsMenu /> + <MenuButton.Item + onPress={() => console.log("logout")} + icon={<Icons name="account" size={16} />} + title="Account" + /> + <Separator w="100%" /> + <MenuButton.Item + onPress={() => logout()} + icon={<Icons name="logout" size={16} />} + title="Logout" + /> + </MenuButton> + <TeamForm /> + </> ); }; @@ -107,6 +111,7 @@ const TeamsMenu = () => { <MenuButton.Item icon={<Icons name="plus" size={16} />} title="Create Team" + onPress={() => teamFormModal.onOpen({ icon: "🍃", name: "" })} /> </MenuButton> ); diff --git a/frontend/components/ui/menu-button.tsx b/frontend/components/ui/menu-button.tsx index 09deef9..55a6896 100644 --- a/frontend/components/ui/menu-button.tsx +++ b/frontend/components/ui/menu-button.tsx @@ -21,7 +21,7 @@ const MenuButtonFrame = ({ ...props }: MenuButtonProps) => { return ( - <Popover {...props}> + <Popover size="$1" {...props}> <Popover.Trigger asChild={asChild}>{trigger}</Popover.Trigger> <Popover.Content diff --git a/frontend/components/ui/modal.tsx b/frontend/components/ui/modal.tsx index 4ef3c18..415b0c2 100644 --- a/frontend/components/ui/modal.tsx +++ b/frontend/components/ui/modal.tsx @@ -9,6 +9,7 @@ type ModalProps = { description?: string; children?: React.ReactNode; width?: number | string; + maxHeight?: number | string; }; const Modal = ({ @@ -17,6 +18,7 @@ const Modal = ({ title, description, width = 512, + maxHeight = 600, }: ModalProps) => { const { open, onOpenChange } = disclosure.use(); @@ -64,7 +66,7 @@ const Modal = ({ width="90%" maxWidth={width} height="90%" - maxHeight={600} + maxHeight={maxHeight} > <View p="$4"> <Dialog.Title>{title}</Dialog.Title> diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7386624..ceabdc1 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,5 +1,5 @@ import { getCurrentServer } from "@/stores/app"; -import authStore from "@/stores/auth"; +import authStore, { logout } from "@/stores/auth"; import { ofetch } from "ofetch"; const api = ofetch.create({ @@ -23,7 +23,7 @@ const api = ofetch.create({ }, onResponseError: (error) => { if (error.response.status === 401 && !!authStore.getState().token) { - authStore.setState({ token: null }); + logout(); throw new Error("Unauthorized"); } diff --git a/frontend/pages/hosts/components/host-list.tsx b/frontend/pages/hosts/components/host-list.tsx index f44a09c..98d0f3d 100644 --- a/frontend/pages/hosts/components/host-list.tsx +++ b/frontend/pages/hosts/components/host-list.tsx @@ -1,7 +1,5 @@ import { View, Text, Spinner } from "tamagui"; import React, { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import api from "@/lib/api"; import { useNavigation } from "expo-router"; import SearchInput from "@/components/ui/search-input"; import { useTermSession } from "@/stores/terminal-sessions"; diff --git a/frontend/pages/hosts/page.tsx b/frontend/pages/hosts/page.tsx index 568bdca..1c3a3ea 100644 --- a/frontend/pages/hosts/page.tsx +++ b/frontend/pages/hosts/page.tsx @@ -6,22 +6,21 @@ import HostForm, { hostFormModal } from "./components/form"; import Icons from "@/components/ui/icons"; import { initialValues } from "./schema/form"; import KeyForm from "../keychains/components/form"; +import { useUser } from "@/hooks/useUser"; +import { useTeamId } from "@/stores/auth"; export default function HostsPage() { + const teamId = useTeamId(); + const user = useUser(); + return ( <> <Drawer.Screen options={{ - headerRight: () => ( - <Button - bg="$colorTransparent" - icon={<Icons name="plus" size={24} />} - onPress={() => hostFormModal.onOpen(initialValues)} - $gtSm={{ mr: "$3" }} - > - New - </Button> - ), + headerRight: + !teamId || user?.teamCanWrite(teamId) + ? () => <AddButton /> + : undefined, }} /> @@ -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> +); diff --git a/frontend/pages/team/components/header-actions.tsx b/frontend/pages/team/components/header-actions.tsx new file mode 100644 index 0000000..0447578 --- /dev/null +++ b/frontend/pages/team/components/header-actions.tsx @@ -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> + ); +} diff --git a/frontend/pages/team/components/invite-form.tsx b/frontend/pages/team/components/invite-form.tsx new file mode 100644 index 0000000..5597084 --- /dev/null +++ b/frontend/pages/team/components/invite-form.tsx @@ -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; diff --git a/frontend/pages/team/components/member-list.tsx b/frontend/pages/team/components/member-list.tsx new file mode 100644 index 0000000..a0f3890 --- /dev/null +++ b/frontend/pages/team/components/member-list.tsx @@ -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; diff --git a/frontend/pages/team/components/team-form.tsx b/frontend/pages/team/components/team-form.tsx new file mode 100644 index 0000000..7b973ab --- /dev/null +++ b/frontend/pages/team/components/team-form.tsx @@ -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; diff --git a/frontend/pages/team/hooks/query.ts b/frontend/pages/team/hooks/query.ts new file mode 100644 index 0000000..1fbf0c4 --- /dev/null +++ b/frontend/pages/team/hooks/query.ts @@ -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] }); + }, + }); +}; diff --git a/frontend/pages/team/page.tsx b/frontend/pages/team/page.tsx new file mode 100644 index 0000000..070c99d --- /dev/null +++ b/frontend/pages/team/page.tsx @@ -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> + </> + ); +} diff --git a/frontend/pages/team/schema/team-form.ts b/frontend/pages/team/schema/team-form.ts new file mode 100644 index 0000000..ef362b7 --- /dev/null +++ b/frontend/pages/team/schema/team-form.ts @@ -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>; diff --git a/server/app/hosts/repository.go b/server/app/hosts/repository.go index 4e9830b..ce45af9 100644 --- a/server/app/hosts/repository.go +++ b/server/app/hosts/repository.go @@ -56,10 +56,6 @@ func (r *Hosts) Exists(id string) (bool, 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 { 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 { 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 +} diff --git a/server/app/router.go b/server/app/router.go index 94751ec..517de11 100644 --- a/server/app/router.go +++ b/server/app/router.go @@ -4,6 +4,7 @@ import ( "github.com/gofiber/fiber/v2" "rul.sh/vaulterm/app/hosts" "rul.sh/vaulterm/app/keychains" + "rul.sh/vaulterm/app/teams" "rul.sh/vaulterm/app/ws" ) @@ -12,6 +13,7 @@ func InitRouter(app *fiber.App) { routes := []Router{ hosts.Router, keychains.Router, + teams.Router, ws.Router, } diff --git a/server/app/teams/repository.go b/server/app/teams/repository.go index efe8aca..fb84366 100644 --- a/server/app/teams/repository.go +++ b/server/app/teams/repository.go @@ -2,6 +2,7 @@ package teams import ( "gorm.io/gorm" + "gorm.io/gorm/clause" "rul.sh/vaulterm/db" "rul.sh/vaulterm/models" "rul.sh/vaulterm/utils" @@ -22,17 +23,50 @@ func NewRepository(r *Teams) *Teams { func (r *Teams) GetAll() ([]*models.Team, error) { 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 } 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 - if err := r.db.Where("id = ?", id).First(&data).Error; err != nil { + if err := query.First(&data).Error; err != nil { 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 { 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 +} diff --git a/server/app/teams/router.go b/server/app/teams/router.go new file mode 100644 index 0000000..980ee38 --- /dev/null +++ b/server/app/teams/router.go @@ -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) +} diff --git a/server/app/teams/schema.go b/server/app/teams/schema.go new file mode 100644 index 0000000..9dd3af9 --- /dev/null +++ b/server/app/teams/schema.go @@ -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"` +} diff --git a/server/app/users/repository.go b/server/app/users/repository.go new file mode 100644 index 0000000..e3ebb1d --- /dev/null +++ b/server/app/users/repository.go @@ -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 +} diff --git a/server/models/team.go b/server/models/team.go index 86e242b..776f631 100644 --- a/server/models/team.go +++ b/server/models/team.go @@ -21,7 +21,7 @@ type Team struct { type TeamMembers struct { 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)"` User User `json:"user"` Role string `json:"role" gorm:"type:varchar(16)"`