feat: team access control

This commit is contained in:
2024-11-12 17:17:10 +00:00
parent f5250d5361
commit 2d4c81e15d
31 changed files with 410 additions and 161 deletions
+16 -7
View File
@@ -2,11 +2,17 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Drawer } from "expo-router/drawer";
import React from "react";
import { useMedia } from "tamagui";
import DrawerContent from "@/components/containers/drawer";
import DrawerContent, {
DrawerNavigationOptions,
} from "@/components/containers/drawer";
import Icons from "@/components/ui/icons";
import { useUser } from "@/hooks/useUser";
import { useTeamId } from "@/stores/auth";
export default function Layout() {
const media = useMedia();
const teamId = useTeamId();
const user = useUser();
return (
<GestureHandlerRootView style={{ flex: 1 }}>
@@ -29,12 +35,15 @@ export default function Layout() {
/>
<Drawer.Screen
name="keychains"
options={{
title: "Keychains",
drawerIcon: ({ size, color }) => (
<Icons name="key" size={size} color={color} />
),
}}
options={
{
title: "Keychains",
hidden: teamId && !user?.teamCanWrite(teamId),
drawerIcon: ({ size, color }) => (
<Icons name="key" size={size} color={color} />
),
} as DrawerNavigationOptions
}
/>
<Drawer.Screen
name="terminal"
+1 -1
View File
@@ -11,8 +11,8 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { router, usePathname, useRootNavigationState } from "expo-router";
import { useAuthStore } from "@/stores/auth";
import { PortalProvider } from "tamagui";
import { queryClient } from "@/lib/api";
import { useServer } from "@/stores/app";
import queryClient from "@/lib/queryClient";
type Props = PropsWithChildren;
+11 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import {
DrawerContentComponentProps,
DrawerContentScrollView,
DrawerNavigationOptions as NavProps,
} from "@react-navigation/drawer";
import { Button, View } from "tamagui";
import {
@@ -13,6 +14,10 @@ import { Link } from "expo-router";
import ThemeSwitcher from "./theme-switcher";
import UserMenuButton from "./user-menu-button";
export type DrawerNavigationOptions = NavProps & {
hidden?: boolean | null;
};
const Drawer = (props: DrawerContentComponentProps) => {
return (
<>
@@ -61,7 +66,12 @@ const DrawerItemList = ({
}
};
const { title, drawerLabel, drawerIcon } = descriptors[route.key].options;
const { title, drawerLabel, drawerIcon, hidden } = descriptors[route.key]
.options as DrawerNavigationOptions;
if (hidden) {
return null;
}
return (
<Link key={route.key} href={buildHref(route.name, route.params) as never}>
@@ -11,11 +11,13 @@ import {
} from "tamagui";
import MenuButton from "../ui/menu-button";
import Icons from "../ui/icons";
import { logout } from "@/stores/auth";
import { logout, setTeam, useTeamId } from "@/stores/auth";
import { useUser } from "@/hooks/useUser";
const UserMenuButton = () => {
const user = useUser();
const teamId = useTeamId();
const team = user?.teams?.find((t: any) => t.id === teamId);
return (
<MenuButton
@@ -35,7 +37,7 @@ const UserMenuButton = () => {
<View flex={1} style={{ textAlign: "left" }}>
<Text numberOfLines={1}>{user?.name}</Text>
<Text numberOfLines={1} fontWeight="600" mt="$1.5">
Personal
{team ? `${team.icon} ${team.name}` : "Personal"}
</Text>
</View>
<Icons name="chevron-down" size={16} />
@@ -61,6 +63,7 @@ const UserMenuButton = () => {
const TeamsMenu = () => {
const media = useMedia();
const user = useUser();
const teamId = useTeamId();
const teams = user?.teams || [];
return (
@@ -73,20 +76,30 @@ const TeamsMenu = () => {
<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"
/>
{teamId != null && (
<MenuButton.Item
icon={<Icons name="account" size={16} />}
title="Personal"
onPress={() => setTeam(null)}
/>
)}
{teams.map((team: any) => (
<MenuButton.Item icon={<Text>{team.icon}</Text>} title={team.name} />
<MenuButton.Item
key={team.id}
icon={<Text>{team.icon}</Text>}
iconAfter={
teamId === team.id ? <Icons name="check" size={16} /> : undefined
}
title={team.name}
onPress={() => setTeam(team.id)}
/>
))}
{teams.length > 0 && <Separator width="100%" />}
+28 -1
View File
@@ -6,5 +6,32 @@ export const useUser = () => {
queryKey: ["auth", "user"],
queryFn: authRepo.getUser,
});
return user;
if (!user) {
return null;
}
function getTeamRole(teamId?: string | null) {
if (!user.teams?.length) {
return false;
}
const team = user.teams.find((i: any) => i.id === teamId);
return team?.role;
}
function isInTeam(teamId?: string | null) {
return getTeamRole(teamId) != null;
}
function teamCanWrite(teamId?: string | null) {
const role = getTeamRole(teamId);
return ["admin", "owner"].includes(role);
}
return {
...user,
getTeamRole,
isInTeam,
teamCanWrite,
};
};
+7 -6
View File
@@ -1,6 +1,5 @@
import { getCurrentServer } from "@/stores/app";
import authStore from "@/stores/auth";
import { QueryClient } from "@tanstack/react-query";
import { ofetch } from "ofetch";
const api = ofetch.create({
@@ -13,9 +12,13 @@ const api = ofetch.create({
// set server url
config.options.baseURL = server.url;
const authToken = authStore.getState().token;
if (authToken) {
config.options.headers.set("Authorization", `Bearer ${authToken}`);
const { token, teamId } = authStore.getState();
if (token) {
config.options.headers.set("Authorization", `Bearer ${token}`);
}
if (teamId) {
config.options.headers.set("X-Team-Id", teamId);
}
},
onResponseError: (error) => {
@@ -31,6 +34,4 @@ const api = ofetch.create({
},
});
export const queryClient = new QueryClient();
export default api;
+5
View File
@@ -0,0 +1,5 @@
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient();
export default queryClient;
+1
View File
@@ -47,6 +47,7 @@ export default function LoginPage() {
marginHorizontal: "auto",
},
title: "Login",
headerTitle: "",
headerRight: () => (
<ThemeSwitcher bg="$colorTransparent" $gtSm={{ mr: "$3" }} />
),
@@ -8,6 +8,7 @@ import { useTermSession } from "@/stores/terminal-sessions";
import { hostFormModal } from "./form";
import GridView from "@/components/ui/grid-view";
import HostItem from "./host-item";
import { useHosts } from "../hooks/query";
type HostsListProps = {
allowEdit?: boolean;
@@ -18,11 +19,7 @@ const HostList = ({ allowEdit = true }: HostsListProps) => {
const navigation = useNavigation();
const [search, setSearch] = useState("");
const hosts = useQuery({
queryKey: ["hosts"],
queryFn: () => api("/hosts"),
select: (i) => i.rows,
});
const hosts = useHosts();
const hostsList = useMemo(() => {
let items = hosts.data || [];
+16 -2
View File
@@ -1,8 +1,19 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form";
import api, { queryClient } from "@/lib/api";
import api from "@/lib/api";
import { useMemo } from "react";
import { useKeychains } from "@/pages/keychains/hooks/query";
import queryClient from "@/lib/queryClient";
import { useTeamId } from "@/stores/auth";
export const useHosts = () => {
const teamId = useTeamId();
return useQuery({
queryKey: ["hosts", teamId],
queryFn: () => api("/hosts", { params: { teamId } }),
select: (i) => i.rows,
});
};
export const useKeychainsOptions = () => {
const keys = useKeychains();
@@ -20,8 +31,11 @@ export const useKeychainsOptions = () => {
};
export const useSaveHost = () => {
const teamId = useTeamId();
return useMutation({
mutationFn: async (body: FormSchema) => {
mutationFn: async (payload: FormSchema) => {
const body = { teamId, ...payload };
return body.id
? api(`/hosts/${body.id}`, { method: "PUT", body })
: api(`/hosts`, { method: "POST", body });
+11 -3
View File
@@ -1,8 +1,13 @@
import api, { queryClient } from "@/lib/api";
import api from "@/lib/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form";
import queryClient from "@/lib/queryClient";
import { useTeamId } from "@/stores/auth";
export const useKeychains = (params?: any) => {
const teamId = useTeamId();
const query = { teamId, ...params };
export const useKeychains = (query?: any) => {
return useQuery({
queryKey: ["keychains", query],
queryFn: () => api("/keychains", { query }),
@@ -11,8 +16,11 @@ export const useKeychains = (query?: any) => {
};
export const useSaveKeychain = () => {
const teamId = useTeamId();
return useMutation({
mutationFn: async (body: FormSchema) => {
mutationFn: async (payload: FormSchema) => {
const body = { teamId, ...payload };
return body.id
? api(`/keychains/${body.id}`, { method: "PUT", body })
: api(`/keychains`, { method: "POST", body });
+14 -2
View File
@@ -2,15 +2,18 @@ import { createStore, useStore } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import termSessionStore from "./terminal-sessions";
import queryClient from "@/lib/queryClient";
type AuthStore = {
token?: string | null;
token: string | null;
teamId: string | null;
};
const authStore = createStore(
persist<AuthStore>(
() => ({
token: null,
teamId: null,
}),
{
name: "vaulterm:auth",
@@ -24,9 +27,18 @@ export const useAuthStore = () => {
return { ...state, isLoggedIn: state.token != null };
};
export const setTeam = (teamId: string | null) => {
authStore.setState({ teamId });
queryClient.invalidateQueries();
};
export const logout = () => {
authStore.setState({ token: null });
authStore.setState({ token: null, teamId: null });
termSessionStore.setState({ sessions: [], curSession: 0 });
};
export const useTeamId = () => {
return useStore(authStore, (i) => i.teamId);
};
export default authStore;