mirror of
https://github.com/khairul169/vaulterm.git
synced 2026-09-15 17:03:30 +07:00
feat: add team member role change & removal, add uptime server stats, etc
This commit is contained in:
@@ -13,6 +13,7 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { PortalProvider } from "tamagui";
|
||||
import { useServer } from "@/stores/app";
|
||||
import queryClient from "@/lib/queryClient";
|
||||
import DialogMessageProvider from "@/components/containers/dialog-message";
|
||||
|
||||
type Props = PropsWithChildren;
|
||||
|
||||
@@ -45,6 +46,7 @@ const Providers = ({ children }: Props) => {
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
||||
<Theme name="blue">
|
||||
<PortalProvider shouldAddRootHost>{children}</PortalProvider>
|
||||
<DialogMessageProvider />
|
||||
</Theme>
|
||||
</TamaguiProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { View, Text } from "react-native";
|
||||
import React from "react";
|
||||
import Modal from "../ui/modal";
|
||||
import { dialogStore } from "@/hooks/useDialog";
|
||||
import { Button, XStack } from "tamagui";
|
||||
|
||||
const DialogMessageProvider = () => {
|
||||
const { data, onClose } = dialogStore.use();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
disclosure={dialogStore}
|
||||
title={data?.title}
|
||||
description={data?.description}
|
||||
height="auto"
|
||||
>
|
||||
<XStack p="$4" gap="$4">
|
||||
<Button flex={1} onPress={data?.onCancel} bg="$colorTransparent">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
flex={1}
|
||||
onPress={() => {
|
||||
data?.onConfirm?.();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</XStack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogMessageProvider;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { View, Text, XStack, Separator } from "tamagui";
|
||||
import { View, Text, XStack, Separator, ScrollView } from "tamagui";
|
||||
import React, { useState } from "react";
|
||||
import { useWebSocket } from "@/hooks/useWebsocket";
|
||||
import Icons from "../ui/icons";
|
||||
import { formatDuration } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
@@ -12,6 +13,7 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
const [memory, setMemory] = useState({ total: 0, used: 0, available: 0 });
|
||||
const [disk, setDisk] = useState({ total: "0", used: "0", percent: "0%" });
|
||||
const [network, setNetwork] = useState({ tx: 0, rx: 0 });
|
||||
const [uptime, setUptime] = useState(0);
|
||||
|
||||
const { isConnected } = useWebSocket(url, {
|
||||
onMessage: (msg) => {
|
||||
@@ -44,6 +46,10 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
rx: parseInt(values[1]) || 0,
|
||||
});
|
||||
break;
|
||||
|
||||
case "\x05":
|
||||
setUptime(parseInt(value) || 0);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -53,7 +59,15 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack gap="$1" p="$2" alignItems="center">
|
||||
<ScrollView
|
||||
horizontal
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: "$1",
|
||||
padding: "$2",
|
||||
}}
|
||||
>
|
||||
<XStack gap="$1" alignItems="center" minWidth={48}>
|
||||
<Icons name="desktop-tower" size={16} />
|
||||
<Text fontSize="$2" aria-label="CPU">
|
||||
@@ -61,21 +75,18 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="memory" size={16} />
|
||||
<Icons ml="$2" name="memory" size={16} />
|
||||
<Text fontSize="$2" aria-label="Memory">
|
||||
{memory.used} MB / {memory.total} MB (
|
||||
{Math.round((memory.used / memory.total) * 100) || 0}%)
|
||||
</Text>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="harddisk" size={16} />
|
||||
<Icons ml="$2" name="harddisk" size={16} />
|
||||
<Text fontSize="$2" aria-label="Disk">
|
||||
{disk.used} / {disk.total} ({disk.percent})
|
||||
</Text>
|
||||
|
||||
<Separator vertical h="100%" mx="$2" borderColor="$color" />
|
||||
<Icons name="download" size={16} />
|
||||
<Icons ml="$2" name="download" size={16} />
|
||||
<Text fontSize="$2" aria-label="Network Received">
|
||||
{network.rx} MB
|
||||
</Text>
|
||||
@@ -83,7 +94,12 @@ const ServerStatsBar = ({ url }: Props) => {
|
||||
<Text fontSize="$2" aria-label="Network Sent">
|
||||
{network.tx} MB
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
<Icons ml="$2" name="clock" size={16} />
|
||||
<Text fontSize="$2" aria-label="Uptime">
|
||||
{formatDuration(uptime)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const ThemeSwitcher = ({ iconSize = 18, ...props }: Props) => {
|
||||
size={iconSize}
|
||||
/>
|
||||
<Label htmlFor={id} flex={1} cursor="pointer">
|
||||
{`${theme === "light" ? "Dark" : "Light"} Mode`}
|
||||
Dark Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
|
||||
@@ -9,6 +9,7 @@ type ModalProps = {
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
maxHeight?: number | string;
|
||||
};
|
||||
|
||||
@@ -18,6 +19,7 @@ const Modal = ({
|
||||
title,
|
||||
description,
|
||||
width = 512,
|
||||
height = "90%",
|
||||
maxHeight = 600,
|
||||
}: ModalProps) => {
|
||||
const { open, onOpenChange } = disclosure.use();
|
||||
@@ -65,7 +67,7 @@ const Modal = ({
|
||||
p="$1"
|
||||
width="90%"
|
||||
maxWidth={width}
|
||||
height="90%"
|
||||
height={height}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<View p="$4">
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createDisclosure } from "@/lib/utils";
|
||||
|
||||
export type DialogData = {
|
||||
title: string;
|
||||
description?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export const dialogStore = createDisclosure<DialogData>();
|
||||
|
||||
export const showDialog = (data: DialogData) => {
|
||||
dialogStore.onOpen(data);
|
||||
};
|
||||
@@ -36,3 +36,20 @@ export const isHostnameOrIP = (value?: string | null) => {
|
||||
|
||||
export const hostnameShape = (message: string = "Invalid hostname") =>
|
||||
z.string().refine(isHostnameOrIP, { message });
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
const days = Math.floor(seconds / (24 * 3600));
|
||||
seconds %= 24 * 3600;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
seconds %= 3600;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
seconds = Math.floor(seconds % 60);
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`);
|
||||
if (hours > 0) parts.push(`${hours} hr${hours > 1 ? "s" : ""}`);
|
||||
if (minutes > 0) parts.push(`${minutes} min`);
|
||||
if (seconds > 0) parts.push(`${seconds} sec`);
|
||||
|
||||
return parts.join(" ") || "0 seconds";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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 FormField from "@/components/ui/form";
|
||||
import { ErrorAlert } from "@/components/ui/alert";
|
||||
import Button from "@/components/ui/button";
|
||||
import {
|
||||
SetRoleSchema,
|
||||
setRoleSchema,
|
||||
teamMemberRoles,
|
||||
} from "../schema/team-form";
|
||||
import { SelectField } from "@/components/ui/select";
|
||||
import { useSetRoleMutation } from "../hooks/query";
|
||||
|
||||
export const changeRoleModal = createDisclosure<SetRoleSchema>();
|
||||
|
||||
const ChangeRoleForm = () => {
|
||||
const { data } = changeRoleModal.use();
|
||||
const form = useZForm(setRoleSchema, data);
|
||||
const setRole = useSetRoleMutation(data?.teamId || "");
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
setRole.mutate(values, {
|
||||
onSuccess: () => {
|
||||
changeRoleModal.onClose();
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
disclosure={changeRoleModal}
|
||||
title="Change Role"
|
||||
description="Change team member role."
|
||||
maxHeight={280}
|
||||
>
|
||||
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||
<ErrorAlert error={setRole.error} />
|
||||
|
||||
<FormField label="Role">
|
||||
<SelectField
|
||||
items={teamMemberRoles}
|
||||
form={form}
|
||||
name="role"
|
||||
placeholder="Select Role..."
|
||||
/>
|
||||
</FormField>
|
||||
</ScrollView>
|
||||
|
||||
<XStack p="$4" gap="$4">
|
||||
<Button
|
||||
flex={1}
|
||||
onPress={changeRoleModal.onClose}
|
||||
bg="$colorTransparent"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
icon={<Icons name="account-plus" size={18} />}
|
||||
onPress={onSubmit}
|
||||
isLoading={setRole.isPending}
|
||||
>
|
||||
Update Role
|
||||
</Button>
|
||||
</XStack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeRoleForm;
|
||||
@@ -3,6 +3,10 @@ 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";
|
||||
import { useTeamId } from "@/stores/auth";
|
||||
import { changeRoleModal } from "./change-role-form";
|
||||
import { useRemoveMemberMutation } from "../hooks/query";
|
||||
import { showDialog } from "@/hooks/useDialog";
|
||||
|
||||
type Props = {
|
||||
members?: any[];
|
||||
@@ -10,7 +14,17 @@ type Props = {
|
||||
};
|
||||
|
||||
const MemberList = ({ members, allowWrite }: Props) => {
|
||||
const teamId = useTeamId();
|
||||
const [search, setSearch] = useState("");
|
||||
const remove = useRemoveMemberMutation(teamId);
|
||||
|
||||
const onRemove = (member: any) => {
|
||||
showDialog({
|
||||
title: "Remove Member",
|
||||
description: "Are you sure you want to remove this member?",
|
||||
onConfirm: () => remove.mutate(member.userId),
|
||||
});
|
||||
};
|
||||
|
||||
const memberList = useMemo(() => {
|
||||
let items = members || [];
|
||||
@@ -51,7 +65,12 @@ const MemberList = ({ members, allowWrite }: Props) => {
|
||||
</Avatar>
|
||||
}
|
||||
iconAfter={
|
||||
allowWrite ? <MemberActionButton member={member} /> : undefined
|
||||
allowWrite ? (
|
||||
<MemberActionButton
|
||||
member={member}
|
||||
onRemove={() => onRemove(member)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</YGroup.Item>
|
||||
@@ -63,9 +82,10 @@ const MemberList = ({ members, allowWrite }: Props) => {
|
||||
|
||||
type MemberActionButtonProps = {
|
||||
member: any;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
||||
const MemberActionButton = ({ member, onRemove }: MemberActionButtonProps) => (
|
||||
<MenuButton
|
||||
size="$1"
|
||||
placement="bottom-end"
|
||||
@@ -77,10 +97,24 @@ const MemberActionButton = ({ member }: MemberActionButtonProps) => (
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuButton.Item icon={<Icons name="account-key" size={16} />}>
|
||||
<MenuButton.Item
|
||||
icon={<Icons name="account-key" size={16} />}
|
||||
onPress={() =>
|
||||
changeRoleModal.onOpen({
|
||||
teamId: member.teamId,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
})
|
||||
}
|
||||
>
|
||||
Change Role
|
||||
</MenuButton.Item>
|
||||
<MenuButton.Item color="$red10" icon={<Icons name="trash-can" size={16} />}>
|
||||
|
||||
<MenuButton.Item
|
||||
color="$red10"
|
||||
icon={<Icons name="trash-can" size={16} />}
|
||||
onPress={onRemove}
|
||||
>
|
||||
Remove Member
|
||||
</MenuButton.Item>
|
||||
</MenuButton>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import api from "@/lib/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { InviteSchema, TeamFormSchema } from "../schema/team-form";
|
||||
import {
|
||||
InviteSchema,
|
||||
SetRoleSchema,
|
||||
TeamFormSchema,
|
||||
} from "../schema/team-form";
|
||||
import queryClient from "@/lib/queryClient";
|
||||
import { setTeam, useTeamId } from "@/stores/auth";
|
||||
import { router } from "expo-router";
|
||||
@@ -28,7 +32,6 @@ export const useSaveTeam = () => {
|
||||
? api(`/teams/${body.id}`, { method: "PUT", body })
|
||||
: api(`/teams`, { method: "POST", body });
|
||||
},
|
||||
onError: (e) => console.error(e),
|
||||
onSuccess: (res, body) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams"] });
|
||||
|
||||
@@ -43,9 +46,31 @@ export const useSaveTeam = () => {
|
||||
export const useInviteMutation = (teamId: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body: InviteSchema) => {
|
||||
return api(`/teams/${teamId}/invite`, { method: "POST", body });
|
||||
return api(`/teams/${teamId}/members`, { method: "POST", body });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetRoleMutation = (teamId: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body: SetRoleSchema) => {
|
||||
const url = `/teams/${teamId}/members/${body.userId}/role`;
|
||||
return api(url, { method: "PUT", body });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveMemberMutation = (teamId: string | null) => {
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return api(`/teams/${teamId}/members/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onError: (e) => console.error(e),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["teams", teamId] });
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import tamaguiConfig from "@/tamagui.config";
|
||||
import MemberList from "./components/member-list";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import InviteForm, { inviteFormModal } from "./components/invite-form";
|
||||
import ChangeRoleForm from "./components/change-role-form";
|
||||
|
||||
export default function TeamPage() {
|
||||
const teamId = useTeamId();
|
||||
@@ -71,6 +72,7 @@ export default function TeamPage() {
|
||||
<MemberList members={data?.members} allowWrite={canWrite} />
|
||||
|
||||
<InviteForm />
|
||||
<ChangeRoleForm />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ export const teamFormSchema = z.object({
|
||||
|
||||
export type TeamFormSchema = z.infer<typeof teamFormSchema>;
|
||||
|
||||
const teamRoles = ["owner", "admin", "member"] as const;
|
||||
|
||||
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"], {
|
||||
role: z.enum(teamRoles, {
|
||||
errorMap: () => ({ message: "Role is required" }),
|
||||
}),
|
||||
});
|
||||
@@ -24,3 +26,13 @@ export const teamMemberRoles: SelectItem[] = [
|
||||
];
|
||||
|
||||
export type InviteSchema = z.infer<typeof inviteSchema>;
|
||||
|
||||
export const setRoleSchema = z.object({
|
||||
teamId: z.string().ulid(),
|
||||
userId: z.string().ulid(),
|
||||
role: z.enum(teamRoles, {
|
||||
errorMap: () => ({ message: "Role is required" }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SetRoleSchema = z.infer<typeof setRoleSchema>;
|
||||
|
||||
Reference in New Issue
Block a user