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 {