diff --git a/.gitignore b/.gitignore index 88df481..2a0e857 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ yarn-error.* *.tsbuildinfo .idea +.env diff --git a/src/auth.tsx b/src/auth.tsx index 85cce10..e40f49d 100644 --- a/src/auth.tsx +++ b/src/auth.tsx @@ -9,6 +9,7 @@ import { DiscoveryDocument } from "expo-auth-session"; import * as Device from "expo-device"; import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; +import { CLIENT_ID, LEDEN_ADMIN, SCOPES } from "./env"; const redirectUri = AuthSession.makeRedirectUri({ path: "redirect" }); WebBrowser.maybeCompleteAuthSession(); @@ -98,7 +99,7 @@ async function registerForPushNotifications(user: AuthenticatedState) { const { data: pushToken } = await Notifications.getExpoPushTokenAsync({ projectId: config.extra?.eas.projectId, }); - await fetch("https://leden.djoamersfoort.nl/notifications/token", { + await fetch(`${LEDEN_ADMIN}/notifications/token`, { method: "POST", headers: { authorization: `Bearer ${await user.token}`, @@ -137,7 +138,7 @@ function createAuthState( }, body: new URLSearchParams({ grant_type: "refresh_token", - client_id: "QI0CNwnSLMJQbsZindMceAhtPR7lQlis0lTcCxGZ", + client_id: CLIENT_ID, refresh_token: refresh, }).toString(), }).then((res) => res.json()); @@ -173,16 +174,9 @@ function AuthScreen({ const [request, result, promptAsync] = AuthSession.useAuthRequest( { redirectUri, - clientId: "QI0CNwnSLMJQbsZindMceAhtPR7lQlis0lTcCxGZ", + clientId: CLIENT_ID, responseType: "code", - scopes: [ - "openid", - "user/basic", - "user/names", - "user/email", - "media", - "aanmelden", - ], + scopes: SCOPES, }, discovery, ); @@ -207,7 +201,7 @@ function AuthScreen({ }, body: new URLSearchParams({ grant_type: "authorization_code", - client_id: "QI0CNwnSLMJQbsZindMceAhtPR7lQlis0lTcCxGZ", + client_id: CLIENT_ID, code: result.params.code, redirect_uri: redirectUri, code_verifier: request?.codeVerifier, @@ -221,6 +215,7 @@ function AuthScreen({ await SecureStore.setItemAsync("id_token", token); await SecureStore.setItemAsync("expiration_date", expiry.toString()); await SecureStore.setItemAsync("refresh_token", refresh!); + await SecureStore.setItemAsync("scopes", JSON.stringify(SCOPES)); setAuthenticated( createAuthState(setAuthenticated, discovery, token, refresh!, expiry), @@ -268,9 +263,7 @@ function AuthScreen({ export function AuthProvider({ children }: { children: JSX.Element }) { const theme = useTheme(); - const discovery = AuthSession.useAutoDiscovery( - "https://leden.djoamersfoort.nl/o", - ); + const discovery = AuthSession.useAutoDiscovery(`${LEDEN_ADMIN}/o`); const [authenticated, setAuthenticated] = useState({ authenticated: Authed.LOADING, }); @@ -294,7 +287,12 @@ export function AuthProvider({ children }: { children: JSX.Element }) { const refresh = await SecureStore.getItemAsync("refresh_token"); const expiry = await SecureStore.getItemAsync("expiration_date"); - if (!token || !refresh || !expiry) { + const scopes = JSON.parse( + (await SecureStore.getItemAsync("scopes")) || "[]", + ) as string[]; + const missing = SCOPES.find((scope) => !scopes.includes(scope)); + + if (!token || !refresh || !expiry || missing) { return setAuthenticated({ authenticated: Authed.UNAUTHENTICATED, }); diff --git a/src/components/corvee/Create.tsx b/src/components/corvee/Create.tsx new file mode 100644 index 0000000..4440da3 --- /dev/null +++ b/src/components/corvee/Create.tsx @@ -0,0 +1,77 @@ +import { StyleSheet, View } from "react-native"; +import PresenceCard from "../register/precenseCard"; +import { useAtom, useAtomValue } from "jotai"; +import { membersAtom, Slot, slotsAtom } from "../../stores/register"; +import { getStatus, stateAtom } from "../../stores/corvee"; +import { useContext, useEffect, useState } from "react"; +import { Button, Text, useTheme } from "react-native-paper"; +import { CORVEE } from "../../env"; +import AuthContext, { Authed } from "../../auth"; + +export default function Create() { + const slots = useAtomValue(slotsAtom); + const members = useAtomValue(membersAtom); + const [state, setState] = useAtom(stateAtom); + const [loading, setLoading] = useState(false); + const theme = useTheme(); + const authState = useContext(AuthContext); + + const [slot, setSlot] = useState(); + + useEffect(() => { + if (!slots || !state) return setSlot(undefined); + + setSlot( + slots.find((slot) => slot.pod === state.pod && slot.name === state.day), + ); + }, [slots, state]); + + async function create() { + if (authState.authenticated !== Authed.AUTHENTICATED) return; + + setLoading(true); + const token = await authState.token; + await fetch(`${CORVEE}/api/v1/renew`, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + const state = await getStatus(token); + setLoading(false); + setState(state); + } + + if (!slot) return; + + return ( + <> + + + Wie is er aanwezig? + + + + + + ); +} + +const styles = StyleSheet.create({ + text: { + textAlign: "center", + marginBottom: 15, + }, + card: { + padding: 15, + borderRadius: 15, + flexGrow: 1, + }, +}); diff --git a/src/components/corvee/Listing.tsx b/src/components/corvee/Listing.tsx new file mode 100644 index 0000000..f7bd627 --- /dev/null +++ b/src/components/corvee/Listing.tsx @@ -0,0 +1,86 @@ +import { Button, Card } from "react-native-paper"; +import { ScrollView, StyleSheet, View } from "react-native"; +import { CorveeProfile, getStatus, stateAtom } from "../../stores/corvee"; +import { useContext, useState } from "react"; +import AuthContext, { Authed } from "../../auth"; +import { CORVEE } from "../../env"; +import { useAtom, useSetAtom } from "jotai"; + +enum Action { + ACKNOWLEDGE = "ack", + ABSENT = "absent", + INSUFFICIENT = "insuff", +} + +export default function Listing({ selected }: { selected: CorveeProfile }) { + const [loading, setLoading] = useState>(new Set()); + const setState = useSetAtom(stateAtom); + const authState = useContext(AuthContext); + + function action(id: string, action: Action) { + return async function () { + if (authState.authenticated !== Authed.AUTHENTICATED) return; + + setLoading(loading.add(action)); + + const token = await authState.token; + await fetch(`${CORVEE}/api/v1/${action}/${id}`, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + const state = await getStatus(token); + setState(state); + + loading.delete(action); + setLoading(loading); + }; + } + + return ( + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + actions: { + display: "flex", + flexDirection: "row", + gap: 10, + paddingBottom: 10, + }, +}); diff --git a/src/components/corvee/Selected.tsx b/src/components/corvee/Selected.tsx new file mode 100644 index 0000000..351d5e4 --- /dev/null +++ b/src/components/corvee/Selected.tsx @@ -0,0 +1,18 @@ +import { useAtom, useAtomValue } from "jotai"; +import { getStatus, stateAtom } from "../../stores/corvee"; +import { Button, Card } from "react-native-paper"; +import { ScrollView, StyleSheet, View } from "react-native"; +import { CORVEE } from "../../env"; +import { useContext, useState } from "react"; +import AuthContext, { Authed } from "../../auth"; +import Listing from "./Listing"; + +export default function Selected() { + const state = useAtomValue(stateAtom); + + if (!state) return <>; + + return state.current.map((selected) => ( + + )); +} diff --git a/src/components/register/precenseCard.tsx b/src/components/register/precenseCard.tsx new file mode 100644 index 0000000..0634895 --- /dev/null +++ b/src/components/register/precenseCard.tsx @@ -0,0 +1,80 @@ +import { Card, Surface, useTheme } from "react-native-paper"; +import { PaperSelect } from "react-native-paper-select"; +import { StyleSheet, View } from "react-native"; +import Presence from "./presence"; +import { getSlots, Member, Slot, slotsAtom } from "../../stores/register"; +import AuthContext, { Authed } from "../../auth"; +import { AANMELDEN } from "../../env"; +import { useContext } from "react"; +import { useSetAtom } from "jotai"; + +export default function PresenceCard({ + slot, + members, +}: { + slot: Slot; + members: Member[]; +}) { + const theme = useTheme(); + const authState = useContext(AuthContext); + const setSlots = useSetAtom(slotsAtom); + + async function registerManual(user: string) { + if (authState.authenticated !== Authed.AUTHENTICATED) return; + + const token = await authState.token; + await fetch( + `${AANMELDEN}/api/v1/register_manual/${slot.name}/${slot.pod}/${user}`, + { + headers: { + authorization: `Bearer ${token}`, + }, + }, + ); + + setSlots((await getSlots(token)).slots); + } + + if (!slot.presence) return; + + return ( + <> + (a.name < b.name ? -1 : 1)) + .map(({ id, name }) => ({ + _id: id.toString(), + value: name, + }))} + selectedArrayList={[]} + multiEnable={false} + value={""} + onSelection={async (selection) => { + if (!selection.selectedList[0]) return; + await registerManual(selection.selectedList[0]._id); + }} + theme={theme} + textInputStyle={{ + backgroundColor: theme.colors.elevation.level5, + color: theme.colors.onPrimaryContainer, + }} + searchStyle={{ + backgroundColor: theme.colors.elevation.level5, + }} + textColor={theme.colors.onPrimary} + /> + + {slot.presence.map((presence) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + presence: { + gap: 5, + }, +}); diff --git a/src/components/register/presence.tsx b/src/components/register/presence.tsx index ee8505f..c55694a 100644 --- a/src/components/register/presence.tsx +++ b/src/components/register/presence.tsx @@ -10,6 +10,7 @@ import { useContext, useState } from "react"; import AuthContext, { Authed } from "../../auth"; import { useAtom } from "jotai"; import { Platform } from "react-native"; +import { AANMELDEN } from "../../env"; export default function Presence({ presence, @@ -32,7 +33,7 @@ export default function Presence({ const token = await authState.token; await fetch( - `https://aanmelden.djoamersfoort.nl/api/v1/seen/${presence.id}/${presence.seen ? "true" : "false"}`, + `${AANMELDEN}/api/v1/seen/${presence.id}/${presence.seen ? "true" : "false"}`, { headers: { authorization: `Bearer ${token}`, diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..5f93559 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,20 @@ +export const CLIENT_ID = + process.env.EXPO_PUBLIC_CLIENT_ID || + "QI0CNwnSLMJQbsZindMceAhtPR7lQlis0lTcCxGZ"; +export const LEDEN_ADMIN = + process.env.EXPO_PUBLIC_LEDEN_ADMIN || "https://leden.djoamersfoort.nl"; +export const AANMELDEN = + process.env.EXPO_PUBLIC_AANMELDEN || "https://aanmelden.djoamersfoort.nl"; +export const CORVEE = + process.env.EXPO_PUBLIC_CORVEE || "https://corvee.djoamersfoort.nl"; +export const SCOPES: string[] = JSON.parse( + process.env.EXPO_PUBLIC_SCOPES || "null", +) || [ + "openid", + "user/basic", + "user/names", + "user/email", + "media", + "aanmelden", + "corvee", +]; diff --git a/src/screens/corvee/corvee.tsx b/src/screens/corvee/corvee.tsx new file mode 100644 index 0000000..ae6407f --- /dev/null +++ b/src/screens/corvee/corvee.tsx @@ -0,0 +1,86 @@ +import { useContext, useEffect, useState } from "react"; +import { useAtom, useSetAtom } from "jotai"; +import { getSlots, membersAtom, slotsAtom } from "../../stores/register"; +import AuthContext, { Authed } from "../../auth"; +import { RefreshControl, ScrollView, StyleSheet, View } from "react-native"; +import { ActivityIndicator, Appbar } from "react-native-paper"; +import { CorveeState, getStatus, stateAtom } from "../../stores/corvee"; +import Create from "../../components/corvee/Create"; +import Selected from "../../components/corvee/Selected"; + +export default function CorveeScreen() { + const [state, setState] = useAtom(stateAtom); + const setMembers = useSetAtom(membersAtom); + const [refreshing, setRefreshing] = useState(false); + const [slots, setSlots] = useAtom(slotsAtom); + const authState = useContext(AuthContext); + + useEffect(() => { + async function fetchDetails() { + if (authState.authenticated !== Authed.AUTHENTICATED) return; + + const token = await authState.token; + await Promise.all([ + !slots && + getSlots(token).then(({ slots, members }) => { + setSlots(slots); + setMembers(members || []); + }), + getStatus(token).then(setState), + ]); + } + + fetchDetails().then(); + }, []); + + async function refresh() { + if (authState.authenticated !== Authed.AUTHENTICATED) return; + + setRefreshing(true); + + const token = await authState.token; + await Promise.all([ + getSlots(token).then(({ slots, members }) => { + setSlots(slots); + setMembers(members || []); + }), + getStatus(token).then(setState), + ]); + + setRefreshing(false); + } + + return ( + <> + + + + + } + > + + {state ? ( + <> + {state.current.length === 0 && } + {state.current.length > 0 && } + + ) : ( + + )} + + + + ); +} + +const styles = StyleSheet.create({ + content: { + display: "flex", + flexGrow: 1, + padding: 10, + gap: 10, + }, +}); diff --git a/src/screens/feed/slot.tsx b/src/screens/feed/slot.tsx index 4a8dcad..af5f9c7 100644 --- a/src/screens/feed/slot.tsx +++ b/src/screens/feed/slot.tsx @@ -1,26 +1,13 @@ import { StackScreenProps } from "@react-navigation/stack"; -import { - Alert, - ScrollView, - StyleSheet, - TouchableOpacity, - View, -} from "react-native"; -import { - Button, - Card, - Checkbox, - Chip, - Text, - useTheme, -} from "react-native-paper"; +import { Alert, ScrollView, StyleSheet, View } from "react-native"; +import { Button, Card, Chip, Text } from "react-native-paper"; import { useContext, useEffect, useState } from "react"; import { getSlots, membersAtom, Slot, slotsAtom } from "../../stores/register"; import { useAtom } from "jotai"; import { StackParamList } from "../../../App"; import AuthContext, { Authed } from "../../auth"; -import Presence from "../../components/register/presence"; -import { PaperSelect } from "react-native-paper-select"; +import { AANMELDEN } from "../../env"; +import PresenceCard from "../../components/register/precenseCard"; type Props = StackScreenProps; @@ -31,26 +18,9 @@ export default function SlotScreen({ route, navigation }: Props) { const [slot, setSlot] = useState(slots[route.params.slot]); const [loading, setLoading] = useState(false); const [members] = useAtom(membersAtom); - const theme = useTheme(); const authState = useContext(AuthContext); - async function registerManual(user: string) { - if (authState.authenticated !== Authed.AUTHENTICATED) return; - - const token = await authState.token; - await fetch( - `https://aanmelden.djoamersfoort.nl/api/v1/register_manual/${slot.name}/${slot.pod}/${user}`, - { - headers: { - authorization: `Bearer ${token}`, - }, - }, - ); - - setSlots((await getSlots(token)).slots); - } - async function register() { setLoading(true); const token = @@ -59,7 +29,7 @@ export default function SlotScreen({ route, navigation }: Props) { : null; if (token) { const { error }: { error: string | undefined } = await fetch( - `https://aanmelden.djoamersfoort.nl/api/v1/${slot.is_registered ? "deregister" : "register"}/${slot.name}/${slot.pod}`, + `${AANMELDEN}/api/v1/${slot.is_registered ? "deregister" : "register"}/${slot.name}/${slot.pod}`, { headers: { authorization: `Bearer ${token}`, @@ -133,40 +103,7 @@ export default function SlotScreen({ route, navigation }: Props) { Leden - (a.name < b.name ? -1 : 1)) - .map(({ id, name }) => ({ - _id: id.toString(), - value: name, - }))} - selectedArrayList={[]} - multiEnable={false} - value={""} - onSelection={async (selection) => { - if (!selection.selectedList[0]) return; - await registerManual(selection.selectedList[0]._id); - }} - theme={theme} - textInputStyle={{ - backgroundColor: theme.colors.elevation.level5, - color: theme.colors.onPrimaryContainer, - }} - searchStyle={{ - backgroundColor: theme.colors.elevation.level5, - }} - textColor={theme.colors.onPrimary} - /> - - {slot.presence.map((presence) => ( - - ))} - + @@ -219,7 +156,4 @@ const styles = StyleSheet.create({ button: { borderRadius: 25, }, - presence: { - gap: 5, - }, }); diff --git a/src/screens/home.tsx b/src/screens/home.tsx index 0ea7e2b..f8a08ea 100644 --- a/src/screens/home.tsx +++ b/src/screens/home.tsx @@ -5,6 +5,7 @@ import SettingsScreen from "./settings"; import { useContext } from "react"; import AuthContext, { Authed } from "../auth"; import CalendarScreen from "./calendar/calendar"; +import CorveeScreen from "./corvee/corvee"; const Tab = createMaterialBottomTabNavigator(); @@ -29,6 +30,17 @@ export default function HomeScreen() { tabBarIcon: "calendar", }} /> + {authState.authenticated === Authed.AUTHENTICATED && + authState.user.account_type.includes("begeleider") && ( + + )} {authState.authenticated === Authed.AUTHENTICATED && ( (null); + +export async function getStatus(token: string) { + const res = await fetch(`${CORVEE}/api/v1/status`, { + headers: { + authorization: `Bearer ${token}`, + }, + }); + + return (await res.json()) as CorveeState; +} diff --git a/src/stores/feed.ts b/src/stores/feed.ts index d87e2db..4a84b6c 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -3,6 +3,7 @@ import { parse } from "rss-to-json"; import { Item } from "../screens/feed/search"; import { Asset } from "expo-asset"; import { SerializedComponent } from "unfucked-ical"; +import { LEDEN_ADMIN } from "../env"; export enum ActionType { LINK, @@ -72,7 +73,7 @@ export async function getAnnouncements( } const announcements: Record[] = await fetch( - "https://leden.djoamersfoort.nl/notifications/announcements", + `${LEDEN_ADMIN}/notifications/announcements`, { headers: { authorization: `Bearer ${token}`, diff --git a/src/stores/register.ts b/src/stores/register.ts index bd88dbb..112431d 100644 --- a/src/stores/register.ts +++ b/src/stores/register.ts @@ -1,5 +1,6 @@ import { atom } from "jotai"; import { nextFriday, nextSaturday } from "date-fns"; +import { AANMELDEN } from "../env"; export interface Presence { id: number; @@ -65,7 +66,7 @@ export async function getSlots(token: string | null) { } const { slots, members }: { slots: Slot[]; members?: Member[] } = await fetch( - "https://aanmelden.djoamersfoort.nl/api/v1/slots", + `${AANMELDEN}/api/v1/slots`, { headers: { authorization: `Bearer ${token}`,