Merge pull request #527 from kolplattformen/feature/hjarntorget
Feature/hjarntorget
This commit is contained in:
commit
22d099b5d3
43
README.md
43
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
|
||||
|
|
|
@ -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 (
|
||||
<ApiProvider api={api} storage={AsyncStorage} reporter={reporter}>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar
|
||||
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
|
||||
barStyle={colorScheme === 'dark' ? 'light-content' : 'dark-content'}
|
||||
translucent
|
||||
/>
|
||||
<IconRegistry icons={EvaIconsPack} />
|
||||
<ApplicationProvider
|
||||
{...eva}
|
||||
customMapping={customMapping}
|
||||
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
|
||||
>
|
||||
<LanguageProvider cache={true} data={translations}>
|
||||
<AppNavigator />
|
||||
</LanguageProvider>
|
||||
</ApplicationProvider>
|
||||
</SafeAreaProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<View>
|
||||
<Text>ERROR</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<FeatureProvider features={platform.features}>
|
||||
<SchoolPlatformProvider>
|
||||
<ApiProvider
|
||||
api={platform.api}
|
||||
storage={AsyncStorage}
|
||||
reporter={reporter}
|
||||
>
|
||||
<SafeAreaProvider>
|
||||
<StatusBar
|
||||
backgroundColor={colorScheme === 'dark' ? '#2E3137' : '#FFF'}
|
||||
barStyle={
|
||||
colorScheme === 'dark' ? 'light-content' : 'dark-content'
|
||||
}
|
||||
translucent
|
||||
/>
|
||||
<IconRegistry icons={EvaIconsPack} />
|
||||
<ApplicationProvider
|
||||
{...eva}
|
||||
// @ts-expect-error Unknown error
|
||||
customMapping={customMapping}
|
||||
theme={colorScheme === 'dark' ? darkTheme : lightTheme}
|
||||
>
|
||||
<LanguageProvider cache={true} data={translations}>
|
||||
<AppNavigator />
|
||||
</LanguageProvider>
|
||||
</ApplicationProvider>
|
||||
</SafeAreaProvider>
|
||||
</ApiProvider>
|
||||
</SchoolPlatformProvider>
|
||||
</FeatureProvider>
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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<Cookie> 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<Pair<String, String>> iterator = chain.request().headers().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Pair<String, String> 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<String, String> 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;
|
||||
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(<Auth navigation={navigation} />)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
|
@ -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<RootStackParamList, 'Child'>
|
||||
type ChildRouteProps = RouteProp<RootStackParamList, 'Child'>
|
||||
|
@ -47,10 +48,20 @@ const CalendarScreen = () => <Calendar />
|
|||
const MenuScreen = () => <Menu />
|
||||
const ClassmatesScreen = () => <Classmates />
|
||||
|
||||
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
|
||||
}) => (
|
||||
<Navigator
|
||||
initialRouteName={initialRouteName}
|
||||
|
@ -80,31 +91,41 @@ const TabNavigator = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Screen
|
||||
name="News"
|
||||
component={NewsScreen}
|
||||
options={{ title: translate('navigation.news') }}
|
||||
/>
|
||||
<Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
options={{ title: translate('navigation.notifications') }}
|
||||
/>
|
||||
<Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{ title: translate('navigation.calender') }}
|
||||
/>
|
||||
<Screen
|
||||
name="Menu"
|
||||
component={MenuScreen}
|
||||
options={{ title: translate('navigation.menu') }}
|
||||
/>
|
||||
<Screen
|
||||
name="Classmates"
|
||||
component={ClassmatesScreen}
|
||||
options={{ title: translate('navigation.classmates') }}
|
||||
/>
|
||||
{screenSettings.NEWS_SCREEN && (
|
||||
<Screen
|
||||
name="News"
|
||||
component={NewsScreen}
|
||||
options={{ title: translate('navigation.news') }}
|
||||
/>
|
||||
)}
|
||||
{screenSettings.NOTIFICATIONS_SCREEN && (
|
||||
<Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
options={{ title: translate('navigation.notifications') }}
|
||||
/>
|
||||
)}
|
||||
{screenSettings.CALENDER_SCREEN && (
|
||||
<Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{ title: translate('navigation.calender') }}
|
||||
/>
|
||||
)}
|
||||
{screenSettings.MENU_SCREEN && (
|
||||
<Screen
|
||||
name="Menu"
|
||||
component={MenuScreen}
|
||||
options={{ title: translate('navigation.menu') }}
|
||||
/>
|
||||
)}
|
||||
{screenSettings.CLASSMATES_SCREEN && (
|
||||
<Screen
|
||||
name="Classmates"
|
||||
component={ClassmatesScreen}
|
||||
options={{ title: translate('navigation.classmates') }}
|
||||
/>
|
||||
)}
|
||||
</Navigator>
|
||||
)
|
||||
|
||||
|
@ -153,6 +174,8 @@ export const childRouteOptions =
|
|||
export const Child = () => {
|
||||
const route = useRoute<ChildRouteProps>()
|
||||
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 (
|
||||
<ChildProvider child={child}>
|
||||
<TabNavigator
|
||||
screenSettings={screenSettings}
|
||||
initialRouteName={initialRouteName as keyof ChildTabParamList}
|
||||
/>
|
||||
</ChildProvider>
|
||||
|
|
|
@ -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<string | null>(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 (
|
||||
<>
|
||||
<View style={styles.loginForm}>
|
||||
{loginMethodIndex === 1 && (
|
||||
{isUsingPersonalIdNumber && (
|
||||
<Input
|
||||
accessible={true}
|
||||
label={t('general.socialSecurityNumber')}
|
||||
|
@ -139,12 +173,10 @@ export const Login = () => {
|
|||
accessoryRight={(props) => (
|
||||
<TouchableWithoutFeedback
|
||||
accessible={true}
|
||||
onPress={() => 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' }
|
||||
)}
|
||||
>
|
||||
<CloseOutlineIcon {...props} />
|
||||
|
@ -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}
|
||||
</Button>
|
||||
<Button
|
||||
accessible={true}
|
||||
|
@ -185,6 +217,19 @@ export const Login = () => {
|
|||
})}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<View style={styles.platformPicker}>
|
||||
<Button
|
||||
appearance="ghost"
|
||||
status="basic"
|
||||
size="small"
|
||||
accessoryRight={SelectIcon}
|
||||
onPress={() => {
|
||||
setShowSchoolPlatformPicker(true)
|
||||
}}
|
||||
>
|
||||
{getSchoolPlatformName()}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Modal
|
||||
visible={showLoginMethod}
|
||||
|
@ -201,13 +246,13 @@ export const Login = () => {
|
|||
ItemSeparatorComponent={Divider}
|
||||
renderItem={({ item, index }) => (
|
||||
<ListItem
|
||||
title={item}
|
||||
title={item.title}
|
||||
accessible={true}
|
||||
accessoryRight={
|
||||
loginMethodIndex === index ? CheckIcon : undefined
|
||||
loginMethodId === item.id ? CheckIcon : undefined
|
||||
}
|
||||
onPress={() => {
|
||||
setLoginMethodIndex(index)
|
||||
setLoginMethodId(item.id)
|
||||
setShowLoginMethod(false)
|
||||
}}
|
||||
/>
|
||||
|
@ -245,6 +290,42 @@ export const Login = () => {
|
|||
</Button>
|
||||
</Card>
|
||||
</Modal>
|
||||
<Modal
|
||||
visible={showSchoolPlatformPicker}
|
||||
style={styles.modal}
|
||||
onBackdropPress={() => setShowSchoolPlatformPicker(false)}
|
||||
backdropStyle={styles.backdrop}
|
||||
>
|
||||
<Card>
|
||||
<Text category="h5" style={styles.bankIdLoading}>
|
||||
{t('auth.chooseSchoolPlatform')}
|
||||
</Text>
|
||||
<List
|
||||
data={schoolPlatforms}
|
||||
ItemSeparatorComponent={Divider}
|
||||
renderItem={({ item }) => (
|
||||
<ListItem
|
||||
title={item.displayName}
|
||||
accessible={true}
|
||||
accessoryRight={
|
||||
currentSchoolPlatform === item.id ? CheckIcon : undefined
|
||||
}
|
||||
onPress={() => {
|
||||
changeSchoolPlatform(item.id)
|
||||
setShowSchoolPlatformPicker(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
status="basic"
|
||||
style={styles.cancelButtonStyle}
|
||||
onPress={() => setShowSchoolPlatformPicker(false)}
|
||||
>
|
||||
{t('general.cancel')}
|
||||
</Button>
|
||||
</Card>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -271,4 +352,7 @@ const themedStyles = StyleService.create({
|
|||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
platformPicker: {
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -14,7 +14,11 @@ interface NavigationTitleProps {
|
|||
export const NavigationTitle = ({ title, subtitle }: NavigationTitleProps) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1} ellipsizeMode="tail">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||
</View>
|
||||
)
|
||||
|
@ -25,7 +29,7 @@ const styles = StyleSheet.create({
|
|||
...Layout.center,
|
||||
},
|
||||
title: {
|
||||
...fontSize.base,
|
||||
...fontSize.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subtitle: { ...fontSize.xxs },
|
||||
|
|
|
@ -38,10 +38,7 @@ export const newsItemRouteOptions =
|
|||
return {
|
||||
...defaultStackStyling(darkMode),
|
||||
headerCenter: () => (
|
||||
<NavigationTitle
|
||||
title={newsItem.header}
|
||||
subtitle={studentName(child?.name)}
|
||||
/>
|
||||
<NavigationTitle subtitle={studentName(child?.name)} />
|
||||
),
|
||||
headerLargeTitle: false,
|
||||
}
|
||||
|
@ -58,6 +55,9 @@ export const NewsItem = ({ route }: NewsItemProps) => {
|
|||
contentContainerStyle={styles.article}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
<Text maxFontSizeMultiplier={2} style={styles.title}>
|
||||
{newsItem.header}
|
||||
</Text>
|
||||
{dateIsValid(newsItem.published) && (
|
||||
<Text
|
||||
maxFontSizeMultiplier={2}
|
||||
|
|
|
@ -20,7 +20,9 @@ export const Notification = ({ item }: NotificationProps) => {
|
|||
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 (
|
||||
|
|
|
@ -70,7 +70,12 @@ const LessonList = ({ lessons, header, lunch }: LessonListProps) => {
|
|||
>{`${timeStart.slice(0, 5)}-${timeEnd.slice(0, 5)} ${
|
||||
location === '' ? '' : '(' + location + ')'
|
||||
} `}</Text>
|
||||
<Text style={styles.lessonDescription} maxFontSizeMultiplier={1}>
|
||||
<Text
|
||||
style={styles.lessonDescription}
|
||||
maxFontSizeMultiplier={1}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{code?.toUpperCase() === 'LUNCH' ? lunch?.description : teacher}
|
||||
</Text>
|
||||
</View>
|
||||
|
@ -178,7 +183,7 @@ const themedStyles = StyleService.create({
|
|||
padding: 0,
|
||||
},
|
||||
item: {
|
||||
height: 55,
|
||||
height: 90,
|
||||
backgroundColor: 'background-basic-color-2',
|
||||
paddingHorizontal: 0,
|
||||
borderRadius: 2,
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { Features, FeatureType } from '@skolplattformen/api'
|
||||
import React from 'react'
|
||||
|
||||
export const FeatureFlagsContext = React.createContext<Features>({
|
||||
LOGIN_BANK_ID_SAME_DEVICE: false,
|
||||
FOOD_MENU: false,
|
||||
})
|
||||
|
||||
interface Props {
|
||||
features: Features
|
||||
}
|
||||
|
||||
export const FeatureProvider: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<FeatureFlagsContext.Provider value={props.features} {...props}>
|
||||
{props.children}
|
||||
</FeatureFlagsContext.Provider>
|
||||
)
|
||||
}
|
|
@ -20,7 +20,7 @@ export const LanguageContext = React.createContext<LanguageContextProps>({
|
|||
interface Props {
|
||||
children: ReactNode
|
||||
data: any
|
||||
initialLanguageCode: string
|
||||
initialLanguageCode?: string
|
||||
cache: any
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<SchoolPlatformContext.Provider
|
||||
value={{
|
||||
currentSchoolPlatform,
|
||||
changeSchoolPlatform,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SchoolPlatformContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const SchoolPlatformContext =
|
||||
createContext<SchoolPlatformProps>(defaultState)
|
|
@ -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,
|
||||
},
|
||||
]
|
|
@ -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<Features>(FeatureFlagsContext)
|
||||
if (features === null) {
|
||||
throw new Error('You must wrap your components in a FeatureProvider.')
|
||||
}
|
||||
|
||||
return features[name]
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -669,4 +669,4 @@ SPEC CHECKSUMS:
|
|||
|
||||
PODFILE CHECKSUM: 85f5a2dfa1de342b427eecb6e9652410ad153247
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.10.1
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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/*
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"branches": ["main"]
|
||||
}
|
|
@ -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.
|
|
@ -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<boolean>
|
||||
setFromResponse(url: string, cookie: string): Promise<boolean>
|
||||
|
||||
get(url: string, useWebKit?: boolean): Promise<Cookies>
|
||||
|
||||
clearAll(useWebKit?: boolean): Promise<boolean>
|
||||
}
|
||||
|
||||
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
|
|
@ -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"
|
||||
}
|
|
@ -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'],
|
||||
}
|
|
@ -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(`
|
||||
<html>
|
||||
<body>
|
||||
<input name="RelayState" value="aUUID"></input>
|
||||
<input name="SAMLRequest" value="somebase64value"></input>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
|
||||
// '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(`
|
||||
<html>
|
||||
<body>
|
||||
<textarea name="RelayState">relay state probably same uuid as before</textarea>
|
||||
<textarea name="SAMLResponse">base64 encoded saml response</textarea>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
|
||||
// 'authgbg-saml-login'
|
||||
fetcherMock.mockReturnValueOnce(Promise.resolve({
|
||||
text: jest.fn().mockReturnValue(Promise.resolve(`
|
||||
<html>
|
||||
<body>
|
||||
<input name="RelayState" value="aUUID"></input>
|
||||
<input name="SAMLResponse" value="somebase64value"></input>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
|
||||
// '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')
|
||||
})
|
||||
})*/
|
||||
})
|
|
@ -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<void> {
|
||||
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<User> {
|
||||
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<CalendarItem[]> {
|
||||
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<Classmate[]> {
|
||||
// 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<NewsItem[]> {
|
||||
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<any> {
|
||||
return { ...item }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getMenu(_child: EtjanstChild): Promise<MenuItem[]> {
|
||||
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<Notification[]> {
|
||||
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<Skola24Child[]> {
|
||||
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<TimetableEntry[]> {
|
||||
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<void> {
|
||||
this.isLoggedIn = false
|
||||
this.personalNumber = undefined
|
||||
this.cookieManager.clearAll()
|
||||
this.emit('logout')
|
||||
}
|
||||
|
||||
public async login(personalNumber?: string): Promise<LoginStatusChecker> {
|
||||
// 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<LoginStatusChecker> {
|
||||
this.isFake = true
|
||||
|
||||
setTimeout(() => {
|
||||
this.isLoggedIn = true
|
||||
this.emit('login')
|
||||
}, 50)
|
||||
|
||||
const emitter: any = new EventEmitter()
|
||||
emitter.token = 'fake'
|
||||
return emitter
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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<Response> => {
|
||||
const responder = fetchMappings[name] ?? (() => {throw new Error("Request not faked for name: " + name)})
|
||||
return Promise.resolve(responder());
|
||||
}
|
|
@ -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": "<p><strong>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<p>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<p>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<p>Tjänsten fungerar på iPad, Androidplattor och i mobilen.<\/p>\r\n<p>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": "<p>Till vårdnadshavare med barn på Göteborgs Stads grundskolor och grundsärskolor.<\/p>\r\n<p>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<h2>Vi fortsätter hålla avstånd<\/h2>\r\n<ul>\r\n<li>Om du vill ha kontakt med någon på ditt barns skola vill vi gärna att du ringer eller skickar e-post.<\/li>\r\n<li>Lämna och hämta ditt barn utomhus på skolgården.<\/li>\r\n<li>En del möten som skolan har kommer att vara digitala.<\/li>\r\n<li>Uppmuntra ditt barn att promenera till och från skolan för att minska trängseln i kollektivtrafiken.<\/li>\r\n<\/ul>\r\n<h2>Detta gäller när ditt barn är sjukt<\/h2>\r\n<ul>\r\n<li>Barn som bara är lite sjuka, som till exempel är snuviga eller har ont i halsen, ska stanna hemma.<\/li>\r\n<li>Berätta alltid för skolan om ditt barn har konstaterad covid-19.<\/li>\r\n<\/ul>\r\n<p><a href=\"https://goteborg.se/wps/wcm/connect/a515d17c-7078-4663-8493-d1900b78cfb3/Om+ditt+barn+%C3%A4r+sjukt+eller+borta+fr%C3%A5n+skolan_information+till+v%C3%A5rdnadshavare_uppdaterad+13+augusti+2021.pdf?MOD=AJPERES\">Här hittar du mer information om vad som gäller när ditt barn är sjukt.<\/a><\/p>\r\n<h2>Om ditt barn har varit på resa utomlands<\/h2>\r\n<p>Folkhälsomyndigheten rekommenderar alla som har varit i länder utanför Norden att ta ett test för covid-19 när de kommer tillbaka Sverige. Detta gäller oavsett om man har symtom eller inte.<\/p>\r\n<p>Läs mer på Krisinformation.se om vad som gäller för resor från olika länder: <br /><a href=\"https://www.krisinformation.se/detta-kan-handa/handelser-och-storningar/20192/myndigheterna-om-det-nya-coronaviruset/reseinformation-med-anledning-av-det-nya-coronaviruset\">Utrikesresor och att vistas utomlands - Krisinformation.se<\/a><\/p>\r\n<h2>Undervisning på skolan<\/h2>\r\n<p>Från och med höstterminen 2021 har alla skolor undervisning på plats i skolan. Detta gäller även för årskurs 7-9.<\/p>\r\n<p>För förskoleklass till och med årskurs 9 finns det fortfarande möjlighet att få undervisning på distans om:<\/p>\r\n<ul>\r\n<li>Många av de som jobbar på skolan är frånvarande på grund av covid-19 och det inte går att ha undervisning i skolan.<\/li>\r\n<li>Det är stor spridningen av covid-19 bland elever och medarbetare.<\/li>\r\n<\/ul>\r\n<h2>Nytt test för covid-19 på skolorna<\/h2>\r\n<p>Inom kort börjar Västra Götalandsregionen med ett nytt test för covid-19 riktat mot elever. Om ditt barn har haft nära kontakt med en person på skolan som har konstaterad covid-19 får ni med ett paket hem med ett test. <\/p>\r\n<p>Du som vårdnadshavare hjälper ditt barn att ta testet. Testet lämnar du som vårdnadshavare sedan till en utvald vårdcentral.<\/p>\r\n<p>Om ditt barn ska ta ett test får du mer information från ditt barns skola om hur testet går till och vilken vårdcentral du ska lämna det till.<\/p>\r\n<h2>Kontakt<\/h2>\r\n<p>Har du frågor eller funderingar kontaktar du ditt barns skola.<\/p>\r\n<p><a href=\"https://goteborg.se/wps/portal/press-och-media/aktuelltarkivet/aktuellt/18b9930e-d34c-4d6a-817a-c1b8e74e5f9f#Z7_42G01J41KGV2F0ALK2K1SN1M75\">Läs mer om covid-19 och vad som gäller för grundskoleförvaltningen.<\/a><\/p>\r\n<p> <\/p>",
|
||||
"creator": {
|
||||
"id": "486497_goteborgsstad",
|
||||
"firstName": "Grundskola",
|
||||
"lastName": "Informerar",
|
||||
"email": null,
|
||||
"online": false,
|
||||
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
|
||||
"extraInfoInCatalog": ""
|
||||
},
|
||||
"recipientGroups": [
|
||||
{
|
||||
"id": 4925595,
|
||||
"name": "DL Göteborg Grundskola Vhavare Alla"
|
||||
},
|
||||
{
|
||||
"id": 4525636,
|
||||
"name": "Grundskola - informationskonto"
|
||||
},
|
||||
{
|
||||
"id": 4925600,
|
||||
"name": "DL Göteborg Grundsärskola Vhavare Alla"
|
||||
}
|
||||
],
|
||||
"created": {
|
||||
"ts": 1629096850743,
|
||||
"timezoneOffsetMinutes": 120
|
||||
},
|
||||
"attachments": [
|
||||
{
|
||||
"id": 67888219,
|
||||
"name": "Om ditt barn är sjukt eller borta från skolan_information till vårdnadshavare_uppdaterad 13 augusti 2021.pdf",
|
||||
"size": 70466
|
||||
}
|
||||
],
|
||||
"readByUser": false,
|
||||
"archivedByUser": false
|
||||
},
|
||||
{
|
||||
"id": 2982365,
|
||||
"title": "Nya regler för skolplacering i förskoleklass och grundskola",
|
||||
"body": "<p>Grundskolenämnden har beslutat om nya regler för skolplacering i förskoleklass och grundskola. Reglerna ska stärka elevernas rätt till en skola nära hemmet och börjar gälla 1 januari 2021.<\/p>\r\n<p>Du kan läsa mer på sidan <a href=\"https://goteborg.se/wps/portal/press-och-media/aktuelltarkivet/aktuellt/e45ce367-4d46-48b4-936d-900a3e45e490\">Nya regler för skolplacering i förskoleklass och grundskola<\/a>. <\/p>\r\n<p>Om du har frågor kan du kontakta grundskoleförvaltningen på telefon: 031-365 09 60 eller e-post: <a href=\"mailto:grundskola@grundskola.goteborg.se\">grundskola@grundskola.goteborg.se<\/a>. <\/p>\r\n<p><em>Observera att detta meddelande inte går att svara på. <\/em><\/p>\r\n<p> <\/p>",
|
||||
"creator": {
|
||||
"id": "486497_goteborgsstad",
|
||||
"firstName": "Grundskola",
|
||||
"lastName": "Informerar",
|
||||
"email": null,
|
||||
"online": false,
|
||||
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/icons/monalisa_large.png",
|
||||
"extraInfoInCatalog": ""
|
||||
},
|
||||
"recipientGroups": [
|
||||
{
|
||||
"id": 4925595,
|
||||
"name": "DL Göteborg Grundskola Vhavare Alla"
|
||||
},
|
||||
{
|
||||
"id": 4525636,
|
||||
"name": "Grundskola - informationskonto"
|
||||
}
|
||||
],
|
||||
"created": {
|
||||
"ts": 1603974943027,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"attachments": [],
|
||||
"readByUser": false,
|
||||
"archivedByUser": false
|
||||
}
|
||||
])
|
||||
}) as any as Response
|
|
@ -0,0 +1,458 @@
|
|||
// TODO: fix the startDate/endDate of all lessons
|
||||
export const lessons_133700_goteborgsstad = () => {
|
||||
const baseTime = 1636357800000;
|
||||
const baseDate = new Date(baseTime)
|
||||
const today = new Date()
|
||||
today.setHours(baseDate.getHours())
|
||||
today.setMinutes(baseDate.getMinutes())
|
||||
today.setSeconds(0)
|
||||
|
||||
const offset = Math.abs(baseTime - today.getTime())
|
||||
return {
|
||||
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=133700_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
|
||||
"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": "36080472:1",
|
||||
"title": "HKK",
|
||||
"location": "A402",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B/HKK"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636357800000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636360500000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Noel Nyström (NNM)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080497:1",
|
||||
"title": "BL",
|
||||
"location": "B260",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B/BL"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636361700000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636365000000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Joseph Ekström (JHE)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "37164864:1",
|
||||
"title": "IDH",
|
||||
"location": "IDH Ute",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B/IDH"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636365600000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636369800000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Katja Fransson (KAF)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080557:1",
|
||||
"title": "LUNCH",
|
||||
"location": "-",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636370700000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636372800000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080576:1",
|
||||
"title": "EN",
|
||||
"location": "A402",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B/EN"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636372800000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636376400000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Henrietta Fransson (HAF)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080591:1",
|
||||
"title": "MA",
|
||||
"location": "A402",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 8B/MA"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636377000000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636380600000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Amin Månsson (ANM)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
}
|
||||
])
|
||||
} as any as Response
|
||||
}
|
||||
|
||||
export const lessons_123456_goteborgsstad = () => {
|
||||
const baseTime = 1636355400000;
|
||||
const baseDate = new Date(baseTime)
|
||||
const today = new Date()
|
||||
today.setHours(baseDate.getHours())
|
||||
today.setMinutes(baseDate.getMinutes())
|
||||
today.setSeconds(0)
|
||||
|
||||
const offset = Math.abs(baseTime - today.getTime())
|
||||
return {
|
||||
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=123456_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
|
||||
"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": () => [
|
||||
{
|
||||
"id": "36080454:1",
|
||||
"title": "EV",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636355400000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636357500000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Petra Modin (PMO)",
|
||||
"Joakim Ness (JNE)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080467:1",
|
||||
"title": "MENT",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636357500000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636358100000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Petra Modin (PMO)",
|
||||
"Joakim Ness (JNE)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080474:1",
|
||||
"title": "EN",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636358400000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636362000000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Petra Modin (PMO)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080502:1",
|
||||
"title": "SV",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636362900000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636366500000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Joakim Ness (JNE)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080529:1",
|
||||
"title": "LUNCH",
|
||||
"location": "-",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636366500000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636368300000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080545:1",
|
||||
"title": "MA",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636369200000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636372800000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Ali Gupta (AGU)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"id": "36080578:1",
|
||||
"title": "NO",
|
||||
"location": "P18",
|
||||
"calendars": [
|
||||
"138JÄTS 21/22 5A"
|
||||
],
|
||||
"startDate": {
|
||||
"ts": offset + 1636373400000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"endDate": {
|
||||
"ts": offset + 1636376400000,
|
||||
"timezoneOffsetMinutes": 60
|
||||
},
|
||||
"ownPlannings": null,
|
||||
"teacherPlannings": null,
|
||||
"teacherAndStudentPlannings": null,
|
||||
"ownGeneralPlannings": null,
|
||||
"teacherGeneralPlannings": null,
|
||||
"teacherAndStudentGeneralPlannings": null,
|
||||
"bookedResourceNames": [],
|
||||
"bookedTeacherNames": [
|
||||
"Ali Gupta (AGU)"
|
||||
],
|
||||
"hasTest": false,
|
||||
"hasHomework": false,
|
||||
"hasAssignment": false,
|
||||
"url": null,
|
||||
"note": ""
|
||||
}
|
||||
]
|
||||
} as any as Response
|
||||
}
|
||||
|
||||
export const lessons_133737_goteborgsstad = () => (
|
||||
{
|
||||
"url": "https://hjarntorget.goteborg.se/api/schema/lessons?forUser=133737_goteborgsstad&startDateIso=2021-11-01&endDateIso=2021-11-08",
|
||||
"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([] as any[])
|
||||
}
|
||||
) as any as Response
|
|
@ -0,0 +1,41 @@
|
|||
export const myChildren = () => ({
|
||||
"url": "https://hjarntorget.goteborg.se/api/person/children",
|
||||
"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: Förskola
|
||||
"id": "133737_goteborgsstad",
|
||||
"firstName": "Havin",
|
||||
"lastName": "Göransson",
|
||||
"email": null,
|
||||
"online": false,
|
||||
"imagePath": "/pp/lookAndFeel/skins/hjarntorget/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": ""
|
||||
},
|
||||
{
|
||||
// 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
|
|
@ -0,0 +1,15 @@
|
|||
export const genericRolesInEvent = () => ({
|
||||
"url": "https://hjarntorget.goteborg.se/api/event-members/roles?eventId=XXX&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": 821,
|
||||
"name": "SINGLE ROLE"
|
||||
},
|
||||
])
|
||||
}) as any as Response
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,7 @@
|
|||
import { Features } from '@skolplattformen/api'
|
||||
|
||||
export const features: Features = {
|
||||
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: false,
|
||||
FOOD_MENU: false,
|
||||
CLASS_LIST: false,
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
Api,
|
||||
Fetch,
|
||||
FetcherOptions,
|
||||
RNCookieManager,
|
||||
ToughCookieJar,
|
||||
wrapReactNativeCookieManager,
|
||||
wrapToughCookie,
|
||||
} from '@skolplattformen/api'
|
||||
import { ApiHjarntorget } from './apiHjarntorget'
|
||||
export { features } from './features'
|
||||
|
||||
const init = (
|
||||
fetchImpl: Fetch,
|
||||
cookieManagerImpl: RNCookieManager | ToughCookieJar,
|
||||
options?: FetcherOptions
|
||||
): Api => {
|
||||
// prettier-ignore
|
||||
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
|
||||
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
|
||||
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
|
||||
return new ApiHjarntorget(fetchImpl as any, cookieManager, options)
|
||||
}
|
||||
|
||||
export default init
|
|
@ -0,0 +1,122 @@
|
|||
import { Fetcher, LoginStatusChecker } from '@skolplattformen/api'
|
||||
import { EventEmitter } from 'events'
|
||||
import {
|
||||
extractAuthGbgLoginRequestBody,
|
||||
extractHjarntorgetSAMLLogin,
|
||||
} from './parse/parsers'
|
||||
import {
|
||||
authGbgLoginUrl,
|
||||
hjarntorgetSAMLLoginUrl,
|
||||
pollStatusUrl,
|
||||
} from './routes'
|
||||
|
||||
export class HjarntorgetChecker extends EventEmitter {
|
||||
private fetcher: Fetcher
|
||||
|
||||
private basePollingUrl: string
|
||||
|
||||
public token: string
|
||||
|
||||
private cancelled = false
|
||||
|
||||
constructor(fetcher: Fetcher, basePollingUrl: string) {
|
||||
super()
|
||||
this.token = '' // not used, but needed for compatability with the LoginStatusChecker
|
||||
this.fetcher = fetcher
|
||||
this.basePollingUrl = basePollingUrl
|
||||
|
||||
this.check()
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
try {
|
||||
console.log('polling bankid signature')
|
||||
// https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/pollstatus
|
||||
|
||||
const pollStatusResponse = await this.fetcher(
|
||||
'poll-bankid-status',
|
||||
pollStatusUrl(this.basePollingUrl)
|
||||
)
|
||||
console.log('poll-bankid-status')
|
||||
const pollStatusResponseJson = await pollStatusResponse.json()
|
||||
|
||||
const keepPolling = pollStatusResponseJson.infotext !== ''
|
||||
const isError = pollStatusResponseJson.location.indexOf('error') >= 0
|
||||
if (!keepPolling && !isError) {
|
||||
console.log('bankid successfull! follow to location...')
|
||||
// follow response location to get back to auth.goteborg.se
|
||||
// r.location is something like:
|
||||
// 'https://mNN-mg-local.idp.funktionstjanster.se/mg-local/auth/ccp11/grp/signature'
|
||||
const signatureResponse = await this.fetcher(
|
||||
'confirm-signature-redirect',
|
||||
pollStatusResponseJson.location,
|
||||
{
|
||||
redirect: 'follow',
|
||||
}
|
||||
)
|
||||
if (!signatureResponse.ok) {
|
||||
throw new Error('Bad signature response')
|
||||
}
|
||||
const signatureResponseText = await signatureResponse.text()
|
||||
const authGbgLoginBody = extractAuthGbgLoginRequestBody(
|
||||
signatureResponseText
|
||||
)
|
||||
|
||||
console.log('authGbg saml login')
|
||||
const authGbgLoginResponse = await this.fetcher(
|
||||
'authgbg-saml-login',
|
||||
authGbgLoginUrl,
|
||||
{
|
||||
redirect: 'follow',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: authGbgLoginBody,
|
||||
}
|
||||
)
|
||||
if (!authGbgLoginResponse.ok) {
|
||||
throw new Error('Bad augGbgLogin response')
|
||||
}
|
||||
const authGbgLoginResponseText = await authGbgLoginResponse.text()
|
||||
const hjarntorgetSAMLLoginBody = extractHjarntorgetSAMLLogin(
|
||||
authGbgLoginResponseText
|
||||
)
|
||||
|
||||
console.log('hjarntorget saml login')
|
||||
const hjarntorgetSAMLLoginResponse = await this.fetcher(
|
||||
'hjarntorget-saml-login',
|
||||
hjarntorgetSAMLLoginUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: hjarntorgetSAMLLoginBody,
|
||||
}
|
||||
)
|
||||
if (!hjarntorgetSAMLLoginResponse.ok) {
|
||||
throw new Error('Bad hjarntorgetSAMLLogin response')
|
||||
}
|
||||
// TODO: add more checks above between calls to see if everything is actually 'OK'...
|
||||
this.emit('OK')
|
||||
} else if (isError) {
|
||||
console.log('polling error')
|
||||
this.emit('ERROR')
|
||||
} else if (!this.cancelled && keepPolling) {
|
||||
console.log('keep on polling...')
|
||||
this.emit('PENDING')
|
||||
setTimeout(() => this.check(), 3000)
|
||||
}
|
||||
} catch (er) {
|
||||
console.log('Error validating login to Hjärntorget', er)
|
||||
this.emit('ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
export const checkStatus = (
|
||||
fetch: Fetcher,
|
||||
basePollingUrl: string
|
||||
): LoginStatusChecker => new HjarntorgetChecker(fetch, basePollingUrl)
|
|
@ -0,0 +1 @@
|
|||
declare module 'h2m'
|
|
@ -0,0 +1,58 @@
|
|||
import * as html from 'node-html-parser'
|
||||
import { decode } from 'he'
|
||||
|
||||
// TODO: Move this into the parse folder and convert it to follow the pattern of other parsers (include tests).
|
||||
|
||||
export const extractInputField = (sought: string, attrs: string[]) => {
|
||||
// there must be a better way to do this...
|
||||
const s = attrs.find(e => e.indexOf(sought) >= 0) || ""
|
||||
const v = s.substring(s.indexOf('value="') + 'value="'.length)
|
||||
return v.substring(0, v.length - 2)
|
||||
}
|
||||
|
||||
export function extractMvghostRequestBody(initBankIdResponseText: string) {
|
||||
const doc = html.parse(decode(initBankIdResponseText))
|
||||
const inputAttrs = doc.querySelectorAll('input').map(i => (i as any).rawAttrs)
|
||||
const relayState = extractInputField('RelayState', inputAttrs)
|
||||
const samlRequest = extractInputField("SAMLRequest", inputAttrs)
|
||||
const mvghostRequestBody = `RelayState=${encodeURIComponent(relayState)}&SAMLRequest=${encodeURIComponent(samlRequest)}`
|
||||
|
||||
return mvghostRequestBody
|
||||
}
|
||||
|
||||
export function extractHjarntorgetSAMLLogin(authGbgLoginResponseText: string) {
|
||||
const authGbgLoginDoc = html.parse(decode(authGbgLoginResponseText))
|
||||
const inputAttrs = authGbgLoginDoc.querySelectorAll('input').map(i => (i as any).rawAttrs)
|
||||
const RelayStateText = extractInputField('RelayState', inputAttrs)
|
||||
const SAMLResponseText = extractInputField("SAMLResponse", inputAttrs)
|
||||
|
||||
return `SAMLResponse=${encodeURIComponent(SAMLResponseText || '')}&RelayState=${encodeURIComponent(RelayStateText || '')}`
|
||||
}
|
||||
|
||||
export function extractAuthGbgLoginRequestBody(signatureResponseText: string) {
|
||||
const signatureResponseDoc = html.parse(decode(signatureResponseText))
|
||||
const signatureResponseTextAreas = signatureResponseDoc.querySelectorAll('textarea')
|
||||
const SAMLResponseElem = signatureResponseTextAreas.find(ta => {
|
||||
const nameAttr = ta.getAttribute("name")
|
||||
return nameAttr === 'SAMLResponse'
|
||||
})
|
||||
const SAMLResponseText = SAMLResponseElem?.rawText
|
||||
const RelayStateElem = signatureResponseTextAreas.find(ta => {
|
||||
const nameAttr = ta.getAttribute("name")
|
||||
return nameAttr === 'RelayState'
|
||||
})
|
||||
const RelayStateText = RelayStateElem?.rawText
|
||||
const authGbgLoginBody = `SAMLResponse=${encodeURIComponent(SAMLResponseText || '')}&RelayState=${encodeURIComponent(RelayStateText || '')}`
|
||||
return authGbgLoginBody
|
||||
}
|
||||
|
||||
export const parseCalendarItem = (x: html.HTMLElement): { id: number; title: string; startDate: string; endDate: string } => {
|
||||
const info = Array.from(x.querySelectorAll('a'))
|
||||
// TODO: the identifier is realy on this format: '\d+:\d+' currently we only take the first part so Id will clash between items
|
||||
const id = info[0].getAttribute("onClick")?.replace(new RegExp("return viewEvent\\('(\\d+).+"), "$1") || NaN
|
||||
const day = info[1].textContent
|
||||
const timeSpan = info[2].textContent
|
||||
const [startTime, endTime] = timeSpan.replace(".", ":").split("-")
|
||||
|
||||
return { id: +id, title: info[0].textContent, startDate: `${day} ${startTime}`, endDate: `${day} ${endTime}` }
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
export const lessonsUrl = (lessonParams: {forUser: string, startDateIso: string, endDateIso: string}) => {
|
||||
const urlEncodedParams = new URLSearchParams(lessonParams).toString()
|
||||
return `https://hjarntorget.goteborg.se/api/schema/lessons?${urlEncodedParams}`
|
||||
}
|
||||
export const hjarntorgetUrl = 'https://hjarntorget.goteborg.se'
|
||||
export const currentUserUrl = 'https://hjarntorget.goteborg.se/api/core/current-user'
|
||||
export const myChildrenUrl = 'https://hjarntorget.goteborg.se/api/person/children'
|
||||
export const infoUrl = 'https://hjarntorget.goteborg.se/api/information/messages-by-date-desc?messageStatus=CURRENT&offset=0&limit=10&language=en'
|
||||
export const fullImageUrl = (imagePath: string) => hjarntorgetUrl + imagePath;
|
||||
export const infoSetReadUrl = (item: {id: string}) => `https://hjarntorget.goteborg.se/api/information/set-message-read?messageId=${item.id}`
|
||||
export const hjarntorgetEventsUrl = 'https://hjarntorget.goteborg.se/api/events/events-sorted-by-name?offset=0&limit=100'
|
||||
export const rolesInEventUrl = (eventId: number) => `https://hjarntorget.goteborg.se/api/event-members/roles?eventId=${eventId}&language=en`
|
||||
export const membersWithRoleUrl = (eventId: number, roleId: string) => `https://hjarntorget.goteborg.se/api/event-members/members-having-role?eventId=${eventId}&roleId=${roleId}`
|
||||
export const wallMessagesUrl = 'https://hjarntorget.goteborg.se/api/wall/events?language=en&limit=500'
|
||||
export const beginLoginUrl = 'https://hjarntorget.goteborg.se'
|
||||
export const calendarsUrl = 'https://hjarntorget.goteborg.se/pp/system/calendar/cal_events.jsp'
|
||||
export const calendarEventUrl = (calendarId: string, startDate: string, endDate: string) => `${calendarsUrl}?order_by=start_date&show_cal_ids=${calendarId}&mode=separate&filter_start_date=${startDate}&filter_end_date=${endDate}&search_for=`
|
||||
|
||||
export const shibbolethLoginUrlBase = (beginLoginRedirectUrl: string) => {
|
||||
const returnUrlStart = beginLoginRedirectUrl.indexOf('return=') + 'return='.length
|
||||
return decodeURIComponent(beginLoginRedirectUrl.substring(returnUrlStart))
|
||||
}
|
||||
|
||||
export const shibbolethLoginUrl = (returnUrl: string) => {
|
||||
return `${returnUrl}&entityID=${encodeURIComponent('https://auth.goteborg.se/FIM/sps/HjarntorgetEID/saml20')}`
|
||||
}
|
||||
|
||||
export const initBankIdUrl = (shibbolethRedirectUrl: any) => {
|
||||
const targetParamIndex = shibbolethRedirectUrl.indexOf('Target=') + 'Target='.length
|
||||
const targetParam = decodeURIComponent(shibbolethRedirectUrl.substring(targetParamIndex))
|
||||
const initBankIdUrl = 'https://auth.goteborg.se/FIM/sps/BankID/saml20/logininitial?'
|
||||
const initBankIdParams = `ITFIM_WAYF_IDP=${encodeURIComponent('https://m00-mg-local.idp.funktionstjanster.se/samlv2/idp/metadata/0/34')}` +
|
||||
`&submit=Mobilt+BankID` +
|
||||
`&ResponseBinding=HTTPPost` +
|
||||
`&RequestBinding=HTTPPost` +
|
||||
`&Target=${encodeURIComponent(targetParam)}`
|
||||
return initBankIdUrl + initBankIdParams
|
||||
}
|
||||
|
||||
export const mvghostUrl = 'https://m00-mg-local.idp.funktionstjanster.se/samlv2/idp/req/0/34?mgvhostparam=0'
|
||||
|
||||
export const beginBankIdUrl = (beingBankIdUrlBase: string) => `${beingBankIdUrlBase}/ssn`
|
||||
|
||||
export const verifyUrlBase = (verifyUrl: string) => verifyUrl.substring(0, verifyUrl.length - 'verify'.length)
|
||||
|
||||
export const pollStatusUrl = (basePollingUrl: string) => `${basePollingUrl}pollstatus`
|
||||
|
||||
export const authGbgLoginUrl = 'https://auth.goteborg.se/FIM/sps/BankID/saml20/login'
|
||||
export const hjarntorgetSAMLLoginUrl = 'https://hjarntorget.goteborg.se/Shibboleth.sso/SAML2/POST'
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "@skolplattformen/api-hjarntorget",
|
||||
"version": "0.15.0",
|
||||
"description": "Since the proxy was blocked (and also deemed a bad idea by some), this is a reboot of the API running in process in the app(s).",
|
||||
"main": "lib/index.ts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"repository": "git@github.com:kolplattformen/embedded-api.git",
|
||||
"author": "Johan Öbrink <johan.obrink@gmail.com>",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
|
||||
"test": "jest",
|
||||
"build": "tsc --build",
|
||||
"prepare": "yarn build",
|
||||
"run-dev": "yarn run build && node run",
|
||||
"publish-package": "npm publish --access public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@skolplattformen/curriculum": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-cookies/cookies": "^6.0.7",
|
||||
"@skolplattformen/curriculum": "^1.4.2",
|
||||
"@types/base-64": "^1.0.0",
|
||||
"@types/he": "^1.1.1",
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/luxon": "^1.26.4",
|
||||
"@types/node-fetch": "^2.5.10",
|
||||
"@types/tough-cookie": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
"@typescript-eslint/parser": "^4.22.0",
|
||||
"eslint": "^7.24.0",
|
||||
"eslint-config-airbnb-typescript": "^12.3.1",
|
||||
"eslint-config-prettier": "^8.2.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"fetch-cookie": "^0.11.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"node-blob": "^0.0.2",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prettier": "^2.2.1",
|
||||
"tough-cookie": "^4.0.0",
|
||||
"ts-jest": "^26.5.5",
|
||||
"typescript": "^4.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"camelcase-keys": "^6.2.2",
|
||||
"change-case": "^4.1.2",
|
||||
"events": "^3.3.0",
|
||||
"h2m": "^0.7.0",
|
||||
"he": "^1.2.0",
|
||||
"js-htmlencode": "^0.3.0",
|
||||
"luxon": "^1.26.0",
|
||||
"node-html-parser": "^2.1.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
function requestLogger(httpModule) {
|
||||
var original = httpModule.request
|
||||
httpModule.request = function (options, callback) {
|
||||
console.log('-----------------------------------------------')
|
||||
console.log(
|
||||
options.href || options.proto + '://' + options.host + options.path,
|
||||
options.method
|
||||
)
|
||||
console.log(options.headers)
|
||||
console.log('-----------------------------------------------')
|
||||
return original(options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
requestLogger(require('http'))
|
||||
requestLogger(require('https'))
|
||||
|
||||
const { DateTime } = require('luxon')
|
||||
const nodeFetch = require('node-fetch')
|
||||
const { CookieJar } = require('tough-cookie')
|
||||
const fetchCookie = require('fetch-cookie/node-fetch')
|
||||
const { writeFile } = require('fs/promises')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { inspect } = require('util')
|
||||
const init = require('./dist/libs/api-hjarntorget/lib').default
|
||||
|
||||
const [, , personalNumber] = process.argv
|
||||
|
||||
if (!personalNumber) {
|
||||
console.error(
|
||||
'You must pass in a valid personal number, eg `node run 197001011111`'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function ensureDirectoryExistence(filePath) {
|
||||
var dirname = path.dirname(filePath)
|
||||
if (fs.existsSync(dirname)) {
|
||||
return true
|
||||
}
|
||||
ensureDirectoryExistence(dirname)
|
||||
fs.mkdirSync(dirname)
|
||||
}
|
||||
|
||||
const record = async (info, data) => {
|
||||
const name = info.error ? `${info.name}_error` : info.name
|
||||
const filename = `./record/${name}.json`
|
||||
ensureDirectoryExistence(filename)
|
||||
const content = {
|
||||
url: info.url,
|
||||
headers: info.headers,
|
||||
status: info.status,
|
||||
statusText: info.statusText,
|
||||
}
|
||||
if (data) {
|
||||
switch (info.type) {
|
||||
case 'json':
|
||||
content.json = data
|
||||
break
|
||||
case 'text':
|
||||
content.text = data
|
||||
break
|
||||
case 'blob':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const buffer = await data.arrayBuffer()
|
||||
content.blob = Buffer.from(buffer).toString('base64')
|
||||
break
|
||||
}
|
||||
} else if (info.error) {
|
||||
const { message, stack } = info.error
|
||||
content.error = {
|
||||
message,
|
||||
stack,
|
||||
}
|
||||
}
|
||||
await writeFile(filename, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const cookieJar = new CookieJar()
|
||||
const fetch = fetchCookie(nodeFetch, cookieJar)
|
||||
|
||||
try {
|
||||
const api = init(fetch, cookieJar, { record })
|
||||
console.log("inited...")
|
||||
|
||||
api.on('login', async () => {
|
||||
console.log("Logged in!")
|
||||
await api.getUser()
|
||||
const children = await api.getChildren()
|
||||
const now = DateTime.fromJSDate(new Date)
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const c = children[i];
|
||||
await api.getCalendar(c)
|
||||
await api.getNotifications(c)
|
||||
await api.getTimetable(c, 44, 2021, 'ignored')
|
||||
}
|
||||
const news = await api.getNews()
|
||||
// const news = await api.getNews()
|
||||
// //console.table(news.map(n => ({ id: n.id, author: n.author, published: n.published})))
|
||||
// //news.length && console.log(news[0])
|
||||
|
||||
// const notifications = await api.getNotifications(children[2])
|
||||
// //const ns = notifications.map(n => ({id: n.id, sender: n.sender, type: n.type}))
|
||||
// //console.table(ns)
|
||||
// console.log("notifications count", notifications.length)
|
||||
// notifications.slice(0, 10).forEach(console.log)
|
||||
|
||||
// await api.getCalendar(children[1])
|
||||
|
||||
// await api.getTimetable(children[1], 38, 2021, "en")
|
||||
|
||||
// await api.getClassmates()
|
||||
// console.table(schema)
|
||||
|
||||
});
|
||||
const res = await api.login(personalNumber)
|
||||
console.log(res)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["**/*.ts", "**/*.js"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES6",
|
||||
"module": "CommonJS",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"lib"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/__tests__/*",
|
||||
"**/__mocks__/*"
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -104,3 +104,5 @@ dist
|
|||
.tern-port
|
||||
|
||||
record
|
||||
|
||||
dist/*
|
|
@ -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<boolean>
|
||||
setFromResponse(url: string, cookie: string): Promise<boolean>
|
||||
|
||||
get(url: string, useWebKit?: boolean): Promise<Cookies>
|
||||
|
||||
clearAll(useWebKit?: boolean): Promise<boolean>
|
||||
}
|
||||
|
||||
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
|
|
@ -19,7 +19,7 @@ const path = require('path')
|
|||
const fs = require('fs')
|
||||
const HttpProxyAgent = require('https-proxy-agent')
|
||||
const agentWrapper = require('./agentFetchWrapper')
|
||||
const init = require('./dist').default
|
||||
const init = require('./dist/api-skolplattformen/lib').default
|
||||
|
||||
const [, , personalNumber] = process.argv
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import CookieManager from '@react-native-cookies/cookies'
|
||||
import init from './'
|
||||
import { Api } from './api'
|
||||
import { Fetch, Headers, Response } from './types'
|
||||
import { ApiSkolplattformen } from './api'
|
||||
import { Fetch, Headers, Response } from '@skolplattformen/api'
|
||||
import CookieManager from '@react-native-cookies/cookies'
|
||||
|
||||
jest.mock('@react-native-cookies/cookies')
|
||||
|
||||
describe('api', () => {
|
||||
let fetch: jest.Mocked<Fetch>
|
||||
let response: jest.Mocked<Response>
|
||||
let headers: jest.Mocked<Headers>
|
||||
let api: Api
|
||||
let api: ApiSkolplattformen
|
||||
beforeEach(() => {
|
||||
headers = { get: jest.fn() }
|
||||
response = {
|
||||
|
@ -21,7 +23,7 @@ describe('api', () => {
|
|||
fetch = jest.fn().mockResolvedValue(response)
|
||||
response.text.mockResolvedValue('<html></html>')
|
||||
CookieManager.clearAll()
|
||||
api = init(fetch, CookieManager)
|
||||
api = init(fetch, CookieManager) as ApiSkolplattformen
|
||||
})
|
||||
describe('#login', () => {
|
||||
it('exposes token', async () => {
|
||||
|
@ -110,6 +112,7 @@ describe('api', () => {
|
|||
const personalNumber = 'my personal number'
|
||||
try {
|
||||
await api.login(personalNumber)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
expect(error.message).toEqual(expect.stringContaining('Server Error'))
|
||||
}
|
||||
|
|
|
@ -1,32 +1,36 @@
|
|||
import { Language } from '@skolplattformen/curriculum'
|
||||
import { EventEmitter } from 'events'
|
||||
import { decode } from 'he'
|
||||
import { DateTime } from 'luxon'
|
||||
import * as html from 'node-html-parser'
|
||||
import * as fake from './fakeData'
|
||||
import wrap, { Fetcher, FetcherOptions } from './fetcher'
|
||||
import { checkStatus, LoginStatusChecker } from './loginStatus'
|
||||
import * as parse from './parse/index'
|
||||
import * as routes from './routes'
|
||||
import {
|
||||
Api,
|
||||
AuthTicket,
|
||||
CalendarItem,
|
||||
Classmate,
|
||||
CookieManager,
|
||||
EtjanstChild,
|
||||
Fetch,
|
||||
Fetcher,
|
||||
FetcherOptions,
|
||||
LoginStatusChecker,
|
||||
MenuItem,
|
||||
NewsItem,
|
||||
Notification,
|
||||
RequestInit,
|
||||
Response,
|
||||
ScheduleItem,
|
||||
Skola24Child,
|
||||
SSOSystem,
|
||||
TimetableEntry,
|
||||
URLSearchParams,
|
||||
User,
|
||||
Response,
|
||||
} from './types'
|
||||
import { URLSearchParams } from './URLSearchParams'
|
||||
wrap,
|
||||
} from '@skolplattformen/api'
|
||||
import { Language } from '@skolplattformen/curriculum'
|
||||
import { EventEmitter } from 'events'
|
||||
import { decode } from 'he'
|
||||
import { DateTime } from 'luxon'
|
||||
import * as html from 'node-html-parser'
|
||||
import * as fake from './fakeData'
|
||||
import { checkStatus } from './loginStatusChecker'
|
||||
import * as parse from './parse/index'
|
||||
import * as routes from './routes'
|
||||
|
||||
const fakeResponse = <T>(data: T): Promise<T> =>
|
||||
new Promise((res) => setTimeout(() => res(data), 200 + Math.random() * 800))
|
||||
|
@ -49,11 +53,12 @@ interface SSOSystems {
|
|||
[name: string]: boolean | undefined
|
||||
}
|
||||
|
||||
export class Api extends EventEmitter {
|
||||
export class ApiSkolplattformen extends EventEmitter implements Api {
|
||||
private fetch: Fetcher
|
||||
|
||||
private personalNumber?: string
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private headers: any
|
||||
|
||||
private cookieManager: CookieManager
|
||||
|
@ -65,7 +70,7 @@ export class Api extends EventEmitter {
|
|||
private authorizedSystems: SSOSystems = {}
|
||||
|
||||
constructor(
|
||||
fetch: Fetch,
|
||||
fetch: Fetch, // typeof global.fetch,
|
||||
cookieManager: CookieManager,
|
||||
options?: FetcherOptions
|
||||
) {
|
||||
|
@ -198,6 +203,7 @@ export class Api extends EventEmitter {
|
|||
this.emit('login')
|
||||
}, 50)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const emitter: any = new EventEmitter()
|
||||
emitter.token = 'fake'
|
||||
return emitter
|
||||
|
@ -284,10 +290,17 @@ export class Api extends EventEmitter {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private CheckResponseForCorrectChildStatus(response: Response, child: EtjanstChild) {
|
||||
const setCookieResp = response.headers.get("Set-Cookie")
|
||||
private CheckResponseForCorrectChildStatus(
|
||||
response: Response,
|
||||
child: EtjanstChild
|
||||
) {
|
||||
const setCookieResp = response.headers.get('Set-Cookie')
|
||||
|
||||
if (child.status !== 'FS' && setCookieResp && setCookieResp.includes("Status=FS")) {
|
||||
if (
|
||||
child.status !== 'FS' &&
|
||||
setCookieResp &&
|
||||
setCookieResp.includes('Status=FS')
|
||||
) {
|
||||
throw new Error('Wrong child in response')
|
||||
}
|
||||
}
|
||||
|
@ -295,7 +308,7 @@ export class Api extends EventEmitter {
|
|||
public async getNewsDetails(
|
||||
child: EtjanstChild,
|
||||
item: NewsItem
|
||||
): Promise<NewsItem> {
|
||||
): Promise<NewsItem | undefined> {
|
||||
if (this.isFake) {
|
||||
return fakeResponse(fake.news(child).find((ni) => ni.id === item.id) || {id: "", published: ""})
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
Skola24Child,
|
||||
TimetableEntry,
|
||||
User,
|
||||
} from './types'
|
||||
} from '@skolplattformen/api';
|
||||
|
||||
const data: any = {
|
||||
'39b59e-bf4b9f-f68ac25321-977218-bf0': {
|
||||
|
@ -1234,7 +1234,7 @@ export const timetable = (child: Skola24Child): TimetableEntry[] => {
|
|||
category: 'Moderna språk, språkval',
|
||||
blockName: '',
|
||||
dayOfWeek: 2,
|
||||
location: '302,Fjärr',
|
||||
location: '302,Fjärr asd asdasd asdad aasdds',
|
||||
teacher: 'DNi',
|
||||
timeEnd: '09:50:00',
|
||||
timeStart: '09:05:00',
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Features } from '@skolplattformen/api'
|
||||
|
||||
export const features: Features = {
|
||||
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: true,
|
||||
FOOD_MENU: true,
|
||||
CLASS_LIST: true,
|
||||
}
|
|
@ -1,16 +1,13 @@
|
|||
import { Api } from './api'
|
||||
import {
|
||||
Api,
|
||||
FetcherOptions,
|
||||
RNCookieManager,
|
||||
ToughCookieJar,
|
||||
wrapReactNativeCookieManager,
|
||||
wrapToughCookie,
|
||||
} from './cookies'
|
||||
import { FetcherOptions } from './fetcher'
|
||||
import { Fetch } from './types'
|
||||
|
||||
export { LoginStatusChecker } from './loginStatus'
|
||||
export * from './types'
|
||||
export { Api, FetcherOptions }
|
||||
} from '@skolplattformen/api'
|
||||
import { ApiSkolplattformen } from './api'
|
||||
export { features } from './features'
|
||||
|
||||
const init = (
|
||||
fetchImpl: Fetch,
|
||||
|
@ -21,7 +18,7 @@ const init = (
|
|||
const cookieManager = ((cookieManagerImpl as RNCookieManager).get)
|
||||
? wrapReactNativeCookieManager(cookieManagerImpl as RNCookieManager)
|
||||
: wrapToughCookie(cookieManagerImpl as ToughCookieJar)
|
||||
return new Api(fetchImpl, cookieManager, options)
|
||||
return new ApiSkolplattformen(fetchImpl as any, cookieManager, options)
|
||||
}
|
||||
|
||||
export default init
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import { Fetcher } from './fetcher'
|
||||
import { loginStatus } from './routes'
|
||||
import { AuthTicket } from './types'
|
||||
import { Fetcher, AuthTicket } from '@skolplattformen/api'
|
||||
|
||||
/*
|
||||
export enum LoginEvent {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import { loginStatus } from './routes';
|
||||
import { AuthTicket, Fetcher, LoginStatusChecker } from '@skolplattformen/api';
|
||||
|
||||
export class Checker extends EventEmitter {
|
||||
public token: string;
|
||||
|
||||
private fetcher: Fetcher;
|
||||
|
||||
private url: string;
|
||||
|
||||
private cancelled = false;
|
||||
|
||||
constructor(fetcher: Fetcher, ticket: AuthTicket) {
|
||||
super();
|
||||
this.fetcher = fetcher;
|
||||
this.url = loginStatus(ticket.order);
|
||||
this.token = ticket.token;
|
||||
this.check();
|
||||
}
|
||||
|
||||
async check(): Promise<void> {
|
||||
const response = await this.fetcher('login-status', this.url);
|
||||
const status = await response.text();
|
||||
this.emit(status);
|
||||
if (!this.cancelled &&
|
||||
status !== 'OK' &&
|
||||
status !== 'ERROR!' &&
|
||||
status !== 'CANCELLED') {
|
||||
setTimeout(() => this.check(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const checkStatus = (
|
||||
fetch: Fetcher,
|
||||
ticket: AuthTicket
|
||||
): LoginStatusChecker => new Checker(fetch, ticket)
|
|
@ -1,6 +1,5 @@
|
|||
import { etjanst } from './etjanst'
|
||||
import { CalendarItem } from '../types'
|
||||
import { parseDate } from '../utils/dateHandling'
|
||||
import { CalendarItem, parseDate } from '@skolplattformen/api'
|
||||
|
||||
export const calendarItem = ({
|
||||
id,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Child } from '@skolplattformen/api'
|
||||
import { etjanst } from './etjanst'
|
||||
import { Child } from '../types'
|
||||
|
||||
export const child = ({ id, sdsId, name, status, schoolId }: any): Child => ({
|
||||
id,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Classmate, Guardian } from '@skolplattformen/api'
|
||||
import { etjanst } from './etjanst'
|
||||
import { Classmate, Guardian } from '../types'
|
||||
|
||||
export const guardian = ({
|
||||
emailhome,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { MenuItem, MenuList, toMarkdown } from '@skolplattformen/api'
|
||||
import { etjanst } from './etjanst'
|
||||
import { toMarkdown } from '../parseHtml'
|
||||
import { MenuItem, MenuList } from '../types'
|
||||
|
||||
|
||||
export const menuItem = ({ title, description }: any): MenuItem => ({
|
||||
title,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { NewsItem, parseDate, toMarkdown } from '@skolplattformen/api';
|
||||
import { etjanst } from './etjanst'
|
||||
import { toMarkdown } from '../parseHtml'
|
||||
import { NewsItem } from '../types'
|
||||
import { parseDate } from '../utils/dateHandling'
|
||||
|
||||
|
||||
const IMAGE_HOST =
|
||||
'https://etjanst.stockholm.se/Vardnadshavare/inloggad2/NewsBanner?url='
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { parseDate, Notification } from '@skolplattformen/api'
|
||||
import { etjanst } from './etjanst'
|
||||
import { parseDate } from '../utils/dateHandling'
|
||||
import { Notification } from '../types'
|
||||
|
||||
export const notification = ({
|
||||
notification: { messageid, dateCreated, dateModified },
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { parseDate, ScheduleItem } from '@skolplattformen/api'
|
||||
import { etjanst } from './etjanst'
|
||||
import { ScheduleItem } from '../types'
|
||||
import { parseDate } from '../utils/dateHandling'
|
||||
|
||||
export const scheduleItem = ({
|
||||
title,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { TimetableEntry } from '@skolplattformen/api'
|
||||
import parse, { Language } from '@skolplattformen/curriculum'
|
||||
import { DateTime } from 'luxon'
|
||||
import { TimetableEntry } from '../types'
|
||||
|
||||
const calculateDate = (
|
||||
year: number,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from '../types'
|
||||
import { User } from "@skolplattformen/api";
|
||||
|
||||
export const user = ({
|
||||
socialSecurityNumber,
|
||||
|
|
|
@ -6,14 +6,14 @@
|
|||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"repository": "git@github.com:kolplattformen/embedded-api.git",
|
||||
"author": "Johan Öbrink <johan.obrink@gmail.com>",
|
||||
"repository": "git@github.com:kolplattformen/skolplattformen.git",
|
||||
"author": "ÖS <info@skolplattformen.org>",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"lint": "eslint 'lib/**/*.{js,ts}' --quiet --fix",
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"build": "tsc --build",
|
||||
"prepare": "yarn build",
|
||||
"run-dev": "yarn run build && node run",
|
||||
"publish-package": "npm publish --access public"
|
||||
|
|
|
@ -24,7 +24,7 @@ const path = require('path')
|
|||
const fs = require('fs')
|
||||
const { inspect } = require('util')
|
||||
|
||||
const init = require('./dist').default
|
||||
const init = require('./dist/api-skolplattformen/lib').default
|
||||
|
||||
const [, , personalNumber] = process.argv
|
||||
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ES6",
|
||||
"module": "CommonJS",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
"include": [
|
||||
"lib"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/__tests__/*",
|
||||
"**/__mocks__/*",
|
||||
]
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
},
|
||||
"exclude": ["**/*.spec.ts", "**/*.spec.tsx"],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
"types": ["jest", "node"],
|
||||
"composite": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', {targets: {node: 'current'}}],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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/*
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"branches": ["main"]
|
||||
}
|
|
@ -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.
|
|
@ -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<boolean>
|
||||
setFromResponse(url: string, cookie: string): Promise<boolean>
|
||||
|
||||
get(url: string, useWebKit?: boolean): Promise<Cookies>
|
||||
|
||||
clearAll(useWebKit?: boolean): Promise<boolean>
|
||||
}
|
||||
|
||||
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
|
|
@ -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"
|
||||
}
|
|
@ -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'],
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { Language } from '@skolplattformen/curriculum'
|
||||
import { EventEmitter } from 'events'
|
||||
import { DateTime } from 'luxon'
|
||||
import { LoginStatusChecker } from './loginStatus'
|
||||
import {
|
||||
CalendarItem,
|
||||
Classmate,
|
||||
MenuItem,
|
||||
NewsItem,
|
||||
Notification,
|
||||
User,
|
||||
Skola24Child,
|
||||
EtjanstChild,
|
||||
TimetableEntry,
|
||||
ScheduleItem,
|
||||
} from './types'
|
||||
|
||||
export interface Api extends EventEmitter {
|
||||
isFake: boolean
|
||||
isLoggedIn: boolean
|
||||
getPersonalNumber(): string | undefined
|
||||
login(personalNumber?: string): Promise<LoginStatusChecker>
|
||||
setSessionCookie(sessionCookie: string): Promise<void>
|
||||
getUser(): Promise<User>
|
||||
getChildren(): Promise<EtjanstChild[]>
|
||||
getCalendar(child: EtjanstChild): Promise<CalendarItem[]>
|
||||
getClassmates(child: EtjanstChild): Promise<Classmate[]>
|
||||
getNews(child: EtjanstChild): Promise<NewsItem[]>
|
||||
getNewsDetails(child: EtjanstChild, item: NewsItem): Promise<any>
|
||||
getMenu(child: EtjanstChild): Promise<MenuItem[]>
|
||||
getNotifications(child: EtjanstChild): Promise<Notification[]>
|
||||
getSchedule(child: EtjanstChild, from: DateTime, to: DateTime): Promise<ScheduleItem[]>
|
||||
getSkola24Children(): Promise<Skola24Child[]>
|
||||
getTimetable(child: Skola24Child, week: number, year: number, lang: Language): Promise<TimetableEntry[]>
|
||||
logout(): Promise<void>
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export interface Features {
|
||||
LOGIN_BANK_ID_SAME_DEVICE_WITHOUT_ID: boolean
|
||||
FOOD_MENU: boolean,
|
||||
CLASS_LIST: boolean
|
||||
}
|
||||
|
||||
export type FeatureType = keyof Features
|
|
@ -1,4 +1,4 @@
|
|||
import { Fetch, RequestInit, Response } from './types'
|
||||
import { Response, Fetch, RequestInit } from './types'
|
||||
|
||||
export interface CallInfo extends RequestInit {
|
||||
name: string
|
|
@ -0,0 +1,18 @@
|
|||
import wrap from './fetcher'
|
||||
|
||||
export { toMarkdown } from './parseHtml'
|
||||
export * from './types'
|
||||
export { LoginStatusChecker } from './loginStatus'
|
||||
export { Api } from './api'
|
||||
export { FetcherOptions, Fetcher } from './fetcher'
|
||||
export {
|
||||
RNCookieManager,
|
||||
ToughCookieJar,
|
||||
wrapReactNativeCookieManager,
|
||||
wrapToughCookie,
|
||||
} from './cookies'
|
||||
export { URLSearchParams } from './URLSearchParams'
|
||||
|
||||
export { wrap };
|
||||
export { FeatureType, Features } from './features'
|
||||
export * from './utils/dateHandling';
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
export enum LoginEvent {
|
||||
OK = 'OK',
|
||||
PENDING = 'PENDING',
|
||||
ERROR = 'ERROR',
|
||||
USER_SIGN = 'USER_SIGN',
|
||||
}
|
||||
*/
|
||||
|
||||
export interface LoginStatusChecker {
|
||||
token: string
|
||||
on: (
|
||||
event: 'OK' | 'PENDING' | 'ERROR' | 'USER_SIGN' | 'CANCELLED',
|
||||
listener: (...args: any[]) => void
|
||||
) => LoginStatusChecker
|
||||
cancel: () => Promise<void>
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue