diff --git a/README.md b/README.md index 592fc048..467a4cee 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ The respective README files there contain more detailed descriptions. * [skolplattformen-sthlm](#skolplattformen-sthlm) * [website](#website) * [Libs](#embedded-api) + * [api](#api) * [api-skolplattformen](#api-skolplattformen) + * [api-hjarntorget](#api-hjarntorget) * [curriculum](#curriculum) * [hooks](#hooks) * [Development](#development) @@ -48,6 +50,7 @@ The project consists of several apps and libraries inside [a NX](https://nx.dev/ The central part of the project is the app itself. It is written in [TypeScript](https://www.typescriptlang.org/) using [React Native](https://reactnative.dev/) and [React Native Kitten](https://akveo.github.io/react-native-ui-kitten/). Our main goal with the app is to make it as fast and easy to use as possible. \ + We're starting small, with more features being added over time. For more information, check out the [source code](apps/skolplattformen-sthlm). @@ -60,9 +63,16 @@ For more information, check out the [source code](apps/website). ### Libs /libs/ contains the library projects. There are many different kinds of libraries, and each library defines its own external API so that boundaries between libraries remain clear. -#### api-skolplattformen -(renamed from embedded-api) +#### api + +The base for all api implementations + +#### api-hjarntorget + +The implementation for the school platform in Gothenburg called Hjärntorget + +#### api-skolplattformen By not having to worry about the complex nature of the official API, the app becomes light-weight. \ It also makes it easier for others to develop their own applications for the Skolplattformen API. @@ -73,6 +83,7 @@ Check out the documentation [here](libs/api-skolplattformen). #### curriculum Translations of curriculum codes (sv: ämneskoder på schemat) to clear text descriptions + #### hooks To make it easier to use the the api in the app, we also created a set of React hooks. @@ -83,7 +94,6 @@ Check out the documentation [here](libs/hooks). To clone and build the project, you first need to install the required dependencies: ```bash $ sudo apt install git npm -$ npx lerna bootstrap ``` Clone the repo with @@ -91,6 +101,31 @@ Clone the repo with $ git clone https://github.com/kolplattformen/skolplattformen.git ``` +Install dependencies +``` +cd skolplattformen && yarn +``` + +Start the iOS app +``` +yarn run ios +``` + +Start the Android app +``` +yarn run android +``` + +Run all tests +``` +yarn run test +``` + +Run a specific test +``` +yarn run test:api-skolplattformen +``` + The README files for the [app](apps/skolplattformen-sthlm) and [website](apps/website) contain further instructions. ## Contributions @@ -128,6 +163,8 @@ If you're offended by this initiative, rest assured there is no reason to be — - [Andreas Eriksson](https://github.com/whyer) - [Kajetan Kazimierczak](https://github.com/kajetan-kazimierczak) - [Karin Nygårds (artwork)](https://github.com/grishund) +- [Jonathan Edenström](https://github.com/edenstrom) +- [Emil Hellman](https://github.com/archevel) - You? ## License diff --git a/apps/skolplattformen-sthlm/App.js b/apps/skolplattformen-sthlm/App.js deleted file mode 100644 index 9ff52f8b..00000000 --- a/apps/skolplattformen-sthlm/App.js +++ /dev/null @@ -1,84 +0,0 @@ -import * as eva from '@eva-design/eva' -import AsyncStorage from '@react-native-async-storage/async-storage' -import CookieManager from '@react-native-cookies/cookies' -import init from '@skolplattformen/api-skolplattformen' -import { ApiProvider } from '@skolplattformen/hooks' -import { ApplicationProvider, IconRegistry } from '@ui-kitten/components' -import { EvaIconsPack } from '@ui-kitten/eva-icons' -import React from 'react' -import { StatusBar, useColorScheme } from 'react-native' -import { SafeAreaProvider } from 'react-native-safe-area-context' -import { AppNavigator } from './components/navigation.component' -import { LanguageProvider } from './context/language/languageContext' -import { default as customMapping } from './design/mapping.json' -import { darkTheme, lightTheme } from './design/themes' -import useSettingsStorage from './hooks/useSettingsStorage' -import { translations } from './utils/translation' -const api = init(fetch, CookieManager) - -const reporter = __DEV__ - ? { - log: (message) => console.log(message), - error: (error, label) => console.error(label, error), - } - : undefined - -if (__DEV__) { - const DevMenu = require('react-native-dev-menu') - DevMenu.addItem('Log AsyncStorage contents', () => logAsyncStorage()) -} - -const safeJsonParse = (maybeJson) => { - if (maybeJson) { - try { - return JSON.parse(maybeJson) - } catch (error) { - return maybeJson - } - } - return 'null' -} - -const logAsyncStorage = async () => { - const allKeys = await AsyncStorage.getAllKeys() - const keysAndValues = await AsyncStorage.multiGet(allKeys) - console.log('*** AsyncStorage contents:') - keysAndValues.forEach((keyAndValue) => { - console.log( - keyAndValue[0], - '=>', - keyAndValue[1] ? safeJsonParse(keyAndValue[1]) : 'null' - ) - }) - console.log('***') -} - -export default () => { - const [usingSystemTheme] = useSettingsStorage('usingSystemTheme') - const [theme] = useSettingsStorage('theme') - const systemTheme = useColorScheme() - - const colorScheme = usingSystemTheme ? systemTheme : theme - - return ( - - - - - - - - - - - - ) -} diff --git a/apps/skolplattformen-sthlm/App.tsx b/apps/skolplattformen-sthlm/App.tsx new file mode 100644 index 00000000..4b278c6c --- /dev/null +++ b/apps/skolplattformen-sthlm/App.tsx @@ -0,0 +1,105 @@ +import * as eva from '@eva-design/eva' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { ApiProvider, Reporter } from '@skolplattformen/hooks' +import { ApplicationProvider, IconRegistry, Text } from '@ui-kitten/components' +import { EvaIconsPack } from '@ui-kitten/eva-icons' +import React from 'react' +import { StatusBar, useColorScheme, View } from 'react-native' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { AppNavigator } from './components/navigation.component' +import { FeatureProvider } from './context/feature/featureContext' +import { LanguageProvider } from './context/language/languageContext' +import { SchoolPlatformProvider } from './context/schoolPlatform/schoolPlatformContext' +import { schoolPlatforms } from './data/schoolPlatforms' +import { default as customMapping } from './design/mapping.json' +import { darkTheme, lightTheme } from './design/themes' +import useSettingsStorage from './hooks/useSettingsStorage' +import { translations } from './utils/translation' + +const reporter: Reporter | undefined = __DEV__ + ? { + log: (message: string) => console.log(message), + error: (error: Error, label?: string) => console.error(label, error), + } + : undefined + +if (__DEV__) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const DevMenu = require('react-native-dev-menu') + DevMenu.addItem('Log AsyncStorage contents', () => logAsyncStorage()) +} + +const safeJsonParse = (maybeJson: string) => { + if (maybeJson) { + try { + return JSON.parse(maybeJson) + } catch (error) { + return maybeJson + } + } + return 'null' +} + +const logAsyncStorage = async () => { + const allKeys = await AsyncStorage.getAllKeys() + const keysAndValues = await AsyncStorage.multiGet(allKeys) + console.log('*** AsyncStorage contents:') + keysAndValues.forEach((keyAndValue) => { + console.log( + keyAndValue[0], + '=>', + keyAndValue[1] ? safeJsonParse(keyAndValue[1]) : 'null' + ) + }) + console.log('***') +} + +export default () => { + const [usingSystemTheme] = useSettingsStorage('usingSystemTheme') + const [currentSchoolPlatform] = useSettingsStorage('currentSchoolPlatform') + const [theme] = useSettingsStorage('theme') + const systemTheme = useColorScheme() + const colorScheme = usingSystemTheme ? systemTheme : theme + + const platform = schoolPlatforms.find((pf) => pf.id === currentSchoolPlatform) + + if (!platform) + return ( + + ERROR + + ) + + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/libs/api-skolplattformen/lib/__mocks__/@react-native-cookies/cookies.ts b/apps/skolplattformen-sthlm/__mocks__/@react-native-cookies/cookies.ts similarity index 100% rename from libs/api-skolplattformen/lib/__mocks__/@react-native-cookies/cookies.ts rename to apps/skolplattformen-sthlm/__mocks__/@react-native-cookies/cookies.ts diff --git a/apps/skolplattformen-sthlm/android/app/build.gradle b/apps/skolplattformen-sthlm/android/app/build.gradle index f8a3862b..321648d7 100644 --- a/apps/skolplattformen-sthlm/android/app/build.gradle +++ b/apps/skolplattformen-sthlm/android/app/build.gradle @@ -134,7 +134,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 20000 - versionName "2.0.4" + versionName "3.0.0" } splits { abi { diff --git a/apps/skolplattformen-sthlm/android/app/src/debug/java/com/app/ReactNativeFlipper.java b/apps/skolplattformen-sthlm/android/app/src/debug/java/com/app/ReactNativeFlipper.java index a58c8100..9f3d347f 100644 --- a/apps/skolplattformen-sthlm/android/app/src/debug/java/com/app/ReactNativeFlipper.java +++ b/apps/skolplattformen-sthlm/android/app/src/debug/java/com/app/ReactNativeFlipper.java @@ -7,6 +7,8 @@ package com.app; import android.content.Context; +import android.util.Log; + import com.facebook.flipper.android.AndroidFlipperClient; import com.facebook.flipper.android.utils.FlipperUtils; import com.facebook.flipper.core.FlipperClient; @@ -22,6 +24,13 @@ import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPl import com.facebook.react.ReactInstanceManager; import com.facebook.react.bridge.ReactContext; import com.facebook.react.modules.network.NetworkingModule; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import okhttp3.Cookie; +import okhttp3.CookieJar; import okhttp3.OkHttpClient; public class ReactNativeFlipper { @@ -40,6 +49,7 @@ public class ReactNativeFlipper { new NetworkingModule.CustomClientBuilder() { @Override public void apply(OkHttpClient.Builder builder) { + builder.callTimeout(5000, TimeUnit.MILLISECONDS); builder.addNetworkInterceptor(new FlipperOkhttpInterceptor(networkFlipperPlugin)); } }); diff --git a/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/CookieInterceptor.java b/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/CookieInterceptor.java new file mode 100644 index 00000000..66fa96a8 --- /dev/null +++ b/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/CookieInterceptor.java @@ -0,0 +1,79 @@ +package org.skolplattformen.app; + +import android.util.Log; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import kotlin.Pair; +import okhttp3.Cookie; +import okhttp3.CookieJar; +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +class CookieInterceptor implements Interceptor { + private final List cookies; + private final CookieJar cookieJar; + + public CookieInterceptor(CookieJar cookieJar) { + this.cookies = new ArrayList<>(); + this.cookieJar = cookieJar; + } + + @NotNull + @Override + public Response intercept(@NotNull Chain chain) throws IOException { + // TODO: Clean up the code so only the necessary feeding of cookies to + // the cookie jar remains. That is needed because of: + // https://reactnative.dev/docs/0.64/network#known-issues-with-fetch-and-cookie-based-authentication + // Specifically react native's fetch does not respect multiple `set-cookie` headers and only + // seem to set one cookie per request. Some of the login calls in the api-hjarntorget lib + // receives multiple `set-cookie` headers. + String domain = chain.request().url().topPrivateDomain(); + Log.d("Skolplattformen", "requseting resource on domain: " + domain); + if(domain == null || !domain.contains("goteborg.se") && !domain.contains("funktionstjanster.se")) { + return chain.proceed(chain.request()); + } + + Log.d("Skolplattformen", "\n\n<<<<<<<<<<<<<<<<<<<<< BEGIN >>>>>>>>>>>>>>>>>>"); + Log.d("Skolplattformen", "" + chain.request().method() + " " + chain.request().url()); + Log.d("Skolplattformen", "url have length: " + chain.request().url().toString().length()); + Iterator> iterator = chain.request().headers().iterator(); + while (iterator.hasNext()) { + Pair header = iterator.next(); + Log.d("Skolplattformen", "SENT " + header.getFirst() + ": " + header.getSecond() + ""); + } + Request request = chain.request(); + Response response = chain.proceed(request); + + String location = response.header("Location"); + location = location != null ? location : ""; + Log.d("Skolplattformen", "url=" + response.request().url()); + Log.d("Skolplattformen", "isRedirect=" + response.isRedirect()); + Log.d("Skolplattformen", "responseCode=" + response.code()); + Log.d("Skolplattformen", "redirectUri has length=" + location.length()); + + iterator = response.headers().iterator(); + cookies.clear(); + while (iterator.hasNext()) { + Pair header = iterator.next(); + Log.d("Skolplattformen", "RECEIVED " + header.getFirst() + ": " + header.getSecond() + ""); + if (header.getFirst().equals("Set-Cookie")) { + Cookie c = Cookie.parse(response.request().url(), header.getSecond()); + cookies.add(c); + } + } + + HttpUrl url = new HttpUrl.Builder().host(request.url().host()).scheme("https").build(); + cookieJar.saveFromResponse(url, cookies); + Log.d("Skolplattformen", "<<<<<<<<<<<<<<<<<<<<< END >>>>>>>>>>>>>>>>>>\n\n"); + return response; + + } +} diff --git a/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/MainApplication.java b/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/MainApplication.java index 496190f1..dabee36a 100644 --- a/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/MainApplication.java +++ b/apps/skolplattformen-sthlm/android/app/src/main/java/org/skolplattformen/app/MainApplication.java @@ -9,6 +9,9 @@ import com.facebook.react.ReactApplication; import com.facebook.react.ReactInstanceManager; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.modules.network.OkHttpClientProvider; +import com.facebook.react.modules.network.ReactCookieJarContainer; import com.facebook.soloader.SoLoader; import java.io.IOException; @@ -16,6 +19,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.Collections; import java.util.List; +import okhttp3.CookieJar; import okhttp3.Interceptor; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -54,6 +58,14 @@ public class MainApplication extends Application implements ReactApplication { super.onCreate(); SoLoader.init(this, /* native exopackage */ false); initializeFlipper(this, getReactNativeHost().getReactInstanceManager()); + + OkHttpClientProvider.setOkHttpClientFactory(() -> { + ReactContext currentReactContext = mReactNativeHost.getReactInstanceManager().getCurrentReactContext(); + OkHttpClient.Builder builder = OkHttpClientProvider.createClientBuilder(currentReactContext); + @SuppressWarnings("KotlinInternalInJava") CookieJar cookieJar = builder.getCookieJar$okhttp(); + builder.addNetworkInterceptor(new CookieInterceptor(cookieJar)); + return builder.build(); + }); } /** diff --git a/apps/skolplattformen-sthlm/components/__tests__/Auth.test.js b/apps/skolplattformen-sthlm/components/__tests__/Auth.test.js deleted file mode 100644 index 42c6d8cd..00000000 --- a/apps/skolplattformen-sthlm/components/__tests__/Auth.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { useApi } from '@skolplattformen/hooks' -import React from 'react' -import { render } from '../../utils/testHelpers' -import { Auth } from '../auth.component' - -const setup = () => { - useApi.mockReturnValue({ - api: { on: jest.fn(), off: jest.fn() }, - isLoggedIn: false, - }) - - const navigation = { - navigate: jest.fn(), - } - - return render() -} - -test('renders a random fun argument state', () => { - const screen = setup() - - expect(screen.getByText(/öppna skolplattformen/i)).toBeTruthy() - expect(screen.getByText(/det [a-zåäö]+ alternativet/i)).toBeTruthy() -}) diff --git a/apps/skolplattformen-sthlm/components/child.component.tsx b/apps/skolplattformen-sthlm/components/child.component.tsx index 03146ef3..33bfbc92 100644 --- a/apps/skolplattformen-sthlm/components/child.component.tsx +++ b/apps/skolplattformen-sthlm/components/child.component.tsx @@ -22,6 +22,7 @@ import { NewsList } from './newsList.component' import { NotificationsList } from './notificationsList.component' import { Classmates } from './classmates.component' import { TabBarLabel } from './tabBarLabel.component' +import { useFeature } from '../hooks/useFeature' type ChildNavigationProp = StackNavigationProp type ChildRouteProps = RouteProp @@ -47,10 +48,20 @@ const CalendarScreen = () => const MenuScreen = () => const ClassmatesScreen = () => +interface ScreenSettings { + NEWS_SCREEN: boolean + NOTIFICATIONS_SCREEN: boolean + CALENDER_SCREEN: boolean + MENU_SCREEN: boolean + CLASSMATES_SCREEN: boolean +} + const TabNavigator = ({ initialRouteName = 'News', + screenSettings, }: { initialRouteName?: keyof ChildTabParamList + screenSettings: ScreenSettings }) => ( - - - - - + {screenSettings.NEWS_SCREEN && ( + + )} + {screenSettings.NOTIFICATIONS_SCREEN && ( + + )} + {screenSettings.CALENDER_SCREEN && ( + + )} + {screenSettings.MENU_SCREEN && ( + + )} + {screenSettings.CLASSMATES_SCREEN && ( + + )} ) @@ -153,6 +174,8 @@ export const childRouteOptions = export const Child = () => { const route = useRoute() const { child, initialRouteName } = route.params + const useFoodMenu = useFeature('FOOD_MENU') + const useClassList = useFeature('CLASS_LIST') const navigation = useNavigation() @@ -160,9 +183,17 @@ export const Child = () => { navigation.setOptions({ title: getHeaderTitle(route) }) }, [navigation, route]) + const screenSettings: ScreenSettings = { + NEWS_SCREEN: true, + NOTIFICATIONS_SCREEN: true, + CALENDER_SCREEN: true, + MENU_SCREEN: useFoodMenu, + CLASSMATES_SCREEN: useClassList, + } return ( diff --git a/apps/skolplattformen-sthlm/components/login.component.tsx b/apps/skolplattformen-sthlm/components/login.component.tsx index 19f5ca6d..66dc2b8b 100644 --- a/apps/skolplattformen-sthlm/components/login.component.tsx +++ b/apps/skolplattformen-sthlm/components/login.component.tsx @@ -13,7 +13,7 @@ import { useStyleSheet, } from '@ui-kitten/components' import Personnummer from 'personnummer' -import React, { useEffect, useState } from 'react' +import React, { useContext, useEffect, useState } from 'react' import { Image, Linking, @@ -22,6 +22,9 @@ import { View, } from 'react-native' import { schema } from '../app.json' +import { SchoolPlatformContext } from '../context/schoolPlatform/schoolPlatformContext' +import { schoolPlatforms } from '../data/schoolPlatforms' +import { useFeature } from '../hooks/useFeature' import useSettingsStorage from '../hooks/useSettingsStorage' import { useTranslation } from '../hooks/useTranslation' import { Layout } from '../styles' @@ -40,6 +43,18 @@ const BankId = () => ( /> ) +interface Logins { + BANKID_SAME_DEVICE: number + BANKID_ANOTHER_DEVICE: number + TEST_USER: number +} + +const LoginMethods: Logins = { + BANKID_SAME_DEVICE: 0, + BANKID_ANOTHER_DEVICE: 2, + TEST_USER: 3, +} + export const Login = () => { const { api } = useApi() const [cancelLoginRequest, setCancelLoginRequest] = useState< @@ -47,21 +62,30 @@ export const Login = () => { >(() => () => null) const [visible, showModal] = useState(false) const [showLoginMethod, setShowLoginMethod] = useState(false) + const [showSchoolPlatformPicker, setShowSchoolPlatformPicker] = + useState(false) const [error, setError] = useState(null) const [personalIdNumber, setPersonalIdNumber] = useSettingsStorage( 'cachedPersonalIdentityNumber' ) - const [loginMethodIndex, setLoginMethodIndex] = - useSettingsStorage('loginMethodIndex') + const [loginMethodId, setLoginMethodId] = useSettingsStorage('loginMethodId') + + const loginBankIdSameDeviceWithoutId = useFeature( + 'LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID' + ) + const { currentSchoolPlatform, changeSchoolPlatform } = useContext( + SchoolPlatformContext + ) + const { t } = useTranslation() const valid = Personnummer.valid(personalIdNumber) const loginMethods = [ - t('auth.bankid.OpenOnThisDevice'), - t('auth.bankid.OpenOnAnotherDevice'), - t('auth.loginAsTestUser'), - ] + { id: 'thisdevice', title: t('auth.bankid.OpenOnThisDevice') }, + { id: 'otherdevice', title: t('auth.bankid.OpenOnAnotherDevice') }, + { id: 'testuser', title: t('auth.loginAsTestUser') }, + ] as const const loginHandler = async () => { showModal(false) @@ -74,14 +98,15 @@ export const Login = () => { } }, [api]) - /* Helpers */ - const handleInput = (text: string) => { - setPersonalIdNumber(text) + const getSchoolPlatformName = () => { + return schoolPlatforms.find((item) => item.id === currentSchoolPlatform) + ?.displayName } const openBankId = (token: string) => { try { - const redirect = loginMethodIndex === 0 ? encodeURIComponent(schema) : '' + const redirect = + loginMethodId === 'thisdevice' ? encodeURIComponent(schema) : '' const bankIdUrl = Platform.OS === 'ios' ? `https://app.bankid.com/?autostarttoken=${token}&redirect=${redirect}` @@ -92,19 +117,24 @@ export const Login = () => { } } + const isUsingPersonalIdNumber = + loginMethodId === 'otherdevice' || + (loginMethodId === 'thisdevice' && !loginBankIdSameDeviceWithoutId) + const startLogin = async (text: string) => { - if (loginMethodIndex < 2) { + if (loginMethodId === 'thisdevice' || loginMethodId === 'otherdevice') { showModal(true) let ssn - if (loginMethodIndex === 1) { + + if (isUsingPersonalIdNumber) { ssn = Personnummer.parse(text).format(true) setPersonalIdNumber(ssn) } const status = await api.login(ssn) setCancelLoginRequest(() => () => status.cancel()) - if (status.token !== 'fake' && loginMethodIndex === 0) { + if (status.token !== 'fake' && loginMethodId === 'thisdevice') { openBankId(status.token) } status.on('PENDING', () => console.log('BankID app not yet opened')) @@ -125,10 +155,14 @@ export const Login = () => { const styles = useStyleSheet(themedStyles) + const currentLoginMethod = + loginMethods.find((method) => method.id === loginMethodId) || + loginMethods[0] + return ( <> - {loginMethodIndex === 1 && ( + {isUsingPersonalIdNumber && ( { accessoryRight={(props) => ( handleInput('')} + onPress={() => setPersonalIdNumber('')} accessibilityHint={t( 'login.a11y_clear_social_security_input_field', - { - defaultValue: 'Rensa fältet för personnummer', - } + { defaultValue: 'Rensa fältet för personnummer' } )} > @@ -153,7 +185,7 @@ export const Login = () => { keyboardType="numeric" onSubmitEditing={(event) => startLogin(event.nativeEvent.text)} caption={error || ''} - onChangeText={(text) => handleInput(text)} + onChangeText={setPersonalIdNumber} placeholder={t('auth.placeholder_SocialSecurityNumber')} /> )} @@ -163,12 +195,12 @@ export const Login = () => { onPress={() => startLogin(personalIdNumber)} style={styles.loginButton} appearance="ghost" - disabled={loginMethodIndex === 1 && !valid} + disabled={isUsingPersonalIdNumber && !valid} status="primary" accessoryLeft={BankId} size="medium" > - {loginMethods[loginMethodIndex]} + {currentLoginMethod.title} + { ItemSeparatorComponent={Divider} renderItem={({ item, index }) => ( { - setLoginMethodIndex(index) + setLoginMethodId(item.id) setShowLoginMethod(false) }} /> @@ -245,6 +290,42 @@ export const Login = () => { + setShowSchoolPlatformPicker(false)} + backdropStyle={styles.backdrop} + > + + + {t('auth.chooseSchoolPlatform')} + + ( + { + changeSchoolPlatform(item.id) + setShowSchoolPlatformPicker(false) + }} + /> + )} + /> + + + ) } @@ -271,4 +352,7 @@ const themedStyles = StyleService.create({ width: 20, height: 20, }, + platformPicker: { + width: '100%', + }, }) diff --git a/apps/skolplattformen-sthlm/components/navigationTitle.component.tsx b/apps/skolplattformen-sthlm/components/navigationTitle.component.tsx index a3de0ea4..6ab083e6 100644 --- a/apps/skolplattformen-sthlm/components/navigationTitle.component.tsx +++ b/apps/skolplattformen-sthlm/components/navigationTitle.component.tsx @@ -14,7 +14,11 @@ interface NavigationTitleProps { export const NavigationTitle = ({ title, subtitle }: NavigationTitleProps) => { return ( - {title} + {title && ( + + {title} + + )} {subtitle} ) @@ -25,7 +29,7 @@ const styles = StyleSheet.create({ ...Layout.center, }, title: { - ...fontSize.base, + ...fontSize.sm, fontWeight: '500', }, subtitle: { ...fontSize.xxs }, diff --git a/apps/skolplattformen-sthlm/components/newsItem.component.tsx b/apps/skolplattformen-sthlm/components/newsItem.component.tsx index da3d8cfa..aadda62c 100644 --- a/apps/skolplattformen-sthlm/components/newsItem.component.tsx +++ b/apps/skolplattformen-sthlm/components/newsItem.component.tsx @@ -38,10 +38,7 @@ export const newsItemRouteOptions = return { ...defaultStackStyling(darkMode), headerCenter: () => ( - + ), headerLargeTitle: false, } @@ -58,6 +55,9 @@ export const NewsItem = ({ route }: NewsItemProps) => { contentContainerStyle={styles.article} style={styles.scrollView} > + + {newsItem.header} + {dateIsValid(newsItem.published) && ( { const displayDate = date ? moment(date).fromNow() : null const sharedCookiesEnabled = Boolean( - item.url && item.url.startsWith('https://start.unikum.net/') + item.url && + (item.url.startsWith('https://start.unikum.net/') || + item.url.startsWith('https://hjarntorget.goteborg.se')) ) return ( diff --git a/apps/skolplattformen-sthlm/components/week.component.tsx b/apps/skolplattformen-sthlm/components/week.component.tsx index 0c70aae2..8be3925f 100644 --- a/apps/skolplattformen-sthlm/components/week.component.tsx +++ b/apps/skolplattformen-sthlm/components/week.component.tsx @@ -70,7 +70,12 @@ const LessonList = ({ lessons, header, lunch }: LessonListProps) => { >{`${timeStart.slice(0, 5)}-${timeEnd.slice(0, 5)} ${ location === '' ? '' : '(' + location + ')' } `} - + {code?.toUpperCase() === 'LUNCH' ? lunch?.description : teacher} @@ -178,7 +183,7 @@ const themedStyles = StyleService.create({ padding: 0, }, item: { - height: 55, + height: 90, backgroundColor: 'background-basic-color-2', paddingHorizontal: 0, borderRadius: 2, diff --git a/apps/skolplattformen-sthlm/context/feature/featureContext.tsx b/apps/skolplattformen-sthlm/context/feature/featureContext.tsx new file mode 100644 index 00000000..82ae6762 --- /dev/null +++ b/apps/skolplattformen-sthlm/context/feature/featureContext.tsx @@ -0,0 +1,19 @@ +import { Features, FeatureType } from '@skolplattformen/api' +import React from 'react' + +export const FeatureFlagsContext = React.createContext({ + LOGIN_BANK_ID_SAME_DEVICE: false, + FOOD_MENU: false, +}) + +interface Props { + features: Features +} + +export const FeatureProvider: React.FC = (props) => { + return ( + + {props.children} + + ) +} diff --git a/apps/skolplattformen-sthlm/context/language/languageContext.tsx b/apps/skolplattformen-sthlm/context/language/languageContext.tsx index 01c8b1a4..872324c9 100644 --- a/apps/skolplattformen-sthlm/context/language/languageContext.tsx +++ b/apps/skolplattformen-sthlm/context/language/languageContext.tsx @@ -20,7 +20,7 @@ export const LanguageContext = React.createContext({ interface Props { children: ReactNode data: any - initialLanguageCode: string + initialLanguageCode?: string cache: any } diff --git a/apps/skolplattformen-sthlm/context/schoolPlatform/schoolPlatformContext.tsx b/apps/skolplattformen-sthlm/context/schoolPlatform/schoolPlatformContext.tsx new file mode 100644 index 00000000..e223ec41 --- /dev/null +++ b/apps/skolplattformen-sthlm/context/schoolPlatform/schoolPlatformContext.tsx @@ -0,0 +1,36 @@ +import useSettingsStorage from '../../hooks/useSettingsStorage' +import React, { createContext } from 'react' + +interface SchoolPlatformProps { + currentSchoolPlatform?: string + changeSchoolPlatform: (platform: string) => void +} + +const defaultState: SchoolPlatformProps = { + changeSchoolPlatform: (platform: string) => + console.log('DEBUG ONLY: changing to', platform), +} + +export const SchoolPlatformProvider: React.FC = ({ children }) => { + const [currentSchoolPlatform, setCurrentSchoolPlatform] = useSettingsStorage( + 'currentSchoolPlatform' + ) + + const changeSchoolPlatform = (platform: string) => { + setCurrentSchoolPlatform(platform) + } + + return ( + + {children} + + ) +} + +export const SchoolPlatformContext = + createContext(defaultState) diff --git a/apps/skolplattformen-sthlm/data/schoolPlatforms.ts b/apps/skolplattformen-sthlm/data/schoolPlatforms.ts new file mode 100644 index 00000000..70f66019 --- /dev/null +++ b/apps/skolplattformen-sthlm/data/schoolPlatforms.ts @@ -0,0 +1,22 @@ +import CookieManager from '@react-native-cookies/cookies' +import initHjarntorget, { + features as featuresHjarntorget, +} from '@skolplattformen/api-hjarntorget' +import initSkolplattformen, { + features as featuresSkolPlattformen, +} from '@skolplattformen/api-skolplattformen' + +export const schoolPlatforms = [ + { + id: 'stockholm-skolplattformen', + displayName: 'Stockholm stad (Skolplattformen)', + api: initSkolplattformen(fetch, CookieManager), + features: featuresSkolPlattformen, + }, + { + id: 'goteborg-hjarntorget', + displayName: 'Göteborg stad (Hjärntorget)', + api: initHjarntorget(fetch, CookieManager), + features: featuresHjarntorget, + }, +] diff --git a/apps/skolplattformen-sthlm/hooks/useFeature.tsx b/apps/skolplattformen-sthlm/hooks/useFeature.tsx new file mode 100644 index 00000000..fb822ddb --- /dev/null +++ b/apps/skolplattformen-sthlm/hooks/useFeature.tsx @@ -0,0 +1,12 @@ +import { Features, FeatureType } from '@skolplattformen/api' +import React from 'react' +import { FeatureFlagsContext } from '../context/feature/featureContext' + +export const useFeature = (name: FeatureType) => { + const features = React.useContext(FeatureFlagsContext) + if (features === null) { + throw new Error('You must wrap your components in a FeatureProvider.') + } + + return features[name] +} diff --git a/apps/skolplattformen-sthlm/hooks/useSettingsStorage.tsx b/apps/skolplattformen-sthlm/hooks/useSettingsStorage.tsx index 607da624..7fdfb501 100644 --- a/apps/skolplattformen-sthlm/hooks/useSettingsStorage.tsx +++ b/apps/skolplattformen-sthlm/hooks/useSettingsStorage.tsx @@ -5,10 +5,13 @@ import AppStorage from '../services/appStorage' export const settingsState = proxy({ hydrated: false, settings: { - loginMethodIndex: 0, + loginMethodId: 'thisdevice' as 'thisdevice' | 'otherdevice' | 'testuser', usingSystemTheme: true, theme: 'light', cachedPersonalIdentityNumber: '', + currentSchoolPlatform: 'stockholm-skolplattformen' as + | 'stockholm-skolplattformen' + | 'goteborg-hjarntorget', }, }) diff --git a/apps/skolplattformen-sthlm/ios/Podfile.lock b/apps/skolplattformen-sthlm/ios/Podfile.lock index fef1aa99..fda48c7f 100644 --- a/apps/skolplattformen-sthlm/ios/Podfile.lock +++ b/apps/skolplattformen-sthlm/ios/Podfile.lock @@ -669,4 +669,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 85f5a2dfa1de342b427eecb6e9652410ad153247 -COCOAPODS: 1.11.2 +COCOAPODS: 1.10.1 diff --git a/apps/skolplattformen-sthlm/ios/app.xcodeproj/project.pbxproj b/apps/skolplattformen-sthlm/ios/app.xcodeproj/project.pbxproj index a39009e5..739ab8d7 100644 --- a/apps/skolplattformen-sthlm/ios/app.xcodeproj/project.pbxproj +++ b/apps/skolplattformen-sthlm/ios/app.xcodeproj/project.pbxproj @@ -793,7 +793,7 @@ ENABLE_BITCODE = NO; INFOPLIST_FILE = app/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.0.4; + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -822,7 +822,7 @@ DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = app/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.0.4; + MARKETING_VERSION = 3.0.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/skolplattformen-sthlm/translations/en.json b/apps/skolplattformen-sthlm/translations/en.json index b6c547b6..d85f7932 100644 --- a/apps/skolplattformen-sthlm/translations/en.json +++ b/apps/skolplattformen-sthlm/translations/en.json @@ -24,6 +24,7 @@ "Waiting": "Waiting for BankID…" }, "chooseLoginMethod": "Choose login method", + "chooseSchoolPlatform": "Choose platform", "loginAsTestUser": "Log in as a test user", "loginFailed": "Could not log in. Please try again.", "placeholder_SocialSecurityNumber": "Your personal identity number", diff --git a/apps/skolplattformen-sthlm/translations/sv.json b/apps/skolplattformen-sthlm/translations/sv.json index 18a21bb6..717a78f7 100644 --- a/apps/skolplattformen-sthlm/translations/sv.json +++ b/apps/skolplattformen-sthlm/translations/sv.json @@ -24,6 +24,7 @@ "Waiting": "Väntar på BankID…" }, "chooseLoginMethod": "Välj inloggningsmetod", + "chooseSchoolPlatform": "Välj plattform", "loginAsTestUser": "Logga in som testanvändare", "loginFailed": "Inloggningen misslyckades, försök igen!", "placeholder_SocialSecurityNumber": "Ditt personnummer", diff --git a/libs/api-hjarntorget/.babelrc.js b/libs/api-hjarntorget/.babelrc.js new file mode 100644 index 00000000..77c7288d --- /dev/null +++ b/libs/api-hjarntorget/.babelrc.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-typescript', + ], +} diff --git a/libs/api-hjarntorget/.eslintrc b/libs/api-hjarntorget/.eslintrc new file mode 100644 index 00000000..72de0bda --- /dev/null +++ b/libs/api-hjarntorget/.eslintrc @@ -0,0 +1,23 @@ +{ + "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "public", ".cache", "node_modules"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "@typescript-eslint/ban-ts-comment": "off" + } + }, + { + "files": ["*.js", "*.jsx"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] + } + \ No newline at end of file diff --git a/libs/api-hjarntorget/.gitignore b/libs/api-hjarntorget/.gitignore new file mode 100644 index 00000000..5092bddb --- /dev/null +++ b/libs/api-hjarntorget/.gitignore @@ -0,0 +1,108 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +record + +dist/* \ No newline at end of file diff --git a/libs/api-hjarntorget/.prettierrc b/libs/api-hjarntorget/.prettierrc new file mode 100644 index 00000000..da36fd16 --- /dev/null +++ b/libs/api-hjarntorget/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "jsxBracketSameLine": false +} diff --git a/libs/api-hjarntorget/.releaserc b/libs/api-hjarntorget/.releaserc new file mode 100644 index 00000000..518f8dda --- /dev/null +++ b/libs/api-hjarntorget/.releaserc @@ -0,0 +1,3 @@ +{ + "branches": ["main"] +} \ No newline at end of file diff --git a/libs/api-hjarntorget/LICENSE b/libs/api-hjarntorget/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/libs/api-hjarntorget/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/api-hjarntorget/__mocks__/@react-native-cookies/cookies.ts b/libs/api-hjarntorget/__mocks__/@react-native-cookies/cookies.ts new file mode 100644 index 00000000..7ce777bc --- /dev/null +++ b/libs/api-hjarntorget/__mocks__/@react-native-cookies/cookies.ts @@ -0,0 +1,81 @@ +import { CookieJar, Cookie as TCookie } from 'tough-cookie' + +export interface Cookie { + name: string + value: string + path?: string + domain?: string + version?: string + expires?: string + secure?: boolean + httpOnly?: boolean +} + +export interface Cookies { + [key: string]: Cookie +} + +export interface CookieManagerStatic { + set(url: string, cookie: Cookie, useWebKit?: boolean): Promise + setFromResponse(url: string, cookie: string): Promise + + get(url: string, useWebKit?: boolean): Promise + + clearAll(useWebKit?: boolean): Promise +} + +const convertTtoC = (cookie: string | TCookie): Cookie => { + if (typeof cookie === 'string') { + return convertTtoC(TCookie.parse(cookie) as TCookie) + } + return { + name: cookie.key, + value: cookie.value, + domain: cookie.domain || undefined, + expires: + cookie.expires === 'Infinity' ? undefined : cookie.expires.toUTCString(), + httpOnly: cookie.httpOnly || undefined, + path: cookie.path || undefined, + secure: cookie.secure, + } +} +const convertCtoT = (cookie: Cookie): TCookie => + new TCookie({ + key: cookie.name, + value: cookie.value, + domain: cookie.domain, + expires: cookie.expires ? new Date(cookie.expires) : undefined, + httpOnly: cookie.httpOnly || false, + path: cookie.path, + secure: cookie.secure || false, + }) +const convertCookies = (cookies: TCookie[]): Cookies => + cookies.reduce( + (map, cookie) => ({ + ...map, + [cookie.key]: convertTtoC(cookie), + }), + {} as Cookies + ) + +const jar = new CookieJar() +const CookieManager: CookieManagerStatic = { + clearAll: async () => { + await jar.removeAllCookies() + return true + }, + get: async (url) => { + const cookies = await jar.getCookies(url) + return convertCookies(cookies) + }, + set: async (url, cookie) => { + await jar.setCookie(convertCtoT(cookie), url) + return true + }, + setFromResponse: async (url, cookie) => { + await jar.setCookie(cookie, url) + return true + }, +} + +export default CookieManager diff --git a/libs/api-hjarntorget/config.json b/libs/api-hjarntorget/config.json new file mode 100644 index 00000000..e01ad24d --- /dev/null +++ b/libs/api-hjarntorget/config.json @@ -0,0 +1,25 @@ +{ + "headers": { + "accept": "text/plain", + "accept-language": "en-GB,en-SE;q=0.9,en;q=0.8,sv-SE;q=0.7,sv;q=0.6,en-US;q=0.5", + "access-control-allow-origin": "*", + "cache-control": "no-cache", + "content-type": "text/plain", + "pragma": "no-cache", + "sec-ch-ua": "\"Google Chrome\";v=\"89\", \"Chromium\";v=\"89\", \";Not A Brand\";v=\"99\"", + "sec-ch-ua-mobile": "?0", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "x-xsrf-token": "SfONpuvKXD1XHML3Kelvm3easB6Xn3xtbVPG52jdpc3Q7sRxJv7_6wfjo1qS3NOQWkfCvfPkJpJg0QIBmo358o7FdQY2aWvUOxA9MU2Fl0E1", + "y-xsrf-token11": "FyXUbtZUE2iT09J7FOLTpfZ_onjbj3WEIO6jOY9B1KaZzMrAs4WS03AuWbQhmKyCEX2inTPVDzyPc58tN2EM4L1vYD6aH_zhlc7gVo9jaPdLKQc4qnE6ue184cSamKE0", + "topology-key": "labor matter federal|", + "topology-short-key": "assumeoutside", + "topology-base64-iterations": "8" + }, + "referrer": "https://etjanst.stockholm.se/", + "referrerPolicy": "strict-origin-when-cross-origin", + "body": "XVDf/EliJ/oZH9BRlRCMNds2jCRcTL8/isnpuj2wD6wH1lxX/cHY/AM6XJ8nweGne+FAPgcpj+blQ+dQvvmiJfK4t0u66tg8L60ysfDs/eBeoA794lvvtwRwJ946VUahZG89Al7UFkx5Ew1AGp4yuJ38drNDK4J5RAUGvzOWTmniZnSYs9P5UR2SWP39NcOoovwZsce7tRigdusI8sSDSUh+lVDkwfERfQqe3oG+FPiGQsfeFd2y/5f8chxU9VQz4oF7BLiP69xBPJr2KFkQc7MJaqEQy87loe0vwehRN/lOP858pPiVfc96M2jc0+yQEgnUBXPgQmFVC6CIHfQ0Mg==", + "method": "POST", + "mode": "cors" +} diff --git a/libs/api-hjarntorget/jest.config.js b/libs/api-hjarntorget/jest.config.js new file mode 100644 index 00000000..612ab1c5 --- /dev/null +++ b/libs/api-hjarntorget/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + transform: { + '.(ts|tsx)': 'ts-jest', + }, + testRegex: '(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$', + moduleFileExtensions: ['ts', 'tsx', 'js'], +} diff --git a/libs/api-hjarntorget/lib/apiHjarntorget.test.ts b/libs/api-hjarntorget/lib/apiHjarntorget.test.ts new file mode 100644 index 00000000..c3a42bef --- /dev/null +++ b/libs/api-hjarntorget/lib/apiHjarntorget.test.ts @@ -0,0 +1,262 @@ +import { ApiHjarntorget } from './apiHjarntorget' +import { checkStatus } from './loginStatus' +import { wrapToughCookie } from '@skolplattformen/api' +import { CookieJar } from 'tough-cookie' + +const setupSuccessfullLoginInitiation = (fetcherMock: jest.Mock) => { + // 'begin-login' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + url: "some url with url encoded at the end?return=hello" + })) + + // 'init-shibboleth-login' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + url: "some url with url encoded at the end?Target=hello" + })) + + // 'init-bankId' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + text: jest.fn().mockReturnValue(Promise.resolve(` + + + + + + `)) + })) + + // 'pick-mvghost' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + url: "some url to a mvghost" + })) + + // 'start-bankId' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + url: "some base url to a mvghost to use when polling status" + })) +} + +const setupSuccessfullBankIdLogin = (fetcherMock: jest.Mock) => { + // 'poll-bankid-status' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + json: jest.fn().mockReturnValue(Promise.resolve({ + infotext: "", + location: "an url to go to confirm the login" + })) + })) + + // 'confirm-signature-redirect' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + text: jest.fn().mockReturnValue(Promise.resolve(` + + + + + + `)) + })) + + // 'authgbg-saml-login' + fetcherMock.mockReturnValueOnce(Promise.resolve({ + text: jest.fn().mockReturnValue(Promise.resolve(` + + + + + + `)) + })) + + // 'hjarntorget-saml-login' + fetcherMock.mockReturnValueOnce(Promise.resolve({ status: 200 })) +} + +describe('api', () => { + let fetcherMock: jest.Mock + let api: ApiHjarntorget + + beforeEach(() => { + const fetcher = jest.fn() + fetcherMock = fetcher as jest.Mock + + const cookieManager = wrapToughCookie(new CookieJar()) + cookieManager.clearAll(); + api = new ApiHjarntorget(jest.fn(), cookieManager) + api.replaceFetcher(fetcher) + }) + // describe('#login', () => { + // it('goes through single sing-on steps', async (done) => { + // setupSuccessfullLoginInitiation(fetcherMock) + // setupSuccessfullBankIdLogin(fetcherMock) + // const personalNumber = 'my personal number' + + // const loginComplete = new Promise((resolve, reject) => { + // api.on('login', () => done()) + // }); + // await api.login(personalNumber) + // }) + // it('checker emits PENDING', async (done) => { + // // 'poll-bankid-status' + // fetcherMock.mockReturnValueOnce(Promise.resolve({ + // json: jest.fn().mockReturnValue(Promise.resolve({ + // infotext: "some prompt to do signing in app", + // location: "" + // })) + // })) + + // const status = checkStatus(fetcherMock, "some url") + // status.on('PENDING', () => { + // status.cancel() + // done() + // }) + // }) + // it('checker emits ERROR', async (done) => { + // // 'poll-bankid-status' + // fetcherMock.mockReturnValueOnce(Promise.resolve({ + // json: jest.fn().mockReturnValue(Promise.resolve({ + // infotext: "some prompt to do signing in app", + // location: "url with error in the name" + // })) + // })) + + // const status = checkStatus(fetcherMock, "some url") + // status.on('ERROR', () => { + // status.cancel() + // done() + // }) + // }) + // it('checker emits ERROR when an exception occurs', async (done) => { + // // 'poll-bankid-status' + // fetcherMock.mockReturnValueOnce(Promise.resolve({ + // json: jest.fn().mockReturnValue(Promise.resolve({ + // infotext: undefined, + // location: undefined + // })) + // })) + + // const status = checkStatus(fetcherMock, "some url") + // status.on('ERROR', () => { + // status.cancel() + // done() + // }) + // }) + // it('remembers used personal number', async (done) => { + // setupSuccessfullLoginInitiation(fetcherMock) + // setupSuccessfullBankIdLogin(fetcherMock) + // const personalNumber = 'my personal number' + // await api.login(personalNumber) + // api.on('login', () => { + // expect(api.getPersonalNumber()).toEqual(personalNumber) + // done() + // }) + // }) + // it('forgets used personal number if sign in is unsuccessful', async (done) => { + // setupSuccessfullLoginInitiation(fetcherMock) + // // 'poll-bankid-status' + // fetcherMock.mockReturnValueOnce(Promise.resolve({ + // json: jest.fn().mockReturnValue(Promise.resolve({ + // infotext: "", + // location: "an url to go to confirm the login" + // })) + // })) + // // 'confirm-signature-redirect' + // fetcherMock.mockReturnValueOnce(Promise.resolve({ + // text: Promise.resolve("some error occured") + // })) + + // const personalNumber = 'my personal number' + // const status = await api.login(personalNumber) + + // status.on('ERROR', () => { + // expect(api.getPersonalNumber()).toEqual(undefined) + // done() + // }) + // }) + + // // TODO: Possibly rewrite the mocking so we mock the responses more properly, + // // that way it would be possible to implement a throwIfNotOk wrapper for the + // // fetch calls. + // // it('throws error on external api error', async () => { + // // const personalNumber = 'my personal number' + // // try { + // // await api.login(personalNumber) + // // } catch (error: any) { + // // expect(error.message).toEqual(expect.stringContaining('Server Error')) + // // } + // // }) + // }) + // describe('#logout', () => { + // // it('clears session', async () => { + // // await api.logout() + // // const session = await api.getSession('') + // // expect(session).toEqual({ + // // headers: { + // // cookie: '', + // // }, + // // }) + // // }) + // it('emits logout event', async () => { + // const listener = jest.fn() + // api.on('logout', listener) + // await api.logout() + // expect(listener).toHaveBeenCalled() + // }) + // it('sets .isLoggedIn', async () => { + // api.isLoggedIn = true + // await api.logout() + // expect(api.isLoggedIn).toBe(false) + // }) + // it('forgets personalNumber', async () => { + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // (api as any).personalNumber = 'my personal number' + // api.isLoggedIn = true + + // await api.logout() + + // expect(api.getPersonalNumber()).toEqual(undefined) + // }) + // }) + /* + describe('fake', () => { + it('sets fake mode for the correct pnr:s', async () => { + let status + + status = await api.login('121212121212') + expect(status.token).toEqual('fake') + + status = await api.login('201212121212') + expect(status.token).toEqual('fake') + + status = await api.login('1212121212') + expect(status.token).toEqual('fake') + }) + it('delivers fake data', async (done) => { + api.on('login', async () => { + const user = await api.getUser() + expect(user).toEqual({ + firstName: 'Namn', + lastName: 'Namnsson', + isAuthenticated: true, + personalNumber: "195001182046", + }) + + const children = await api.getChildren() + expect(children).toHaveLength(2) + + const calendar1 = await api.getCalendar(children[0]) + expect(calendar1).toHaveLength(20) + const calendar2 = await api.getCalendar(children[1]) + expect(calendar2).toHaveLength(18) + + const skola24Children = await api.getSkola24Children() + expect(skola24Children).toHaveLength(1) + + const timetable = await api.getTimetable(skola24Children[0], 2021, 15, 'sv') + expect(timetable).toHaveLength(32) + + done() + }) + await api.login('121212121212') + }) + })*/ +}) diff --git a/libs/api-hjarntorget/lib/apiHjarntorget.ts b/libs/api-hjarntorget/lib/apiHjarntorget.ts new file mode 100644 index 00000000..dba2148b --- /dev/null +++ b/libs/api-hjarntorget/lib/apiHjarntorget.ts @@ -0,0 +1,584 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + Api, + CalendarItem, + Classmate, + CookieManager, + EtjanstChild, + Fetcher, + FetcherOptions, + LoginStatusChecker, + MenuItem, + NewsItem, + Notification, + ScheduleItem, + Skola24Child, + TimetableEntry, + toMarkdown, + URLSearchParams, + User, + wrap, +} from '@skolplattformen/api' +import { EventEmitter } from 'events' +import { decode } from 'he' +import { DateTime, FixedOffsetZone } from 'luxon' +import * as html from 'node-html-parser' +import { fakeFetcher } from './fake/fakeFetcher' +import { checkStatus } from './loginStatus' +import { extractMvghostRequestBody, parseCalendarItem } from './parse/parsers' +import { + beginBankIdUrl, + beginLoginUrl, + calendarEventUrl, + calendarsUrl, + currentUserUrl, + fullImageUrl, + hjarntorgetEventsUrl, + hjarntorgetUrl, + infoSetReadUrl, + infoUrl, + initBankIdUrl, + lessonsUrl, + membersWithRoleUrl, + mvghostUrl, + myChildrenUrl, + rolesInEventUrl, + shibbolethLoginUrl, + shibbolethLoginUrlBase, + verifyUrlBase, + wallMessagesUrl, +} from './routes' + +function getDateOfISOWeek(week: number, year: number) { + const simple = new Date(year, 0, 1 + (week - 1) * 7) + const dow = simple.getDay() + const isoWeekStart = simple + if (dow <= 4) isoWeekStart.setDate(simple.getDate() - simple.getDay() + 1) + else isoWeekStart.setDate(simple.getDate() + 8 - simple.getDay()) + return isoWeekStart +} + +export class ApiHjarntorget extends EventEmitter implements Api { + private fetch: Fetcher + private realFetcher: Fetcher + + private personalNumber?: string + + private cookieManager: CookieManager + + public isLoggedIn = false + + private _isFake = false + + public set isFake(fake: boolean) { + this._isFake = fake + if (this._isFake) { + this.fetch = fakeFetcher + } else { + this.fetch = this.realFetcher + } + } + + public get isFake() { + return this._isFake + } + + constructor( + fetch: typeof global.fetch, + cookieManager: CookieManager, + options?: FetcherOptions + ) { + super() + this.fetch = wrap(fetch, options) + this.realFetcher = this.fetch + this.cookieManager = cookieManager + } + + public replaceFetcher(fetcher: Fetcher) { + this.fetch = fetcher + } + + async getSchedule( + child: EtjanstChild, + from: DateTime, + to: DateTime + ): Promise<(CalendarItem & ScheduleItem)[]> { + const lessonParams = { + forUser: child.id, + startDateIso: from.toISODate(), + endDateIso: to.toISODate(), + } + const lessonsResponse = await this.fetch( + `lessons-${lessonParams.forUser}`, + lessonsUrl(lessonParams) + ) + const lessonsResponseJson: any[] = await lessonsResponse.json() + + return lessonsResponseJson.map((l) => { + const start = DateTime.fromMillis(l.startDate.ts, { + zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes), + }) + const end = DateTime.fromMillis(l.endDate.ts, { + zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes), + }) + return { + id: l.id, + title: l.title, + description: l.note, + location: l.location, + startDate: start.toISO(), + endDate: end.toISO(), + oneDayEvent: false, + allDayEvent: false, + } + }) + } + + getPersonalNumber(): string | undefined { + return this.personalNumber + } + + async setSessionCookie(sessionCookie: string): Promise { + await this.fetch('login-cookie', hjarntorgetUrl, { + headers: { + cookie: sessionCookie, + }, + redirect: 'manual', + }) + + const user = await this.getUser() + if (!user.isAuthenticated) { + throw new Error('Session cookie is expired') + } + + this.isLoggedIn = true + this.emit('login') + } + + async getUser(): Promise { + console.log('fetching user') + const currentUserResponse = await this.fetch('current-user', currentUserUrl) + if (currentUserResponse.status !== 200) { + return { isAuthenticated: false } + } + + const retrivedUser = await currentUserResponse.json() + return { ...retrivedUser, isAuthenticated: true } + } + + async getChildren(): Promise<(Skola24Child & EtjanstChild)[]> { + if (!this.isLoggedIn) { + throw new Error('Not logged in...') + } + console.log('fetching children') + + const myChildrenResponse = await this.fetch('my-children', myChildrenUrl) + const myChildrenResponseJson: any[] = await myChildrenResponse.json() + + return myChildrenResponseJson.map( + (c) => + ({ + id: c.id, + sdsId: c.id, + personGuid: c.id, + firstName: c.firstName, + lastName: c.lastName, + name: `${c.firstName} ${c.lastName}`, + } as Skola24Child & EtjanstChild) + ) + } + + async getCalendar(child: EtjanstChild): Promise { + const childEventsAndMembers = + await this.getChildEventsWithAssociatedMembers(child) + + // This fetches the calendars search page on Hjärntorget. + // It is used (at least at one school) for homework schedule + // The Id for the "event" that the calendar belongs to is not the same as the ones + // fetched using the API... So we match them by name :/ + const calendarsResponse = await this.fetch('calendars', calendarsUrl) + const calendarsResponseText = await calendarsResponse.text() + const calendarsDoc = html.parse(decode(calendarsResponseText)) + const calendarCheckboxes = Array.from( + calendarsDoc.querySelectorAll('.calendarPageContainer input.checkbox') + ) + + let calendarItems: CalendarItem[] = [] + for (let i = 0; i < calendarCheckboxes.length; i++) { + const calendarId = calendarCheckboxes[i].getAttribute('value') || '' + + const today = DateTime.fromJSDate(new Date()) + const start = today.toISODate() + const end = today.plus({ days: 30 }).toISODate() + const calendarResponse = await this.fetch( + `calendar-${calendarId}`, + calendarEventUrl(calendarId, start, end) + ) + const calendarResponseText = await calendarResponse.text() + const calendarDoc = html.parse(decode(calendarResponseText)) + + const calendarRows = Array.from( + calendarDoc.querySelectorAll('.default-table tr') + ) + if (!calendarRows.length) { + continue + } + + calendarRows.shift() + const eventName = calendarRows.shift()?.textContent + if (childEventsAndMembers.some((e) => e.name === eventName)) { + const items: CalendarItem[] = calendarRows.map(parseCalendarItem) + + calendarItems = calendarItems.concat(items) + } + } + + return calendarItems + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getClassmates(_child: EtjanstChild): Promise { + // TODO: We could get this from the events a child is associated with... + if (!this.isLoggedIn) { + throw new Error('Not logged in...') + } + return Promise.resolve([]) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getNews(_child: EtjanstChild): Promise { + if (!this.isLoggedIn) { + throw new Error('Not logged in...') + } + + const children = await this.getChildren() + const eventsAndMembersForChildren = await this.getEventsWithAssociatedMembersForChildren(children) + const membersInChildensEvents = eventsAndMembersForChildren.reduce( + (acc, e) => acc.concat(e.eventMembers), + [] as any[] + ) + + const wallMessagesResponse = await this.fetch( + 'wall-events', + wallMessagesUrl + ) + const wallMessagesResponseJson: any[] = await wallMessagesResponse.json() + const nonChildSpecificMessages = wallMessagesResponseJson + .filter((message) => + // Ignore "Alarm" messages from the calendar + message.creator.id !== '__system$virtual$calendar__' && + // Only include messages that can not reliably be associated with one of the children + !membersInChildensEvents.some((member) => member.id === message.creator.id) + ) + .map(message => { + const createdDate = new Date(message.created.ts) + const body = message.body as string + const trimmedBody = body.trim() + const firstNewline = trimmedBody.indexOf('\n') + const title = trimmedBody.substring(0, firstNewline).trim() || message.title + const intro = trimmedBody.substring(firstNewline).trim() + return { + id: message.id, + author: message.creator && `${message.creator.firstName} ${message.creator.lastName}`, + header: title, + intro: intro, + body: body, + published: createdDate.toISOString(), + modified: createdDate.toISOString(), + fullImageUrl: message.creator && fullImageUrl(message.creator.imagePath), + timestamp: message.created.ts, + } + }) + + const infoResponse = await this.fetch('info', infoUrl) + const infoResponseJson: any[] = await infoResponse.json() + // TODO: Filter out read messages? + const officialInfoMessages = infoResponseJson.map((i) => { + const body = html.parse(decode(i.body || '')) + const bodyText = toMarkdown(i.body) + + const introText = body.innerText || '' + const publishedDate = new Date(i.created.ts) + + return { + id: i.id, + author: i.creator && `${i.creator.firstName} ${i.creator.lastName}`, + header: i.title, + intro: introText, + body: bodyText, + published: publishedDate.toISOString(), + modified: publishedDate.toISOString(), + fullImageUrl: i.creator && fullImageUrl(i.creator.imagePath), + timestamp: i.created.ts, + } + }) + + const newsMessages = officialInfoMessages.concat(nonChildSpecificMessages) + newsMessages.sort((a,b) => b.timestamp - a.timestamp) + return newsMessages + } + + async getNewsDetails(_child: EtjanstChild, item: NewsItem): Promise { + return { ...item } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getMenu(_child: EtjanstChild): Promise { + if (!this.isLoggedIn) { + throw new Error('Not logged in...') + } + // Have not found this available on hjärntorget. Perhaps do a mapping to https://www.skolmaten.se/ ? + return Promise.resolve([]) + } + + async getChildEventsWithAssociatedMembers(child: EtjanstChild) { + return this.getEventsWithAssociatedMembersForChildren([child]) + } + + async getEventsWithAssociatedMembersForChildren(children: EtjanstChild[]) { + const hjarntorgetEventsResponse = await this.fetch( + 'events', + hjarntorgetEventsUrl + ) + const hjarntorgetEventsResponseJson: any[] = + await hjarntorgetEventsResponse.json() + const membersInEvents = await Promise.all( + hjarntorgetEventsResponseJson + .filter((e) => e.state === 'ONGOING') + .map(async (e) => { + const eventId = e.id as number + + const rolesInEvenResponse = await this.fetch( + `roles-in-event-${eventId}`, + rolesInEventUrl(eventId) + ) + const rolesInEvenResponseJson: any[] = + await rolesInEvenResponse.json() + + const eventMembers = await Promise.all( + rolesInEvenResponseJson.map(async (r) => { + const roleId = r.id + const membersWithRoleResponse = await this.fetch( + `event-role-members-${eventId}-${roleId}`, + membersWithRoleUrl(eventId, roleId) + ) + const membersWithRoleResponseJson: any[] = + await membersWithRoleResponse.json() + return membersWithRoleResponseJson + }) + ) + return { + eventId, + name: e.name as string, + eventMembers: ([] as any[]).concat(...eventMembers), + } + }) + ) + + return membersInEvents.filter((e) => + e.eventMembers.find((p) => children.some(c => c.id === p.id)) + ) + } + + async getNotifications(child: EtjanstChild): Promise { + const childEventsAndMembers = + await this.getChildEventsWithAssociatedMembers(child) + const membersInChildsEvents = childEventsAndMembers.reduce( + (acc, e) => acc.concat(e.eventMembers), + [] as any[] + ) + + const wallMessagesResponse = await this.fetch( + 'wall-events', + wallMessagesUrl + ) + const wallMessagesResponseJson: any[] = await wallMessagesResponse.json() + return wallMessagesResponseJson + .filter((message) => + membersInChildsEvents.find((member) => member.id === message.creator.id) + ) + .map((message) => { + const createdDate = new Date(message.created.ts) + return { + id: message.id, + sender: + message.creator && + `${message.creator.firstName} ${message.creator.lastName}`, + dateCreated: createdDate.toISOString(), + message: message.body, + url: message.url, + category: message.title, + type: message.type, + dateModified: createdDate.toISOString(), + } + }) + } + + async getSkola24Children(): Promise { + if (!this.isLoggedIn) { + throw new Error('Not logged in...') + } + return [] + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getTimetable( + child: Skola24Child, + week: number, + year: number, + _lang: string + ): Promise { + const startDate = DateTime.fromJSDate(getDateOfISOWeek(week, year)) + const endDate = startDate.plus({ days: 7 }) + + const lessonParams = { + forUser: child.personGuid!, // This is a bit of a hack due to how we map things... + startDateIso: startDate.toISODate(), + endDateIso: endDate.toISODate(), + } + const lessonsResponse = await this.fetch( + `lessons-${lessonParams.forUser}`, + lessonsUrl(lessonParams) + ) + const lessonsResponseJson: any[] = await lessonsResponse.json() + + return lessonsResponseJson.map((l) => { + const start = DateTime.fromMillis(l.startDate.ts, { + zone: FixedOffsetZone.instance(l.startDate.timezoneOffsetMinutes), + }) + const end = DateTime.fromMillis(l.endDate.ts, { + zone: FixedOffsetZone.instance(l.endDate.timezoneOffsetMinutes), + }) + return { + id: l.id, + teacher: l.bookedTeacherNames && l.bookedTeacherNames[0], + location: l.location, + timeStart: start.toISOTime().substring(0, 5), + timeEnd: end.toISOTime().substring(0, 5), + dayOfWeek: start.toJSDate().getDay(), + blockName: l.title, + dateStart: start.toISODate(), + dateEnd: start.toISODate(), + } as TimetableEntry + }) + } + + async logout(): Promise { + this.isLoggedIn = false + this.personalNumber = undefined + this.cookieManager.clearAll() + this.emit('logout') + } + + public async login(personalNumber?: string): Promise { + // short circut the bank-id login if in fake mode + if (personalNumber !== undefined && personalNumber.endsWith('1212121212')) + return this.fakeMode() + + this.isFake = false + + console.log('initiating login to hjarntorget') + const beginLoginRedirectResponse = await this.fetch( + 'begin-login', + beginLoginUrl, + { + redirect: 'follow', + } + ) + + if((beginLoginRedirectResponse as any).url.endsWith("startPage.do")) { + // already logged in! + const emitter = new EventEmitter() + setTimeout(() => { + this.isLoggedIn = true + emitter.emit('OK') + this.emit('login') + }, 50) + return emitter; + } + + console.log('prepping??? shibboleth') + const shibbolethLoginResponse = await this.fetch( + 'init-shibboleth-login', + shibbolethLoginUrl( + shibbolethLoginUrlBase((beginLoginRedirectResponse as any).url) + ), + { + redirect: 'follow', + } + ) + + const shibbolethRedirectUrl = (shibbolethLoginResponse as any).url + console.log('initiating bankid...') + const initBankIdResponse = await this.fetch( + 'init-bankId', + initBankIdUrl(shibbolethRedirectUrl), + { + redirect: 'follow', + } + ) + + const initBankIdResponseText = await initBankIdResponse.text() + const mvghostRequestBody = extractMvghostRequestBody(initBankIdResponseText) + + console.log('picking auth server???') + const mvghostResponse = await this.fetch('pick-mvghost', mvghostUrl, { + redirect: 'follow', + method: 'POST', + body: mvghostRequestBody, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + + console.log('start bankid sign in') + // We may get redirected to some other subdomain i.e. not 'm00-mg-local': + // https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/other + + const ssnBody = new URLSearchParams({ ssn: personalNumber }).toString() + const beginBankIdResponse = await this.fetch( + 'start-bankId', + beginBankIdUrl((mvghostResponse as any).url), + { + redirect: 'follow', + method: 'POST', + body: ssnBody, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ) + + console.log('start polling') + const statusChecker = checkStatus( + this.fetch, + verifyUrlBase((beginBankIdResponse as any).url) + ) + + statusChecker.on('OK', async () => { + // setting these similar to how the sthlm api does it + // not sure if it is needed or if the cookies are enough for fetching all info... + this.isLoggedIn = true + this.personalNumber = personalNumber + this.emit('login') + }) + statusChecker.on('ERROR', () => { + this.personalNumber = undefined + }) + + return statusChecker + } + + private async fakeMode(): Promise { + this.isFake = true + + setTimeout(() => { + this.isLoggedIn = true + this.emit('login') + }, 50) + + const emitter: any = new EventEmitter() + emitter.token = 'fake' + return emitter + } +} diff --git a/libs/api-hjarntorget/lib/fake/calendars.ts b/libs/api-hjarntorget/lib/fake/calendars.ts new file mode 100644 index 00000000..fc7cbf7f --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/calendars.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-useless-escape */ +export const calendars = () => ({ + "url": "https://hjarntorget.goteborg.se/pp/system/calendar/cal_events.jsp", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "text": () => Promise.resolve("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nThe PING PONG Calendar\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t
\n\t\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t\n\t\n\n\t\n\n\n\n\t\t\n\n\t\t
\n\t\t\t\n\t\t\t
\n\t\t\t\t

Make a selection

\n\n\t\t\t\t

Here you get an overview of your calendars. Choose from which calendars you wish to see events. Choose if you want to search for a word. Click Show to see the result.

\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
Show calendar events for the checked calendars
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\n\n
\"\"
\n\n\n\n
removed checkbox
\"\"
\n\n\n\n
removed checkbox
\"\"
\n\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
Group the events per calendar\n\t\t\t\t\t\t\t\t\t
Don't group\n\t\t\t\t\t\t\t\t\t
From - to:\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
Search for
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t
\n\t\t\t
\n\n\t\t\t

Calendar events

\n\n\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\t No events was found \n\t\t\t\t\t\n\t\t\t\t

\n\t\t\t\t\n\n\t\t\t
\n\t\t
\n\t
\n\n\n\n\n") +}) as any as Response + +export const calendar_14241345 = () => { + return { + "url": "https://hjarntorget.goteborg.se/pp/system/calendar/cal_events.jsp?order_by=start_date&show_cal_ids=14241345&mode=separate&filter_start_date=2021-11-09&filter_end_date=2021-12-09&search_for=", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "text": () => { + const now = new Date() + const dateYearMonth = `${now.getFullYear()}-${now.getMonth() + 1}` + const nextMonthDate = new Date(now.getFullYear(), now.getMonth() + 1, 1) // Should roll over to new year... + const nextMonth = `${nextMonthDate.getFullYear()}-${nextMonthDate.getMonth() + 1}` + const result = ` + \n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nThe PING PONG Calendar\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t
\n\t\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t\n\t\n\n\t\n\n\n\n\t\t\n\n\t\t
\n\t\t\t\n\t\t\t
\n\t\t\t\t

Make a selection

\n\n\t\t\t\t

Here you get an overview of your calendars. Choose from which calendars you wish to see events. Choose if you want to search for a word. Click Show to see the result.

\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
Show calendar events for the checked calendars
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\n\n
removed checkbox
\"\"
\n\n\n\n
removed checkbox
\"\"
\n\n\n\n
removed checkbox
\"\"
\n\n\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
Group the events per calendar\n\t\t\t\t\t\t\t\t\t
Don't group\n\t\t\t\t\t\t\t\t\t
From - to:\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
Search for
\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t
\n\n\t\t\t\t\t\t
\n\t\t\t
\n\n\t\t\t

Calendar events

\n\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\n\t\t\t\t\t\t\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\t\t\tTitle\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tDates\n\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tTimes\n\n\t\t\t\t\t\t\t
138JÄTS Provschema år 8
\"\"
\nProv/komplettering franska ${dateYearMonth}-0113:00-14:00
\"\"
\nTyska läxförhör${dateYearMonth}-0113:00-13:30\"The
\"\"
\nLäxa i franska${dateYearMonth}-0309:40-10:20\"The
\"\"
\nSpanskaprov Repasamos 1- 4${dateYearMonth}-0310:00-11:00
\"\"
\nTyska läxförhör${dateYearMonth}-0813:00-13:30\"The
\"\"
\nLäxa i franska${dateYearMonth}-1009:40-10:20\"The
\"\"
\nDeadline engelska - Postcard from Great Britain${dateYearMonth}-1115:00-16:00\"The
\"\"
\nLäxa engelska${dateYearMonth}-1408:00-09:00\"The
\"\"
\nTyska läxförhör${dateYearMonth}-1513:00-13:30\"The
\"\"
\nLäxa i franska${dateYearMonth}-1709:40-10:20\"The
\"\"
\nLäxa engelska${dateYearMonth}-1908:00-09:00\"The
\"\"
\nProv franska åk 7${dateYearMonth}-2012:00-13:00
\"\"
\nLäxa i franska${dateYearMonth}-2209:40-10:20\"The
\"\"
\nLäxa engelska${nextMonth}-0108:00-09:00\"The
\n\n\t\t\t\t\n\n\t\t\t
\n\t\t
\n\t
\n\n\n\n\n + ` + return Promise.resolve(result as any as Response) + } + } +} \ No newline at end of file diff --git a/libs/api-hjarntorget/lib/fake/current-user.ts b/libs/api-hjarntorget/lib/fake/current-user.ts new file mode 100644 index 00000000..5a2ed961 --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/current-user.ts @@ -0,0 +1,18 @@ +export const currentUser = () => ({ + "url": "https://hjarntorget.goteborg.se/api/core/current-user", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve({ + "id": "889911_goteborgsstad", + "firstName": "TOLV", + "lastName": "TOLVAN", + "email": null, + "online": true, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }) +}) as any as Response \ No newline at end of file diff --git a/libs/api-hjarntorget/lib/fake/event-role-members.ts b/libs/api-hjarntorget/lib/fake/event-role-members.ts new file mode 100644 index 00000000..37e0300b --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/event-role-members.ts @@ -0,0 +1,213 @@ +export const eventRoleMembers21 = () => ({ + "url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=21&roleId=821", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + "id": "__system$virtual$calendar__", + "firstName": "Kalendern", + "lastName": "i PING PONG", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/default/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + // Klass: 8B + "id": "133700_goteborgsstad", + "firstName": "Azra", + "lastName": "Göransson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + ]) +}) as any as Response + +export const eventRoleMembers14 = () => ({ + "url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=14&roleId=821", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + // Klass: 8B + "id": "133700_goteborgsstad", + "firstName": "Azra", + "lastName": "Göransson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "362119_goteborgsstad", + "firstName": "Elina", + "lastName": "Cocolis", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "999999_goteborgsstad", + "firstName": "Sanne", + "lastName": "Berggren", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "168925_goteborgsstad", + "firstName": "Teddy", + "lastName": "Karlsson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "494949_goteborgsstad", + "firstName": "Fideli", + "lastName": "Sundström", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + ]) +}) as any as Response + +export const eventRoleMembers18 = () => ({ + "url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=18&roleId=821", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + "id": "776655_goteborgsstad", + "firstName": "Walid", + "lastName": "Söderström", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "388601_goteborgsstad", + "firstName": "Rosa", + "lastName": "Fredriksson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "654654_goteborgsstad", + "firstName": "Moses", + "lastName": "Johansson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "1313131_goteborgsstad", + "firstName": "Haris", + "lastName": "Jonsson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "887766_goteborgsstad", + "firstName": "Neo", + "lastName": "Lundström", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + // Klass: 5A + "id": "123456_goteborgsstad", + "firstName": "Jon", + "lastName": "Göransson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + ]) +}) as any as Response + +export const eventRoleMembers24 = () => ({ + "url": "https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=24&roleId=821", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + "id": "393939_goteborgsstad", + "firstName": "Malik Maria", + "lastName": "Henriksson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "444444_goteborgsstad", + "firstName": "Idas", + "lastName": "Svensson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "818181_goteborgsstad", + "firstName": "Nadja", + "lastName": "Ekström", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + "id": "919191_goteborgsstad", + "firstName": "Karim", + "lastName": "Fakir", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + { + // Klass: Förskola + "id": "133737_goteborgsstad", + "firstName": "Havin", + "lastName": "Göransson", + "email": null, + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + ]) +}) as any as Response \ No newline at end of file diff --git a/libs/api-hjarntorget/lib/fake/events.ts b/libs/api-hjarntorget/lib/fake/events.ts new file mode 100644 index 00000000..28126240 --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/events.ts @@ -0,0 +1,35 @@ +export const events = () => ({ + "url": "https://hjarntorget.goteborg.se/api/events/events-sorted-by-name?offset=0&limit=100", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + "id": 18, + "name": "138JÄTS 21/22 5A", + "url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D12", + "state": "ONGOING" + }, + { + "id": 14, + "name": "138JÄTS 21/22 8B", + "url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D14", + "state": "ONGOING" + }, + { + "id": 21, + "name": "138JÄTS Provschema år 8", + "url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D21", + "state": "ONGOING" + }, + { + "id": 24, + "name": "139SS27F Södra Bangatan förskola", + "url": "https://hjarntorget.goteborg.se/o/apiAccessWithKey.do?forwardUrl=%2FlaunchCourse.do%3Fid%3D24", + "state": "ONGOING" + } + ]) +}) as any as Response \ No newline at end of file diff --git a/libs/api-hjarntorget/lib/fake/fakeFetcher.ts b/libs/api-hjarntorget/lib/fake/fakeFetcher.ts new file mode 100644 index 00000000..87595ba3 --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/fakeFetcher.ts @@ -0,0 +1,37 @@ +import { Fetcher, Response } from '@skolplattformen/api' +import { calendars, calendar_14241345 } from './calendars'; +import { currentUser } from './current-user'; +import { events } from './events'; +import { lessons_123456_goteborgsstad, lessons_133700_goteborgsstad, lessons_133737_goteborgsstad } from './lessons'; +import { myChildren } from './my-children'; +import { wallEvents } from './wall-events'; +import { information } from './information' +import { genericRolesInEvent } from './roles-in-event'; +import { eventRoleMembers14, eventRoleMembers18, eventRoleMembers21, eventRoleMembers24 } from './event-role-members'; + +const fetchMappings: { [name:string]: () => Response} = { + 'current-user': currentUser, + 'events': events, + 'my-children': myChildren, + 'wall-events': wallEvents, + 'lessons-133700_goteborgsstad': lessons_133700_goteborgsstad, + 'lessons-133737_goteborgsstad': lessons_133737_goteborgsstad, + 'lessons-123456_goteborgsstad': lessons_123456_goteborgsstad, + 'info': information, + 'roles-in-event-14': genericRolesInEvent, + 'roles-in-event-18': genericRolesInEvent, + 'roles-in-event-21': genericRolesInEvent, + 'roles-in-event-24': genericRolesInEvent, + 'event-role-members-14-821': eventRoleMembers14, + 'event-role-members-18-821': eventRoleMembers18, + 'event-role-members-21-821': eventRoleMembers21, + 'event-role-members-24-821': eventRoleMembers24, + 'calendars': calendars, + 'calendar-14241345': calendar_14241345, + +} + +export const fakeFetcher: Fetcher = (name: string, url: string, init?: any): Promise => { + const responder = fetchMappings[name] ?? (() => {throw new Error("Request not faked for name: " + name)}) + return Promise.resolve(responder()); +} diff --git a/libs/api-hjarntorget/lib/fake/information.ts b/libs/api-hjarntorget/lib/fake/information.ts new file mode 100644 index 00000000..d6f4ecbf --- /dev/null +++ b/libs/api-hjarntorget/lib/fake/information.ts @@ -0,0 +1,111 @@ +/* eslint-disable no-useless-escape */ +export const information = () => ({ + "url": "https://hjarntorget.goteborg.se/api/information/messages-by-date-desc?messageStatus=CURRENT&offset=0&limit=10&language=en", + "headers": { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36", + "cookie": "REMOVED" + }, + "status": 200, + "statusText": "200", + "json": () => Promise.resolve([ + { + "id": 3276034, + "title": "Nu får du och ditt barn tillgång till Polyglutt hemma", + "body": "

Nu får alla barn som går i kommunal förskola i Göteborg tillgång till bilderboksappen Polyglutt hemifrån! Det innebär att du som vårdnadshavare och barn kan ta del av ett bibliotek av böcker på både svenska och 60 andra språk, inklusive TAKK och teckenspråk via telefon eller läsplatta.<\/strong><\/p>\r\n

Polyglutt är en app med bilderböcker som fungerar som ett verktyg för att arbeta med språkutveckling och litteratur i förskolan och hemma.<\/p>\r\n

Polyglutt Home Access är en tjänst som innebär att alla barn som går i kommunal förskola i Göteborg får tillgång till ett bibliotek av böcker på både svenska och 60 andra språk, inklusive TAKK och teckenspråk hemifrån. Varje förskola kan också skapa egna bokhyllor med boktips i appen som du och ditt barn kan läsa hemma.<\/p>\r\n

Tjänsten fungerar på iPad, Androidplattor och i mobilen.<\/p>\r\n

Vill du veta mer om tjänsten, kontakta pedagogerna på ditt barns förskola.<\/p>", + "creator": { + "id": "501747_goteborgsstad", + "firstName": "Information Digitalisering", + "lastName": "Innovation", + "email": "information.digitaliseringochinnovation@forskola.goteborg.se", + "online": false, + "imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png", + "extraInfoInCatalog": "" + }, + "recipientGroups": [ + { + "id": 1121821, + "name": "DL Göteborg Vhavare förskolor" + } + ], + "created": { + "ts": 1629970713111, + "timezoneOffsetMinutes": 120 + }, + "attachments": [], + "readByUser": false, + "archivedByUser": false + }, + { + "id": 3270718, + "title": "Information från grundskoleförvaltningen", + "body": "

Till vårdnadshavare med barn på Göteborgs Stads grundskolor och grundsärskolor.<\/p>\r\n

Spridningen av covid-19 har ökat. Därför är det viktigt att alla hjälper till att minska spridningen av smitta.<\/p>\r\n

Vi fortsätter hålla avstånd<\/h2>\r\n