Skip to content

Commit

Permalink
feat: add corvee (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
sverben authored Jun 12, 2024
1 parent 46524e9 commit bb8be12
Show file tree
Hide file tree
Showing 14 changed files with 433 additions and 91 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ yarn-error.*
*.tsbuildinfo

.idea
.env
30 changes: 14 additions & 16 deletions src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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,
);
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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<AuthState>({
authenticated: Authed.LOADING,
});
Expand All @@ -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,
});
Expand Down
77 changes: 77 additions & 0 deletions src/components/corvee/Create.tsx
Original file line number Diff line number Diff line change
@@ -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<Slot>();

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 (
<>
<View
style={[
styles.card,
{ backgroundColor: theme.colors.elevation.level1 },
]}
>
<Text variant={"titleMedium"} style={styles.text}>
Wie is er aanwezig?
</Text>
<PresenceCard slot={slot} members={members} />
</View>
<Button mode={"contained"} onPress={create} loading={loading}>
Maak lijst aan
</Button>
</>
);
}

const styles = StyleSheet.create({
text: {
textAlign: "center",
marginBottom: 15,
},
card: {
padding: 15,
borderRadius: 15,
flexGrow: 1,
},
});
86 changes: 86 additions & 0 deletions src/components/corvee/Listing.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<Action>>(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 (
<Card key={selected.id}>
<Card.Cover
style={{ aspectRatio: 1, height: undefined }}
source={{ uri: selected.picture }}
/>
<Card.Title title={`${selected.first_name} ${selected.last_name}`} />
<Card.Actions>
<ScrollView horizontal={true}>
<View style={styles.actions}>
<Button
loading={loading.has(Action.ACKNOWLEDGE)}
onPress={action(selected.id, Action.ACKNOWLEDGE)}
mode={"contained"}
>
Aftekenen
</Button>
<Button
loading={loading.has(Action.ABSENT)}
onPress={action(selected.id, Action.ABSENT)}
mode={"contained-tonal"}
>
Afwezig
</Button>
<Button
loading={loading.has(Action.INSUFFICIENT)}
onPress={action(selected.id, Action.INSUFFICIENT)}
mode={"contained-tonal"}
>
Onvoldoende
</Button>
</View>
</ScrollView>
</Card.Actions>
</Card>
);
}

const styles = StyleSheet.create({
actions: {
display: "flex",
flexDirection: "row",
gap: 10,
paddingBottom: 10,
},
});
18 changes: 18 additions & 0 deletions src/components/corvee/Selected.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Listing key={selected.id} selected={selected} />
));
}
80 changes: 80 additions & 0 deletions src/components/register/precenseCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<PaperSelect
label={"Lid handmatig aanmelden"}
arrayList={members
.sort((a, b) => (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}
/>
<View style={styles.presence}>
{slot.presence.map((presence) => (
<Presence key={presence.id} presence={presence} slot={slot} />
))}
</View>
</>
);
}

const styles = StyleSheet.create({
presence: {
gap: 5,
},
});
3 changes: 2 additions & 1 deletion src/components/register/presence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`,
Expand Down
20 changes: 20 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -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",
];
Loading

0 comments on commit bb8be12

Please sign in to comment.