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() { ), }} /> + ( + + ), + } as DrawerNavigationOptions + } + /> ); 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) => { - {Math.round(cpu)}% + + {Math.round(cpu)}% + - + {memory.used} MB / {memory.total} MB ( {Math.round((memory.used / memory.total) * 100) || 0}%) - + {disk.used} / {disk.total} ({disk.percent}) - {network.rx} MB + + {network.rx} MB + - {network.tx} MB + + {network.tx} MB + ); }; 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 ( - - - - - - {user?.name} - - {team ? `${team.icon} ${team.name}` : "Personal"} - - - - - } - > - - console.log("logout")} - icon={} - title="Account" - /> - - logout()} - icon={} - title="Logout" - /> - + <> + + + + + + {user?.name} + + {team ? `${team.icon} ${team.name}` : "Personal"} + + + + + } + > + + console.log("logout")} + icon={} + title="Account" + /> + + logout()} + icon={} + title="Logout" + /> + + + ); }; @@ -107,6 +111,7 @@ const TeamsMenu = () => { } title="Create Team" + onPress={() => teamFormModal.onOpen({ icon: "🍃", name: "" })} /> ); 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 ( - + {trigger} { const { open, onOpenChange } = disclosure.use(); @@ -64,7 +66,7 @@ const Modal = ({ width="90%" maxWidth={width} height="90%" - maxHeight={600} + maxHeight={maxHeight} > {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 ( <> ( - - ), + headerRight: + !teamId || user?.teamCanWrite(teamId) + ? () => + : undefined, }} /> @@ -31,3 +30,14 @@ export default function HostsPage() { ); } + +const AddButton = () => ( + +); 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 ( + } + /> + } + > + } + onPress={() => teamFormModal.onOpen(team)} + > + Update Team + + + ); +} 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(); + +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 ( + + + + + + + + + + + + + + + + + + ); +}; + +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 ( + + + + + {memberList?.map((member: any) => ( + + + + + } + iconAfter={ + allowWrite ? : undefined + } + /> + + ))} + + + ); +}; + +type MemberActionButtonProps = { + member: any; +}; + +const MemberActionButton = ({ member }: MemberActionButtonProps) => ( + } + circular + bg="$colorTransparent" + /> + } + > + }> + Change Role + + }> + Remove Member + + +); + +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(); + +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 ( + + + + + + + + + + + + + + + + + + + ); +}; + +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 ; + } + + const canWrite = user?.teamCanWrite(teamId); + + return ( + <> + , + }} + /> + + + + + Team Members + + {canWrite && ( + + )} + + + {canWrite + ? "Manage or view team members here" + : "View your team members here"} + + + + + + + + ); +} 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; + +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; 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)"`