feat: team draft

This commit is contained in:
Khairul Hidayat 2024-11-12 19:15:13 +07:00
parent 8159b65605
commit f5250d5361
23 changed files with 313 additions and 48 deletions

View File

@ -32,6 +32,8 @@ const Providers = ({ children }: Props) => {
colors: { colors: {
...base.colors, ...base.colors,
background: theme.background.val, background: theme.background.val,
card: theme.background.val,
border: theme.borderColor.val,
}, },
}; };
}, [theme, colorScheme]); }, [theme, colorScheme]);

View File

@ -10,27 +10,25 @@ import {
useLinkBuilder, useLinkBuilder,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { Link } from "expo-router"; import { Link } from "expo-router";
import Icons from "../ui/icons"; import ThemeSwitcher from "./theme-switcher";
import { logout } from "@/stores/auth"; import UserMenuButton from "./user-menu-button";
const Drawer = (props: DrawerContentComponentProps) => { const Drawer = (props: DrawerContentComponentProps) => {
return ( return (
<> <>
<View p="$4">
<UserMenuButton />
</View>
<DrawerContentScrollView <DrawerContentScrollView
contentContainerStyle={{ padding: 18 }} contentContainerStyle={{ padding: 18, paddingTop: 0 }}
{...props} {...props}
> >
<DrawerItemList {...props} /> <DrawerItemList {...props} />
</DrawerContentScrollView> </DrawerContentScrollView>
<View p="$4"> <View px="$4" py="$2">
<Button <ThemeSwitcher />
justifyContent="flex-start"
icon={<Icons name="logout" size={16} />}
onPress={() => logout()}
>
Logout
</Button>
</View> </View>
</> </>
); );

View File

@ -1,28 +1,36 @@
import React from "react"; import React, { useId } from "react";
import { Button, GetProps } from "tamagui"; import { Switch, GetProps, XStack, Label } from "tamagui";
import Icons from "../ui/icons";
import useThemeStore from "@/stores/theme"; import useThemeStore from "@/stores/theme";
import { Ionicons } from "../ui/icons";
type Props = GetProps<typeof Button> & { type Props = GetProps<typeof XStack> & {
iconSize?: number; iconSize?: number;
}; };
const ThemeSwitcher = ({ iconSize = 24, ...props }: Props) => { const ThemeSwitcher = ({ iconSize = 18, ...props }: Props) => {
const { theme, toggle } = useThemeStore(); const { theme, toggle } = useThemeStore();
const id = useId();
return ( return (
<Button <XStack alignItems="center" gap="$2">
icon={ <Ionicons
<Icons name={theme === "light" ? "moon-outline" : "sunny-outline"}
name={ size={iconSize}
theme === "light" ? "white-balance-sunny" : "moon-waning-crescent" />
} <Label htmlFor={id} flex={1} cursor="pointer">
size={iconSize} {`${theme === "light" ? "Dark" : "Light"} Mode`}
/> </Label>
} <Switch
onPress={toggle} id={id}
{...props} onPress={toggle}
/> checked={theme === "dark"}
size="$2"
cursor="pointer"
{...props}
>
<Switch.Thumb animation="quicker" />
</Switch>
</XStack>
); );
}; };

View File

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

View File

@ -1,8 +1,12 @@
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
import IoniconsIcon from "@expo/vector-icons/Ionicons";
import { styled } from "tamagui"; import { styled } from "tamagui";
export const Icons = styled(MaterialCommunityIcons, { export const Icons = styled(MaterialCommunityIcons, {
color: "$color", color: "$color",
}); });
export const Ionicons = styled(IoniconsIcon, {
color: "$color",
});
export default Icons; export default Icons;

View File

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

10
frontend/hooks/useUser.ts Normal file
View File

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

View File

@ -13,6 +13,7 @@ import { loginResultSchema, loginSchema } from "./schema";
import api from "@/lib/api"; import api from "@/lib/api";
import Icons from "@/components/ui/icons"; import Icons from "@/components/ui/icons";
import authStore from "@/stores/auth"; import authStore from "@/stores/auth";
import tamaguiConfig from "@/tamagui.config";
export default function LoginPage() { export default function LoginPage() {
const form = useZForm(loginSchema); const form = useZForm(loginSchema);
@ -42,7 +43,7 @@ export default function LoginPage() {
options={{ options={{
contentStyle: { contentStyle: {
width: "100%", width: "100%",
maxWidth: 600, maxWidth: tamaguiConfig.media.xs.maxWidth,
marginHorizontal: "auto", marginHorizontal: "auto",
}, },
title: "Login", title: "Login",

View File

@ -26,6 +26,7 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
name={host.os} name={host.os}
size={18} size={18}
mr="$2" mr="$2"
mt="$1"
fallback="desktop-classic" fallback="desktop-classic"
/> />
@ -39,9 +40,11 @@ const HostItem = ({ host, onMultiTap, onTap, onEdit }: HostItemProps) => {
{onEdit != null && ( {onEdit != null && (
<Button <Button
circular circular
display="none" opacity={0}
$sm={{ display: "block" }} $sm={{ opacity: 1 }}
$group-hover={{ display: "block" }} $group-hover={{ opacity: 1 }}
animation="quick"
animateOnly={["opacity"]}
onPress={(e) => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(); onEdit();

View File

@ -13,7 +13,7 @@ type HostsListProps = {
allowEdit?: boolean; allowEdit?: boolean;
}; };
const HostsList = ({ allowEdit = true }: HostsListProps) => { const HostList = ({ allowEdit = true }: HostsListProps) => {
const openSession = useTermSession((i) => i.push); const openSession = useTermSession((i) => i.push);
const navigation = useNavigation(); const navigation = useNavigation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -98,4 +98,4 @@ const HostsList = ({ allowEdit = true }: HostsListProps) => {
); );
}; };
export default React.memo(HostsList); export default React.memo(HostList);

View File

@ -1,7 +1,7 @@
import { Button } from "tamagui"; import { Button } from "tamagui";
import React from "react"; import React from "react";
import Drawer from "expo-router/drawer"; 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 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";
@ -25,7 +25,7 @@ export default function HostsPage() {
}} }}
/> />
<HostsList /> <HostList />
<HostForm /> <HostForm />
<KeyForm /> <KeyForm />
</> </>

View File

@ -16,7 +16,7 @@ export const UserTypeInputFields = ({ form }: Props) => {
<InputField f={1} form={form} name="data.username" /> <InputField f={1} form={form} name="data.username" />
</FormField> </FormField>
<FormField label="Password"> <FormField label="Password">
<InputField f={1} form={form} name="data.password" /> <InputField f={1} form={form} name="data.password" secureTextEntry />
</FormField> </FormField>
</> </>
); );
@ -32,7 +32,7 @@ export const PVETypeInputFields = ({ form }: Props) => {
<SelectField form={form} name="data.realm" items={pveRealms} /> <SelectField form={form} name="data.realm" items={pveRealms} />
</FormField> </FormField>
<FormField label="Password"> <FormField label="Password">
<InputField f={1} form={form} name="data.password" /> <InputField f={1} form={form} name="data.password" secureTextEntry />
</FormField> </FormField>
</> </>
); );
@ -48,7 +48,7 @@ export const RSATypeInputFields = ({ form }: Props) => {
<TextAreaField rows={7} f={1} form={form} name="data.private" /> <TextAreaField rows={7} f={1} form={form} name="data.private" />
</FormField> </FormField>
<FormField label="Passphrase"> <FormField label="Passphrase">
<InputField f={1} form={form} name="data.passphrase" /> <InputField f={1} form={form} name="data.passphrase" secureTextEntry />
</FormField> </FormField>
</> </>
); );

View File

@ -12,6 +12,7 @@ import { ofetch } from "ofetch";
import { z } from "zod"; import { z } from "zod";
import { ErrorAlert } from "@/components/ui/alert"; import { ErrorAlert } from "@/components/ui/alert";
import { addServer } from "@/stores/app"; import { addServer } from "@/stores/app";
import tamaguiConfig from "@/tamagui.config";
export default function ServerPage() { export default function ServerPage() {
const form = useZForm(serverSchema); const form = useZForm(serverSchema);
@ -41,7 +42,7 @@ export default function ServerPage() {
options={{ options={{
contentStyle: { contentStyle: {
width: "100%", width: "100%",
maxWidth: 600, maxWidth: tamaguiConfig.media.xs.maxWidth,
marginHorizontal: "auto", marginHorizontal: "auto",
}, },
title: "Vaulterm", title: "Vaulterm",

View File

@ -4,7 +4,7 @@ import PagerView from "@/components/ui/pager-view";
import { useTermSession } from "@/stores/terminal-sessions"; import { useTermSession } from "@/stores/terminal-sessions";
import { Button, useMedia } from "tamagui"; import { Button, useMedia } from "tamagui";
import SessionTabs from "./components/session-tabs"; 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 Drawer from "expo-router/drawer";
import { router } from "expo-router"; import { router } from "expo-router";
import Icons from "@/components/ui/icons"; import Icons from "@/components/ui/icons";
@ -35,7 +35,7 @@ const TerminalPage = () => {
style={{ flex: 1 }} style={{ flex: 1 }}
page={curSession} page={curSession}
onChangePage={setSession} onChangePage={setSession}
EmptyComponent={() => <HostsList allowEdit={false} />} EmptyComponent={() => <HostList allowEdit={false} />}
> >
{sessions.map((session) => ( {sessions.map((session) => (
<InteractiveSession key={session.id} {...session} /> <InteractiveSession key={session.id} {...session} />

View File

@ -0,0 +1,9 @@
import api from "@/lib/api";
const authRepo = {
getUser() {
return api("/auth/user");
},
};
export default authRepo;

View File

@ -45,6 +45,7 @@ func Init() {
// Migrate the schema // Migrate the schema
db.AutoMigrate(Models...) db.AutoMigrate(Models...)
InitModels(db)
runSeeders(db) runSeeders(db)
} }

View File

@ -1,10 +1,23 @@
package db package db
import "rul.sh/vaulterm/models" import (
"log"
"gorm.io/gorm"
"rul.sh/vaulterm/models"
)
var Models = []interface{}{ var Models = []interface{}{
&models.User{}, &models.User{},
&models.UserSession{}, &models.UserSession{},
&models.Keychain{}, &models.Keychain{},
&models.Host{}, &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)
}
} }

View File

@ -28,7 +28,18 @@ func seedUsers(tx *gorm.DB) error {
return err 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", Name: "Admin",
Username: "admin", Username: "admin",
@ -42,22 +53,39 @@ func seedUsers(tx *gorm.DB) error {
Password: testPasswd, Password: testPasswd,
Email: "user@mail.com", Email: "user@mail.com",
}, },
{
Name: "Mary Doe",
Username: "user2",
Password: testPasswd,
Email: "user2@mail.com",
},
} }
if res := tx.Create(&userList); res.Error != nil { if res := tx.Create(&userList); res.Error != nil {
return res.Error 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 return nil
} }
func runSeeders(db *gorm.DB) { func runSeeders(db *gorm.DB) {
db.Transaction(func(tx *gorm.DB) error { db.Transaction(func(tx *gorm.DB) error {
for _, seed := range seeders { for _, seed := range seeders {
if err := seed(db); err != nil { if err := seed(tx); err != nil {
return err return err
} }
} }
return nil return nil
}) })
} }

View File

@ -4,6 +4,7 @@ import (
"strings" "strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm"
"rul.sh/vaulterm/db" "rul.sh/vaulterm/db"
"rul.sh/vaulterm/models" "rul.sh/vaulterm/models"
) )
@ -32,7 +33,14 @@ func Auth(c *fiber.Ctx) error {
func GetUserSession(sessionId string) (*models.UserSession, error) { func GetUserSession(sessionId string) (*models.UserSession, error) {
var session models.UserSession 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 return &session, res.Error
} }

View File

@ -13,7 +13,9 @@ type Model struct {
} }
func (m *Model) BeforeCreate(tx *gorm.DB) error { func (m *Model) BeforeCreate(tx *gorm.DB) error {
m.ID = m.GenerateID() if m.ID == "" {
m.ID = m.GenerateID()
}
return nil return nil
} }

23
server/models/team.go Normal file
View File

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

View File

@ -14,6 +14,8 @@ type User struct {
Email string `json:"email" gorm:"unique"` Email string `json:"email" gorm:"unique"`
Role string `json:"role" gorm:"default:user;not null;index:users_role_idx;type:varchar(8)"` 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 Timestamps
SoftDeletes SoftDeletes
} }

View File

@ -8,7 +8,7 @@ import (
type UserContext struct { type UserContext struct {
*models.User *models.User
IsAdmin bool IsAdmin bool `json:"isAdmin"`
} }
func getUserData(user *models.User) *UserContext { func getUserData(user *models.User) *UserContext {