feat: team page

This commit is contained in:
Khairul Hidayat 2024-11-14 17:58:19 +07:00
parent 7a00992ff9
commit 71dfbd5db3
23 changed files with 790 additions and 67 deletions

View File

@ -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>
); );

View File

@ -0,0 +1,3 @@
import TeamPage from "@/pages/team/page";
export default TeamPage;

View File

@ -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>
); );
}; };

View File

@ -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>
); );

View File

@ -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

View File

@ -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>

View File

@ -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");
} }

View File

@ -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";

View File

@ -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>
);

View 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>
);
}

View 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;

View 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;

View 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;

View 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] });
},
});
};

View 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>
</>
);
}

View 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>;

View File

@ -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
}

View File

@ -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,
} }

View File

@ -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(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")
})
} }
func (r *Teams) Get(id string) (*models.Team, error) {
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
View 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)
}

View 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"`
}

View 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
}

View File

@ -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)"`