diff --git a/api/application.ts b/api/application.ts index 8a6fe5d..fee3f3d 100644 --- a/api/application.ts +++ b/api/application.ts @@ -1,4 +1,5 @@ import { queryClient } from "@/app/_layout"; +import { filterResourceByTeam, filterResourcesByTeam } from "@/lib/utils"; import { useMutation, UseMutationOptions, @@ -98,10 +99,14 @@ export const ApplicationKeys = { // Fetch functions export const getApplications = async () => { const data = await coolifyFetch("/applications"); - data.forEach((app) => + const filtered = await filterResourcesByTeam( + data, + (app) => app.destination.server.team_id, + ); + filtered.forEach((app) => optimisticUpdateOne(ApplicationKeys.queries.single(app.uuid), app), ); - return data; + return filtered; }; export const getApplication = async (uuid: string) => { @@ -109,7 +114,11 @@ export const getApplication = async (uuid: string) => { queryKey: ApplicationKeys.queries.all(), exact: true, }); - return coolifyFetch(`/applications/${uuid}`); + const application = await coolifyFetch(`/applications/${uuid}`); + return filterResourceByTeam( + application, + (app) => app.destination.server.team_id, + ); }; export const getApplicationLogs = async (uuid: string, lines = 100) => { @@ -217,7 +226,7 @@ export const useApplications = ( export const useApplication = ( uuid: string, - options?: Omit, "queryKey">, + options?: Omit, "queryKey">, ) => { return useQuery({ queryKey: ApplicationKeys.queries.single(uuid), @@ -272,7 +281,7 @@ export const useCreateApplicationEnv = ( return update; }, onError: onOptimisticUpdateError, - onSettled: onOptimisticUpdateSettled(), + onSettled: onOptimisticUpdateSettled(ApplicationKeys.queries.envs(uuid)), }); }; @@ -339,8 +348,10 @@ export const useUpdateApplication: UseUpdateApplication = ( return { update, insert }; }, onError: (error, variables, context) => { - onOptimisticUpdateError(error, variables, context?.update); - onOptimisticUpdateError(error, variables, context?.insert); + if (context) { + onOptimisticUpdateError(error, variables, context.update); + onOptimisticUpdateError(error, variables, context.insert); + } }, onSettled: () => onOptimisticUpdateSettled(ApplicationKeys.queries.all())(), }); diff --git a/api/client.ts b/api/client.ts index 7e88d79..a99d6d9 100644 --- a/api/client.ts +++ b/api/client.ts @@ -107,10 +107,9 @@ export async function optimisticUpdateOne( return { previousData, queryKey }; } -export const onOptimisticUpdateError = ( - data: unknown, - error: unknown, - variables: unknown, +export const onOptimisticUpdateError = ( + error: Error, + variables: TVariables, context?: { queryKey: (string | number)[]; previousData: unknown; diff --git a/api/databases.ts b/api/databases.ts index bce5b4e..c873c9b 100644 --- a/api/databases.ts +++ b/api/databases.ts @@ -1,4 +1,8 @@ import { queryClient } from "@/app/_layout"; +import { + filterResourceByTeam, + filterResourcesByTeam, +} from "@/lib/utils"; import { useMutation, UseMutationOptions, @@ -85,8 +89,12 @@ export const DatabaseKeys = { */ export const getDatabases = async () => { const data = await coolifyFetch("/databases"); + const filtered = await filterResourcesByTeam( + data, + (db) => db.destination.server.team_id, + ); // Set individual database cache entries - data.forEach((database) => { + filtered.forEach((database) => { optimisticUpdateInsertOneToMany(DatabaseKeys.queries.all(), database); optimisticUpdateOne(DatabaseKeys.queries.single(database.uuid), database); }); @@ -97,9 +105,15 @@ export const getDatabases = async () => { export const getDatabase = async (uuid: string) => { const data = await coolifyFetch(`/databases/${uuid}`); - // Update the databases list cache with the new database - optimisticUpdateInsertOneToMany(DatabaseKeys.queries.all(), data); - return data; + const filtered = await filterResourceByTeam( + data, + (db) => db.destination.server.team_id, + ); + if (filtered) { + // Update the databases list cache with the new database + optimisticUpdateInsertOneToMany(DatabaseKeys.queries.all(), filtered); + } + return filtered; }; export const getDatabaseLogs = async (uuid: string, lines = 100) => { @@ -166,7 +180,7 @@ export const useDatabases = ( export const useDatabase = ( uuid: string, - options?: Omit, "queryKey"> + options?: Omit, "queryKey"> ) => { return useQuery({ queryKey: DatabaseKeys.queries.single(uuid), @@ -236,10 +250,12 @@ export const useUpdateDatabase: UseUpdateDatabase = (uuid: string, options) => { return { update, insert }; }, onError: (error, variables, context) => { - onOptimisticUpdateError(error, variables, context?.update); - onOptimisticUpdateError(error, variables, context?.insert); + if (context) { + onOptimisticUpdateError(error, variables, context.update); + onOptimisticUpdateError(error, variables, context.insert); + } }, - onSettled: () => onOptimisticUpdateSettled(DatabaseKeys.queries.all())(), + onSettled: onOptimisticUpdateSettled(DatabaseKeys.queries.all()), }); }; @@ -260,6 +276,6 @@ export const useCreateDatabase = ( type: CoolifyDatabases; }) => createDatabase(body, type), ...options, - onSettled: () => onOptimisticUpdateSettled(DatabaseKeys.queries.all())(), + onSettled: onOptimisticUpdateSettled(DatabaseKeys.queries.all()), }); }; diff --git a/api/private-keys.ts b/api/private-keys.ts index 4ce3cee..273e161 100644 --- a/api/private-keys.ts +++ b/api/private-keys.ts @@ -1,3 +1,7 @@ +import { + filterResourceByTeam, + filterResourcesByTeam, +} from "@/lib/utils"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { coolifyFetch, optimisticUpdateOne } from "./client"; import { PrivateKey } from "./types/private-keys.types"; @@ -14,14 +18,19 @@ export const PrivateKeyKeys = { // Fetch functions export const getPrivateKeys = async () => { const res = await coolifyFetch("/security/keys"); - res.forEach((key) => + const filtered = await filterResourcesByTeam( + res, + (key) => key.team_id, + ); + filtered.forEach((key) => optimisticUpdateOne(PrivateKeyKeys.queries.single(key.uuid), key) ); - return res; + return filtered; }; export const getPrivateKey = async (uuid: string) => { - return coolifyFetch(`/security/keys/${uuid}`); + const key = await coolifyFetch(`/security/keys/${uuid}`); + return filterResourceByTeam(key, (k) => k.team_id); }; // Query hooks @@ -37,7 +46,7 @@ export const usePrivateKeys = ( export const usePrivateKey = ( uuid: string, - options?: Omit, "queryKey"> + options?: Omit, "queryKey"> ) => { return useQuery({ queryKey: PrivateKeyKeys.queries.single(uuid), diff --git a/api/projects.ts b/api/projects.ts index 0b6c3d3..4e90864 100644 --- a/api/projects.ts +++ b/api/projects.ts @@ -1,4 +1,5 @@ import { queryClient } from "@/app/_layout"; +import { filterResourceByTeam } from "@/lib/utils"; import { useMutation, UseMutationOptions, @@ -6,11 +7,7 @@ import { UseQueryOptions, } from "@tanstack/react-query"; import { coolifyFetch, onOptimisticUpdateError } from "./client"; -import { - PartialProject, - Project, - ProjectCreateBody, -} from "./types/project.types"; +import { Project, ProjectBase, ProjectCreateBody } from "./types/project.types"; import { ResourceActionResponse, ResourceCreateResponse, @@ -31,18 +28,29 @@ export const ProjectKeys = { // Fetch functions export const getProjects = async () => { - const data = await coolifyFetch("/projects"); - data.forEach((project) => { - queryClient.prefetchQuery({ - queryKey: ProjectKeys.queries.single(project.uuid), - queryFn: () => getProject(project.uuid), - }); + const data = await coolifyFetch("/projects"); + + const projectsWithTeam = await Promise.all( + data.map(async (project) => { + try { + return await getProject(project.uuid); + } catch { + return null; + } + }), + ); + const validProjects = projectsWithTeam.filter((p) => p !== null); + + validProjects.forEach((project) => { + queryClient.setQueryData(ProjectKeys.queries.single(project.uuid), project); }); - return data; + + return validProjects; }; export const getProject = async (uuid: string) => { - return coolifyFetch(`/projects/${uuid}`); + const project = await coolifyFetch(`/projects/${uuid}`); + return filterResourceByTeam(project, (p) => p.team_id); }; export const createProject = async (data: ProjectCreateBody) => { @@ -66,7 +74,7 @@ export const deleteProject = async (uuid: string) => { // Query hooks export const useProjects = ( - options?: Omit, "queryKey"> + options?: Omit, "queryKey">, ) => { return useQuery({ queryKey: ProjectKeys.queries.all(), @@ -77,7 +85,7 @@ export const useProjects = ( export const useProject = ( uuid: string, - options?: Omit, "queryKey"> + options?: Omit, "queryKey">, ) => { return useQuery({ queryKey: ProjectKeys.queries.single(uuid), @@ -88,7 +96,11 @@ export const useProject = ( // Mutation hooks export const useCreateProject = ( - options?: UseMutationOptions + options?: UseMutationOptions< + ResourceCreateResponse, + Error, + ProjectCreateBody + >, ) => { return useMutation({ mutationKey: ProjectKeys.mutations.create(), @@ -105,7 +117,7 @@ export const useCreateProject = ( export const useDeleteProject = ( uuid: string, - options?: UseMutationOptions + options?: UseMutationOptions, ) => { return useMutation({ mutationKey: ProjectKeys.mutations.delete(uuid), diff --git a/api/resources.ts b/api/resources.ts index e978a8f..1c49f20 100644 --- a/api/resources.ts +++ b/api/resources.ts @@ -1,3 +1,4 @@ +import { filterResourcesByTeam } from "@/lib/utils"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { ApplicationKeys, useApplication } from "./application"; import { @@ -39,20 +40,25 @@ const getQueryKeyFromType = (type: ResourceFromListType) => { // Fetch functions export const getResources = async () => { const data = await coolifyFetch("/resources"); - data.forEach((resource) => { + // Resources have team_id in destination.server.team_id (similar to applications/databases) + const filtered = await filterResourcesByTeam( + data, + (resource) => resource.destination?.server?.team_id, + ); + filtered.forEach((resource) => { const key = getQueryKeyFromType(resource.type); optimisticUpdateInsertOneToMany(key, resource); optimisticUpdateOne([...key, resource.uuid], resource); }); - return data; + return filtered; }; // Query hooks export const useResource = ( uuid: string, type: ResourceType, - options?: Omit, "queryKey"> + options?: Omit, "queryKey"> ) => { if (!type) { throw new Error("Resource type is required"); diff --git a/api/servers.ts b/api/servers.ts index b6fd7fc..9d06545 100644 --- a/api/servers.ts +++ b/api/servers.ts @@ -1,4 +1,5 @@ import { queryClient } from "@/app/_layout"; +import { filterResourceByTeam, filterResourcesByTeam } from "@/lib/utils"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { coolifyFetch } from "./client"; import { Server, SingleServer } from "./types/server.types"; @@ -15,19 +16,24 @@ export const ServerKeys = { // Fetch functions export const getServers = async () => { const data = await coolifyFetch("/servers"); - data.forEach((server) => { + const filtered = await filterResourcesByTeam( + data, + (server) => server.team_id, + ); + filtered.forEach((server) => { queryClient.setQueryData(ServerKeys.queries.single(server.uuid), server); }); - return data; + return filtered; }; export const getServer = async (uuid: string) => { - return coolifyFetch(`/servers/${uuid}`); + const server = await coolifyFetch(`/servers/${uuid}`); + return filterResourceByTeam(server, (s) => s.team_id); }; // Query hooks export const useServers = ( - options?: Omit, "queryKey"> + options?: Omit, "queryKey">, ) => { return useQuery({ queryKey: ServerKeys.queries.all(), @@ -38,7 +44,7 @@ export const useServers = ( export const useServer = ( uuid: string, - options?: Omit, "queryKey"> + options?: Omit, "queryKey">, ) => { return useQuery({ queryKey: ServerKeys.queries.single(uuid), diff --git a/api/services.ts b/api/services.ts index 4d53ce2..65ccc93 100644 --- a/api/services.ts +++ b/api/services.ts @@ -1,4 +1,8 @@ import { queryClient } from "@/app/_layout"; +import { + filterResourceByTeam, + filterResourcesByTeam, +} from "@/lib/utils"; import { useMutation, UseMutationOptions, @@ -80,10 +84,14 @@ export const ServiceKeys = { // Fetch functions export const getServices = async () => { const data = await coolifyFetch("/services"); - data.forEach((service) => + const filtered = await filterResourcesByTeam( + data, + (service) => service.server.team_id, + ); + filtered.forEach((service) => optimisticUpdateOne(ServiceKeys.queries.single(service.uuid), service) ); - return data; + return filtered; }; export const getService = async (uuid: string) => { @@ -91,7 +99,8 @@ export const getService = async (uuid: string) => { queryKey: ServiceKeys.queries.all(), exact: true, }); - return coolifyFetch(`/services/${uuid}`); + const service = await coolifyFetch(`/services/${uuid}`); + return filterResourceByTeam(service, (s) => s.server.team_id); }; export const getServiceLogs = async (uuid: string, lines = 100) => { @@ -167,7 +176,7 @@ export const useServices = ( export const useService = ( uuid: string, - options?: Omit, "queryKey"> + options?: Omit, "queryKey"> ) => { return useQuery({ queryKey: ServiceKeys.queries.single(uuid), diff --git a/api/types/project.types.ts b/api/types/project.types.ts index 3d26828..f15c2e8 100644 --- a/api/types/project.types.ts +++ b/api/types/project.types.ts @@ -15,8 +15,6 @@ export type ProjectBase = { description: string; }; -export type PartialProject = ProjectBase; - export type Project = ProjectBase & { team_id: number; created_at: string; diff --git a/api/types/server.types.ts b/api/types/server.types.ts index 780640d..a32719a 100644 --- a/api/types/server.types.ts +++ b/api/types/server.types.ts @@ -74,6 +74,7 @@ export type ServerBase = { export type Server = ServerBase & { proxy: ServerProxy; + team_id?: number; }; export type SingleServer = ServerBase & { diff --git a/app/index.tsx b/app/index.tsx index c62994d..73f2e16 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -5,10 +5,11 @@ import * as SplashScreen from "expo-splash-screen"; SplashScreen.preventAutoHideAsync(); export default function RootIndex() { - const { setupComplete } = useSetup(); + const { setupComplete, team } = useSetup(); - if (setupComplete === null) return null; + if (setupComplete === null || team === null) return null; + if (team === "NO_TEAM_SELECTED") return ; if (setupComplete) return ; - else return ; + return ; } diff --git a/app/main/projects/create.tsx b/app/main/projects/create.tsx index a4efb20..b1b72d3 100644 --- a/app/main/projects/create.tsx +++ b/app/main/projects/create.tsx @@ -1,5 +1,5 @@ import { useCreateProject } from "@/api/projects"; -import { PartialProject } from "@/api/types/project.types"; +import { ProjectBase } from "@/api/types/project.types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Text } from "@/components/ui/text"; @@ -13,7 +13,7 @@ export default function CreateProject() { control, formState: { isValid }, handleSubmit, - } = useForm({ + } = useForm({ defaultValues: { name: "", description: "", @@ -22,7 +22,7 @@ export default function CreateProject() { const { mutateAsync, isPending } = useCreateProject(); - const onSubmit = (data: PartialProject) => { + const onSubmit = (data: ProjectBase) => { toast.promise(mutateAsync(data), { loading: "Creating project...", success: ({ uuid }) => { diff --git a/app/main/settings/index.tsx b/app/main/settings/index.tsx index 182d50c..2b4b848 100644 --- a/app/main/settings/index.tsx +++ b/app/main/settings/index.tsx @@ -1,3 +1,4 @@ +import { useTeams } from "@/api/teams"; import { ArrowUpRight } from "@/components/icons/ArrowUpRight"; import Info from "@/components/icons/Info"; import { MessageCircle } from "@/components/icons/MessageCircle"; @@ -10,29 +11,76 @@ import { SettingsButtonProps, } from "@/components/SettingsButton"; import { SettingsLink, SettingsLinkProps } from "@/components/SettingsLink"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { H3 } from "@/components/ui/typography"; import { LOG_REFETCH_INTERVAL_STORAGE_KEY } from "@/constants/StorageKeys"; import { useColorScheme } from "@/hooks/useColorScheme"; +import useSetup from "@/hooks/useSetup"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { Alert, Linking, SectionList, View } from "react-native"; import { Switch } from "react-native-gesture-handler"; type SettingsItem = - | (SettingsLinkProps & { isLink: true }) - | (SettingsButtonProps & { isLink: false }); + | SettingsLinkProps + | SettingsButtonProps + | { label: string; render: React.ReactElement | null }; type SettingsSections = { title: string; data: SettingsItem[]; }; +function TeamSelect() { + const { team, setTeam } = useSetup(); + const { data: teams } = useTeams(); + + if (!teams || !team) return null; + + const selectedTeam = { + value: team, + label: teams.find((t) => t.id.toString() === team)?.name ?? team, + }; + + return ( + + + + + ); +} + export default function Settings() { const { toggleColorScheme, isDarkColorScheme } = useColorScheme(); const appearanceItems: SettingsItem[] = [ { label: "Dark Mode", - isLink: false, onPress: toggleColorScheme, icon: , rightComponent: ( @@ -42,9 +90,12 @@ export default function Settings() { ]; const appConfigItems: SettingsItem[] = [ + { + label: "Team Selection", + render: , + }, { label: "Reconfigure Linked Instance", - isLink: true, href: { pathname: "/setup/serverAddress", params: { reconfigure: "true" }, @@ -55,7 +106,6 @@ export default function Settings() { }, { label: "Logs Refetch Interval", - isLink: false, onPress: async () => { const savedInterval = await AsyncStorage.getItem( LOG_REFETCH_INTERVAL_STORAGE_KEY, @@ -71,7 +121,7 @@ export default function Settings() { { isPreferred: true, text: "Save", - onPress: (text) => { + onPress: (text: string | undefined) => { AsyncStorage.setItem( LOG_REFETCH_INTERVAL_STORAGE_KEY, text || "2000", @@ -89,7 +139,6 @@ export default function Settings() { { label: "Clear Cache", labelClassName: "text-destructive", - isLink: false, onPress: () => { // TODO: Clear cache }, @@ -100,7 +149,6 @@ export default function Settings() { const appInfoItems: SettingsItem[] = [ { label: "Share Feedback", - isLink: false, onPress: () => { Linking.openURL("https://github.com/Jacxk/Coolifynager/issues"); }, @@ -109,7 +157,6 @@ export default function Settings() { }, { label: "About", - isLink: true, href: "/main/settings/about", icon: , }, @@ -140,7 +187,9 @@ export default function Settings() {

{title}

)} renderItem={({ item }) => { - if (item.isLink) { + if ("render" in item) { + return item.render ?? null; + } else if ("href" in item) { return ; } else { return ; diff --git a/app/setup/api_token.tsx b/app/setup/api_token.tsx index 6c96423..b34a131 100644 --- a/app/setup/api_token.tsx +++ b/app/setup/api_token.tsx @@ -28,7 +28,10 @@ export default function ApiTokenStep() { if (reconfigure) { router.dismissTo("/main/settings"); } else { - router.navigate("/setup/permissions"); + router.navigate({ + pathname: "/setup/team", + params: { nextStep: "permissions" }, + }); } }) .catch((e) => setError(e.message)) diff --git a/app/setup/team.tsx b/app/setup/team.tsx new file mode 100644 index 0000000..c65bac2 --- /dev/null +++ b/app/setup/team.tsx @@ -0,0 +1,101 @@ +import { useTeams } from "@/api/teams"; +import LoadingScreen from "@/components/LoadingScreen"; +import SetupScreenContainer from "@/components/SetupScreenContainer"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Text } from "@/components/ui/text"; +import { H1, P } from "@/components/ui/typography"; +import useSetup from "@/hooks/useSetup"; +import { cn } from "@/lib/utils"; +import { router, useLocalSearchParams } from "expo-router"; +import { openBrowserAsync } from "expo-web-browser"; +import { View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { scheduleOnRN } from "react-native-worklets"; + +export default function TeamStep() { + const { nextStep } = useLocalSearchParams<{ nextStep: string }>(); + const { serverAddress, team, setTeam } = useSetup(); + const { data, isPending } = useTeams(); + + if (isPending) { + return ; + } + + if (!data || data.length === 0) { + return ( + + +

No teams found

+

+ We couldn't find any teams on the server. Please contact your + administrator. If you are the administrator, you can create a team + by{" "} + +

+
+
+ ); + } + + const isTeamSelected = team && team !== "NO_TEAM_SELECTED"; + const onContinue = () => { + if (isTeamSelected) { + router.dismissTo( + nextStep === "permissions" ? "/setup/permissions" : "/main", + ); + } + }; + + return ( + + +

Select Team

+

+ Select the primary team you want to use. You can change this later in + the settings. +

+
+ {data?.map((eachTeam) => ( + { + scheduleOnRN(setTeam, eachTeam.id.toString()); + })} + > + + + {eachTeam.name} + {eachTeam.description} + + + + ))} + {(!team || team === "NO_TEAM_SELECTED") && ( + + Please select a team to continue. + + )} + +
+ ); +} diff --git a/components/SetupScreenContainer.tsx b/components/SetupScreenContainer.tsx index 45d1de0..bf6b85f 100644 --- a/components/SetupScreenContainer.tsx +++ b/components/SetupScreenContainer.tsx @@ -1,10 +1,13 @@ +import { cn } from "@/lib/utils"; import * as SplashScreen from "expo-splash-screen"; import { KeyboardAvoidingView, ScrollView } from "react-native"; export default function SetupScreenContainer({ children, + scrollviewClassName, }: { children: React.ReactNode; + scrollviewClassName?: string; }) { return ( {children} diff --git a/constants/StorageKeys.ts b/constants/StorageKeys.ts index e038aa2..df1e26b 100644 --- a/constants/StorageKeys.ts +++ b/constants/StorageKeys.ts @@ -1,5 +1,6 @@ export const FAVORITES_STORAGE_KEY = "FAVORITES"; export const SETUP_COMPLETE_STORAGE_KEY = "SETUP_COMPLETE"; +export const TEAM_STORAGE_KEY = "CURRENT_TEAM"; export const PERMISSIONS_SAVED_STORAGE_KEY = "PERMISSIONS_SAVED"; export const LOGS_LINES_STORAGE_KEY = "LOGS_LINES"; export const LOG_REFETCH_INTERVAL_STORAGE_KEY = "LOG_REFETCH_INTERVAL"; diff --git a/hooks/useSetup.ts b/hooks/useSetup.ts index 913d712..9f2f4cb 100644 --- a/hooks/useSetup.ts +++ b/hooks/useSetup.ts @@ -3,6 +3,7 @@ import { Secrets } from "@/constants/Secrets"; import { PERMISSIONS_SAVED_STORAGE_KEY, SETUP_COMPLETE_STORAGE_KEY, + TEAM_STORAGE_KEY, } from "@/constants/StorageKeys"; import { isValidUrl } from "@/lib/utils"; import SecureStore from "@/utils/SecureStorage"; @@ -14,6 +15,7 @@ export default function useSetup() { const [setupComplete, setSetupCompleteState] = useState(null); const [serverAddress, setServerAddressState] = useState(null); const [permissions, setPermissionsState] = useState(null); + const [team, setTeamState] = useState(null); const setApiToken = async (key: string) => { const { success, message } = await validateToken(key); @@ -47,6 +49,11 @@ export default function useSetup() { setPermissionsState(saved); }; + const setTeam = async (team: string) => { + await AsyncStorage.setItem(TEAM_STORAGE_KEY, team); + setTeamState(team); + }; + const resetSetup = async () => { await SecureStore.deleteItemAsync(Secrets.API_TOKEN); await SecureStore.deleteItemAsync(Secrets.SERVER_ADDRESS); @@ -75,16 +82,21 @@ export default function useSetup() { AsyncStorage.getItem(PERMISSIONS_SAVED_STORAGE_KEY).then((value) => { setPermissionsState(value === "true"); }); + AsyncStorage.getItem(TEAM_STORAGE_KEY).then((value) => { + setTeamState(value ?? "NO_TEAM_SELECTED"); + }); }, []); return { setupComplete, serverAddress, permissions, + team, setApiToken, setServerAddress, setSetupComplete, setPermissions, + setTeam, resetSetup, }; } diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 0000000..62ed780 --- /dev/null +++ b/lib/storage.ts @@ -0,0 +1,8 @@ +import { TEAM_STORAGE_KEY } from "@/constants/StorageKeys"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +export async function getCurrentTeam(apply?: (team: string | null) => void) { + const team = await AsyncStorage.getItem(TEAM_STORAGE_KEY); + apply?.(team); + return team; +} diff --git a/lib/utils.ts b/lib/utils.ts index e1c6cfb..7201505 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import { getCurrentTeam } from "./storage"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -12,18 +13,21 @@ export function isValidUrl(url: string) { export function groupBy( list: T[], - getKey: (item: T) => K + getKey: (item: T) => K, ): Record { - return list.reduce((acc, item) => { - const key = getKey(item); - (acc[key] ||= []).push(item); - return acc; - }, {} as Record); + return list.reduce( + (acc, item) => { + const key = getKey(item); + (acc[key] ||= []).push(item); + return acc; + }, + {} as Record, + ); } export function getDirtyData( data: T, - dirtyFields: Record + dirtyFields: Record, ): Partial { return Object.keys(dirtyFields).reduce((acc, key) => { if (dirtyFields[key]) { @@ -32,3 +36,124 @@ export function getDirtyData( return acc; }, {} as Partial); } + +/** + * Filters a single resource by team_id. Returns null if the resource doesn't belong to the selected team. + * @overload + * @param resource - The resource to filter + * @param getTeamId - Function to extract team_id from the resource (currentTeam will be fetched automatically) + * @returns The resource if it belongs to the team, null otherwise + */ +export function filterResourceByTeam( + resource: T, + getTeamId: (resource: T) => number | string | undefined, +): Promise; +/** + * Filters a single resource by team_id. Returns null if the resource doesn't belong to the selected team. + * @overload + * @param resource - The resource to filter + * @param currentTeam - The currently selected team ID (as string) + * @param getTeamId - Function to extract team_id from the resource + * @returns The resource if it belongs to the team, null otherwise + */ +export function filterResourceByTeam( + resource: T, + currentTeam: string | null, + getTeamId: (resource: T) => number | string | undefined, +): Promise; +export async function filterResourceByTeam( + resource: T, + currentTeamOrGetTeamId: + | string + | null + | ((resource: T) => number | string | undefined), + getTeamId?: (resource: T) => number | string | undefined, +): Promise { + let currentTeam: string | null; + let teamIdExtractor: (resource: T) => number | string | undefined; + + // Determine which overload was called + if (typeof currentTeamOrGetTeamId === "function") { + // First overload: (resource, getTeamId) + currentTeam = await getCurrentTeam(); + teamIdExtractor = currentTeamOrGetTeamId; + } else { + // Second overload: (resource, currentTeam, getTeamId) + currentTeam = currentTeamOrGetTeamId; + teamIdExtractor = getTeamId!; + } + + if (!currentTeam || currentTeam === "NO_TEAM_SELECTED") { + return null; + } + + const resourceTeamId = teamIdExtractor(resource); + if (resourceTeamId === undefined) { + return resource; + } + + if (resourceTeamId.toString() !== currentTeam) { + return null; + } + + return resource; +} + +/** + * Filters an array of resources by team_id. Returns only resources that belong to the selected team. + * @overload + * @param resources - Array of resources to filter + * @param getTeamId - Function to extract team_id from each resource (currentTeam will be fetched automatically) + * @returns Array of resources that belong to the team + */ +export function filterResourcesByTeam( + resources: T[], + getTeamId: (resource: T) => number | string | undefined, +): Promise; +/** + * Filters an array of resources by team_id. Returns only resources that belong to the selected team. + * @overload + * @param resources - Array of resources to filter + * @param currentTeam - The currently selected team ID (as string) + * @param getTeamId - Function to extract team_id from each resource + * @returns Array of resources that belong to the team + */ +export function filterResourcesByTeam( + resources: T[], + currentTeam: string | null, + getTeamId: (resource: T) => number | string | undefined, +): Promise; +export async function filterResourcesByTeam( + resources: T[], + currentTeamOrGetTeamId: + | string + | null + | ((resource: T) => number | string | undefined), + getTeamId?: (resource: T) => number | string | undefined, +): Promise { + let currentTeam: string | null; + let teamIdExtractor: (resource: T) => number | string | undefined; + + // Determine which overload was called + if (typeof currentTeamOrGetTeamId === "function") { + // First overload: (resources, getTeamId) + currentTeam = await getCurrentTeam(); + teamIdExtractor = currentTeamOrGetTeamId; + } else { + // Second overload: (resources, currentTeam, getTeamId) + currentTeam = currentTeamOrGetTeamId; + teamIdExtractor = getTeamId!; + } + + if (!currentTeam || currentTeam === "NO_TEAM_SELECTED") { + return []; + } + + return resources.filter((resource) => { + const resourceTeamId = teamIdExtractor(resource); + if (resourceTeamId === undefined) { + return true; + } + return resourceTeamId.toString() === currentTeam; + }); +}