From f5250d536119ec7deae34acb28e8c8ac0695cdf7 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat <me@khairul.my.id> Date: Tue, 12 Nov 2024 19:15:13 +0700 Subject: [PATCH] feat: team draft --- frontend/app/_providers.tsx | 2 + frontend/components/containers/drawer.tsx | 20 ++-- .../components/containers/theme-switcher.tsx | 42 +++++--- .../containers/user-menu-button.tsx | 102 ++++++++++++++++++ frontend/components/ui/icons.tsx | 4 + frontend/components/ui/menu-button.tsx | 50 +++++++++ frontend/hooks/useUser.ts | 10 ++ frontend/pages/auth/login.tsx | 3 +- frontend/pages/hosts/components/host-item.tsx | 9 +- .../{hosts-list.tsx => host-list.tsx} | 4 +- frontend/pages/hosts/page.tsx | 4 +- .../keychains/components/input-fields.tsx | 6 +- frontend/pages/server/page.tsx | 3 +- frontend/pages/terminal/page.tsx | 4 +- frontend/repositories/auth.ts | 9 ++ server/db/database.go | 1 + server/db/models.go | 15 ++- server/db/seeders.go | 32 +++++- server/middleware/auth.go | 10 +- server/models/base_model.go | 4 +- server/models/team.go | 23 ++++ server/models/user.go | 2 + server/utils/context.go | 2 +- 23 files changed, 313 insertions(+), 48 deletions(-) create mode 100644 frontend/components/containers/user-menu-button.tsx create mode 100644 frontend/components/ui/menu-button.tsx create mode 100644 frontend/hooks/useUser.ts rename frontend/pages/hosts/components/{hosts-list.tsx => host-list.tsx} (96%) create mode 100644 frontend/repositories/auth.ts create mode 100644 server/models/team.go diff --git a/frontend/app/_providers.tsx b/frontend/app/_providers.tsx index 02a867b..e0dd298 100644 --- a/frontend/app/_providers.tsx +++ b/frontend/app/_providers.tsx @@ -32,6 +32,8 @@ const Providers = ({ children }: Props) => { colors: { ...base.colors, background: theme.background.val, + card: theme.background.val, + border: theme.borderColor.val, }, }; }, [theme, colorScheme]); diff --git a/frontend/components/containers/drawer.tsx b/frontend/components/containers/drawer.tsx index b1ffa3a..f5a4d63 100644 --- a/frontend/components/containers/drawer.tsx +++ b/frontend/components/containers/drawer.tsx @@ -10,27 +10,25 @@ import { useLinkBuilder, } from "@react-navigation/native"; import { Link } from "expo-router"; -import Icons from "../ui/icons"; -import { logout } from "@/stores/auth"; +import ThemeSwitcher from "./theme-switcher"; +import UserMenuButton from "./user-menu-button"; const Drawer = (props: DrawerContentComponentProps) => { return ( <> + <View p="$4"> + <UserMenuButton /> + </View> + <DrawerContentScrollView - contentContainerStyle={{ padding: 18 }} + contentContainerStyle={{ padding: 18, paddingTop: 0 }} {...props} > <DrawerItemList {...props} /> </DrawerContentScrollView> - <View p="$4"> - <Button - justifyContent="flex-start" - icon={<Icons name="logout" size={16} />} - onPress={() => logout()} - > - Logout - </Button> + <View px="$4" py="$2"> + <ThemeSwitcher /> </View> </> ); diff --git a/frontend/components/containers/theme-switcher.tsx b/frontend/components/containers/theme-switcher.tsx index fbd5871..2d401cd 100644 --- a/frontend/components/containers/theme-switcher.tsx +++ b/frontend/components/containers/theme-switcher.tsx @@ -1,28 +1,36 @@ -import React from "react"; -import { Button, GetProps } from "tamagui"; -import Icons from "../ui/icons"; +import React, { useId } from "react"; +import { Switch, GetProps, XStack, Label } from "tamagui"; import useThemeStore from "@/stores/theme"; +import { Ionicons } from "../ui/icons"; -type Props = GetProps<typeof Button> & { +type Props = GetProps<typeof XStack> & { iconSize?: number; }; -const ThemeSwitcher = ({ iconSize = 24, ...props }: Props) => { +const ThemeSwitcher = ({ iconSize = 18, ...props }: Props) => { const { theme, toggle } = useThemeStore(); + const id = useId(); return ( - <Button - icon={ - <Icons - name={ - theme === "light" ? "white-balance-sunny" : "moon-waning-crescent" - } - size={iconSize} - /> - } - onPress={toggle} - {...props} - /> + <XStack alignItems="center" gap="$2"> + <Ionicons + name={theme === "light" ? "moon-outline" : "sunny-outline"} + size={iconSize} + /> + <Label htmlFor={id} flex={1} cursor="pointer"> + {`${theme === "light" ? "Dark" : "Light"} Mode`} + </Label> + <Switch + id={id} + onPress={toggle} + checked={theme === "dark"} + size="$2" + cursor="pointer" + {...props} + > + <Switch.Thumb animation="quicker" /> + </Switch> + </XStack> ); }; diff --git a/frontend/components/containers/user-menu-button.tsx b/frontend/components/containers/user-menu-button.tsx new file mode 100644 index 0000000..db592c3 --- /dev/null +++ b/frontend/components/containers/user-menu-button.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { + Avatar, + Button, + ListItem, + Separator, + Text, + useMedia, + View, + YGroup, +} from "tamagui"; +import MenuButton from "../ui/menu-button"; +import Icons from "../ui/icons"; +import { logout } from "@/stores/auth"; +import { useUser } from "@/hooks/useUser"; + +const UserMenuButton = () => { + const user = useUser(); + + 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"> + 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> + ); +}; + +const TeamsMenu = () => { + const media = useMedia(); + const user = useUser(); + const teams = user?.teams || []; + + return ( + <MenuButton + size="$1" + placement={media.xs ? "bottom" : "right-start"} + asChild + width={213} + trigger={ + <ListItem + hoverTheme + pressTheme + onPress={() => console.log("logout")} + icon={<Icons name="account-group" size={16} />} + title="Teams" + iconAfter={<Icons name="chevron-right" size={16} />} + /> + } + > + <MenuButton.Item + icon={<Icons name="account" size={16} />} + title="Personal" + /> + + {teams.map((team: any) => ( + <MenuButton.Item icon={<Text>{team.icon}</Text>} title={team.name} /> + ))} + + {teams.length > 0 && <Separator width="100%" />} + + <MenuButton.Item + icon={<Icons name="plus" size={16} />} + title="Create Team" + /> + </MenuButton> + ); +}; + +export default UserMenuButton; diff --git a/frontend/components/ui/icons.tsx b/frontend/components/ui/icons.tsx index 216e7af..6d6f639 100644 --- a/frontend/components/ui/icons.tsx +++ b/frontend/components/ui/icons.tsx @@ -1,8 +1,12 @@ import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; +import IoniconsIcon from "@expo/vector-icons/Ionicons"; import { styled } from "tamagui"; export const Icons = styled(MaterialCommunityIcons, { color: "$color", }); +export const Ionicons = styled(IoniconsIcon, { + color: "$color", +}); export default Icons; diff --git a/frontend/components/ui/menu-button.tsx b/frontend/components/ui/menu-button.tsx new file mode 100644 index 0000000..09deef9 --- /dev/null +++ b/frontend/components/ui/menu-button.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { + GetProps, + ListItem, + Popover, + styled, + withStaticProperties, +} from "tamagui"; + +type MenuButtonProps = GetProps<typeof Popover> & { + asChild?: boolean; + trigger?: React.ReactNode; + width?: string | number | null; +}; + +const MenuButtonFrame = ({ + asChild, + trigger, + children, + width, + ...props +}: MenuButtonProps) => { + return ( + <Popover {...props}> + <Popover.Trigger asChild={asChild}>{trigger}</Popover.Trigger> + + <Popover.Content + bordered + enterStyle={{ y: -10, opacity: 0 }} + exitStyle={{ y: -10, opacity: 0 }} + animation={["quick", { opacity: { overshootClamping: true } }]} + width={width} + > + {children} + </Popover.Content> + </Popover> + ); +}; + +const MenuButtonItem = (props: GetProps<typeof ListItem>) => ( + <Popover.Close asChild> + <ListItem hoverTheme pressTheme {...props} /> + </Popover.Close> +); + +const MenuButton = withStaticProperties(MenuButtonFrame, { + Item: MenuButtonItem, +}); + +export default MenuButton; diff --git a/frontend/hooks/useUser.ts b/frontend/hooks/useUser.ts new file mode 100644 index 0000000..08cf7f1 --- /dev/null +++ b/frontend/hooks/useUser.ts @@ -0,0 +1,10 @@ +import authRepo from "@/repositories/auth"; +import { useQuery } from "@tanstack/react-query"; + +export const useUser = () => { + const { data: user } = useQuery({ + queryKey: ["auth", "user"], + queryFn: authRepo.getUser, + }); + return user; +}; diff --git a/frontend/pages/auth/login.tsx b/frontend/pages/auth/login.tsx index 73eae9e..4563872 100644 --- a/frontend/pages/auth/login.tsx +++ b/frontend/pages/auth/login.tsx @@ -13,6 +13,7 @@ import { loginResultSchema, loginSchema } from "./schema"; import api from "@/lib/api"; import Icons from "@/components/ui/icons"; import authStore from "@/stores/auth"; +import tamaguiConfig from "@/tamagui.config"; export default function LoginPage() { const form = useZForm(loginSchema); @@ -42,7 +43,7 @@ export default function LoginPage() { options={{ contentStyle: { width: "100%", - maxWidth: 600, + maxWidth: tamaguiConfig.media.xs.maxWidth, marginHorizontal: "auto", }, title: "Login", diff --git a/frontend/pages/hosts/components/host-item.tsx b/frontend/pages/hosts/components/host-item.tsx index 834ab39..4a406f8 100644 --- a/frontend/pages/hosts/components/host-item.tsx +++ b/frontend/pages/hosts/components/host-item.tsx @@ -26,6 +26,7 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => { name={host.os} size={18} mr="$2" + mt="$1" fallback="desktop-classic" /> @@ -39,9 +40,11 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => { {onEdit != null && ( <Button circular - display="none" - $sm={{ display: "block" }} - $group-hover={{ display: "block" }} + opacity={0} + $sm={{ opacity: 1 }} + $group-hover={{ opacity: 1 }} + animation="quick" + animateOnly={["opacity"]} onPress={(e) => { e.stopPropagation(); onEdit(); diff --git a/frontend/pages/hosts/components/hosts-list.tsx b/frontend/pages/hosts/components/host-list.tsx similarity index 96% rename from frontend/pages/hosts/components/hosts-list.tsx rename to frontend/pages/hosts/components/host-list.tsx index 00cf6ab..d1a949f 100644 --- a/frontend/pages/hosts/components/hosts-list.tsx +++ b/frontend/pages/hosts/components/host-list.tsx @@ -13,7 +13,7 @@ type HostsListProps = { allowEdit?: boolean; }; -const HostsList = ({ allowEdit = true }: HostsListProps) => { +const HostList = ({ allowEdit = true }: HostsListProps) => { const openSession = useTermSession((i) => i.push); const navigation = useNavigation(); const [search, setSearch] = useState(""); @@ -98,4 +98,4 @@ const HostsList = ({ allowEdit = true }: HostsListProps) => { ); }; -export default React.memo(HostsList); +export default React.memo(HostList); diff --git a/frontend/pages/hosts/page.tsx b/frontend/pages/hosts/page.tsx index 8670b25..568bdca 100644 --- a/frontend/pages/hosts/page.tsx +++ b/frontend/pages/hosts/page.tsx @@ -1,7 +1,7 @@ import { Button } from "tamagui"; import React from "react"; import Drawer from "expo-router/drawer"; -import HostsList from "./components/hosts-list"; +import HostList from "./components/host-list"; import HostForm, { hostFormModal } from "./components/form"; import Icons from "@/components/ui/icons"; import { initialValues } from "./schema/form"; @@ -25,7 +25,7 @@ export default function HostsPage() { }} /> - <HostsList /> + <HostList /> <HostForm /> <KeyForm /> </> diff --git a/frontend/pages/keychains/components/input-fields.tsx b/frontend/pages/keychains/components/input-fields.tsx index 4c286ab..74cf3ce 100644 --- a/frontend/pages/keychains/components/input-fields.tsx +++ b/frontend/pages/keychains/components/input-fields.tsx @@ -16,7 +16,7 @@ export const UserTypeInputFields = ({ form }: Props) => { <InputField f={1} form={form} name="data.username" /> </FormField> <FormField label="Password"> - <InputField f={1} form={form} name="data.password" /> + <InputField f={1} form={form} name="data.password" secureTextEntry /> </FormField> </> ); @@ -32,7 +32,7 @@ export const PVETypeInputFields = ({ form }: Props) => { <SelectField form={form} name="data.realm" items={pveRealms} /> </FormField> <FormField label="Password"> - <InputField f={1} form={form} name="data.password" /> + <InputField f={1} form={form} name="data.password" secureTextEntry /> </FormField> </> ); @@ -48,7 +48,7 @@ export const RSATypeInputFields = ({ form }: Props) => { <TextAreaField rows={7} f={1} form={form} name="data.private" /> </FormField> <FormField label="Passphrase"> - <InputField f={1} form={form} name="data.passphrase" /> + <InputField f={1} form={form} name="data.passphrase" secureTextEntry /> </FormField> </> ); diff --git a/frontend/pages/server/page.tsx b/frontend/pages/server/page.tsx index 0baa84b..22008e3 100644 --- a/frontend/pages/server/page.tsx +++ b/frontend/pages/server/page.tsx @@ -12,6 +12,7 @@ import { ofetch } from "ofetch"; import { z } from "zod"; import { ErrorAlert } from "@/components/ui/alert"; import { addServer } from "@/stores/app"; +import tamaguiConfig from "@/tamagui.config"; export default function ServerPage() { const form = useZForm(serverSchema); @@ -41,7 +42,7 @@ export default function ServerPage() { options={{ contentStyle: { width: "100%", - maxWidth: 600, + maxWidth: tamaguiConfig.media.xs.maxWidth, marginHorizontal: "auto", }, title: "Vaulterm", diff --git a/frontend/pages/terminal/page.tsx b/frontend/pages/terminal/page.tsx index 178b45d..0889f00 100644 --- a/frontend/pages/terminal/page.tsx +++ b/frontend/pages/terminal/page.tsx @@ -4,7 +4,7 @@ import PagerView from "@/components/ui/pager-view"; import { useTermSession } from "@/stores/terminal-sessions"; import { Button, useMedia } from "tamagui"; import SessionTabs from "./components/session-tabs"; -import HostsList from "../hosts/components/hosts-list"; +import HostList from "../hosts/components/host-list"; import Drawer from "expo-router/drawer"; import { router } from "expo-router"; import Icons from "@/components/ui/icons"; @@ -35,7 +35,7 @@ const TerminalPage = () => { style={{ flex: 1 }} page={curSession} onChangePage={setSession} - EmptyComponent={() => <HostsList allowEdit={false} />} + EmptyComponent={() => <HostList allowEdit={false} />} > {sessions.map((session) => ( <InteractiveSession key={session.id} {...session} /> diff --git a/frontend/repositories/auth.ts b/frontend/repositories/auth.ts new file mode 100644 index 0000000..62444af --- /dev/null +++ b/frontend/repositories/auth.ts @@ -0,0 +1,9 @@ +import api from "@/lib/api"; + +const authRepo = { + getUser() { + return api("/auth/user"); + }, +}; + +export default authRepo; diff --git a/server/db/database.go b/server/db/database.go index a44e976..c3f9e03 100644 --- a/server/db/database.go +++ b/server/db/database.go @@ -45,6 +45,7 @@ func Init() { // Migrate the schema db.AutoMigrate(Models...) + InitModels(db) runSeeders(db) } diff --git a/server/db/models.go b/server/db/models.go index a84fba5..664d957 100644 --- a/server/db/models.go +++ b/server/db/models.go @@ -1,10 +1,23 @@ package db -import "rul.sh/vaulterm/models" +import ( + "log" + + "gorm.io/gorm" + "rul.sh/vaulterm/models" +) var Models = []interface{}{ &models.User{}, &models.UserSession{}, &models.Keychain{}, &models.Host{}, + &models.Team{}, + &models.TeamMembers{}, +} + +func InitModels(db *gorm.DB) { + if err := db.SetupJoinTable(&models.Team{}, "Members", &models.TeamMembers{}); err != nil { + log.Fatal(err) + } } diff --git a/server/db/seeders.go b/server/db/seeders.go index adc2887..4da3a9c 100644 --- a/server/db/seeders.go +++ b/server/db/seeders.go @@ -28,7 +28,18 @@ func seedUsers(tx *gorm.DB) error { return err } - userList := []models.User{ + teams := []*models.Team{ + { + Name: "My Team", + Icon: "☘️", + }, + } + + if res := tx.Create(&teams); res.Error != nil { + return res.Error + } + + userList := []*models.User{ { Name: "Admin", Username: "admin", @@ -42,22 +53,39 @@ func seedUsers(tx *gorm.DB) error { Password: testPasswd, Email: "user@mail.com", }, + { + Name: "Mary Doe", + Username: "user2", + Password: testPasswd, + Email: "user2@mail.com", + }, } if res := tx.Create(&userList); res.Error != nil { return res.Error } + teamMembers := []models.TeamMembers{ + {TeamID: teams[0].ID, UserID: userList[0].ID, Role: "owner"}, + {TeamID: teams[0].ID, UserID: userList[1].ID, Role: "admin"}, + {TeamID: teams[0].ID, UserID: userList[2].ID, Role: "user"}, + } + + if res := tx.Create(&teamMembers); res.Error != nil { + return res.Error + } + return nil } func runSeeders(db *gorm.DB) { db.Transaction(func(tx *gorm.DB) error { for _, seed := range seeders { - if err := seed(db); err != nil { + if err := seed(tx); err != nil { return err } } + return nil }) } diff --git a/server/middleware/auth.go b/server/middleware/auth.go index 559c7e7..b09c661 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" + "gorm.io/gorm" "rul.sh/vaulterm/db" "rul.sh/vaulterm/models" ) @@ -32,7 +33,14 @@ func Auth(c *fiber.Ctx) error { func GetUserSession(sessionId string) (*models.UserSession, error) { var session models.UserSession - res := db.Get().Joins("User").Where("user_sessions.id = ?", sessionId).First(&session) + res := db.Get(). + Joins("User"). + Preload("User.Teams", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "name", "icon") + }). + Where("user_sessions.id = ?", sessionId). + First(&session) + return &session, res.Error } diff --git a/server/models/base_model.go b/server/models/base_model.go index fb48eea..a97a740 100644 --- a/server/models/base_model.go +++ b/server/models/base_model.go @@ -13,7 +13,9 @@ type Model struct { } func (m *Model) BeforeCreate(tx *gorm.DB) error { - m.ID = m.GenerateID() + if m.ID == "" { + m.ID = m.GenerateID() + } return nil } diff --git a/server/models/team.go b/server/models/team.go new file mode 100644 index 0000000..2d607e0 --- /dev/null +++ b/server/models/team.go @@ -0,0 +1,23 @@ +package models + +import "time" + +type Team struct { + Model + + Name string `json:"name" gorm:"type:varchar(32)"` + Icon string `json:"icon" gorm:"type:varchar(2)"` + Members []*User `json:"members" gorm:"many2many:team_members"` + + Timestamps + SoftDeletes +} + +type TeamMembers struct { + TeamID string `json:"teamId" gorm:"primarykey;type:varchar(26)"` + Team Team `json:"team"` + UserID string `json:"userId" gorm:"primarykey;type:varchar(26)"` + User User `json:"user"` + Role string `json:"role" gorm:"type:varchar(16)"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/server/models/user.go b/server/models/user.go index d77bc83..cfc0b57 100644 --- a/server/models/user.go +++ b/server/models/user.go @@ -14,6 +14,8 @@ type User struct { Email string `json:"email" gorm:"unique"` Role string `json:"role" gorm:"default:user;not null;index:users_role_idx;type:varchar(8)"` + Teams []*Team `json:"teams" gorm:"many2many:team_members"` + Timestamps SoftDeletes } diff --git a/server/utils/context.go b/server/utils/context.go index e58d5a8..a1bbbcf 100644 --- a/server/utils/context.go +++ b/server/utils/context.go @@ -8,7 +8,7 @@ import ( type UserContext struct { *models.User - IsAdmin bool + IsAdmin bool `json:"isAdmin"` } func getUserData(user *models.User) *UserContext {