Compare commits

...

2 Commits

Author SHA1 Message Date
09de4fd033 feat: outdated version handling 2023-10-05 09:56:25 +02:00
Rainer Killinger
cb196afded feat: communicate outdated app/api client version 2023-10-02 08:48:07 +00:00
46 changed files with 374 additions and 883 deletions

View File

@@ -0,0 +1,5 @@
---
'@openstapps/app': minor
---
Queue config update for next launch to not block app launches

View File

@@ -0,0 +1,5 @@
---
'@openstapps/proxy': minor
---
Send 426 to outdated clients instead of 404

View File

@@ -31,10 +31,11 @@ To Provide your own configuration file you can create a `default.json` file in t
## Status Codes
- OutdatedVersions return a `HTTP 404`
- ActiveVersions return a `HTTP 503` if currently unavailable or the given code by running backend-node
- Unsupported versions (not configured as outdated or active) return a `HTTP 404`
- Successfull reponses come with a `HTTP 200`
- No version header given returns a `HTTP 300`
- ActiveVersions return a `HTTP 503` if currently unavailable or the given code by running backend-node
- OutdatedVersions return a `HTTP 426`
- Unsupported versions (not configured as outdated or active) return a `HTTP 426`
**NOTE:** The default configuration expects the client to set a version header: `X-StApps-Version=<version of app>`

View File

@@ -14,9 +14,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// ESM is not supported, and cts is not detected, so we use type-checked cjs instead.
/** @type {import('../src/common').ConfigFile} */
const configFile = {
module.exports = {
activeVersions: ['1\\.0\\.\\d+', '2\\.0\\.\\d+'],
hiddenRoutes: ['/bulk'],
logFormat: 'default',
@@ -31,5 +30,3 @@ const configFile = {
dhparam: '/etc/nginx/certs/dhparam.pem',
},
};
export default configFile;

View File

@@ -19,14 +19,15 @@ location {{{ route }}} {
return 300 'You have to supply a client/app version via the X-StApps-Version header!';
}
# Version is unsupported or never existed
# Version is unsupported by now or never existed (App/Client has to update)
if ($proxyurl = unsupported) {
{{{ cors }}}
return 404;
return 426;
}
# The version existed, but is outdated now (App should update)
# The version existed, but is outdated now (App/Client should update)
if ($proxyurl = outdated) {
return 404;
{{{ cors }}}
return 426;
}
# The version is correct, but backend is not responding
if ($proxyurl = unavailable) {

View File

@@ -34,10 +34,12 @@
"build": "tsup-node --dts",
"build:docker": "docker build -t openstapps:proxy ../../.deploy/proxy",
"deploy": "pnpm --prod --filter=@openstapps/proxy deploy ../../.deploy/proxy",
"dev": "tsup --watch --onSuccess \"pnpm run start\"",
"format": "prettier . -c --ignore-path ../../.gitignore",
"format:fix": "prettier --write . --ignore-path ../../.gitignore",
"lint": "eslint --ext .ts src/",
"lint:fix": "eslint --fix --ext .ts src/",
"start": "node app.js",
"test": "c8 mocha"
},
"dependencies": {

View File

@@ -37,7 +37,7 @@ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
* Reads the container information from the docker socket and updates the nginx config if necessary
*/
async function updateNginxConfig() {
const containers = await getContainers();
const containers = await getContainers(process.env.DOCKER_SOCKET);
const containerHash = containers
.map((container: ContainerInfo) => {
@@ -78,4 +78,4 @@ async function updateNginxConfig() {
// start the process that checks the docker socket periodically
// eslint-disable-next-line unicorn/prefer-top-level-await
updateNginxConfig();
await updateNginxConfig();

View File

@@ -1,7 +1,7 @@
version: '3.7'
services:
database:
image: registry.gitlab.com/openstapps/openstapps/database:2.0.0
image: registry.gitlab.com/openstapps/openstapps/database:3.0.0-next.4
volumes:
- ./database:/usr/share/elasticsearch/data
expose:
@@ -9,7 +9,7 @@ services:
restart: unless-stopped
backend:
image: registry.gitlab.com/openstapps/openstapps/backend:3.0.0-next.0
image: registry.gitlab.com/openstapps/openstapps/backend:3.0.0-next.4
environment:
ES_ADDR: "http://database:9200"
NODE_CONFIG_ENV: "elasticsearch"
@@ -27,17 +27,17 @@ services:
- database
api:
image: registry.gitlab.com/openstapps/openstapps/api:3.0.0-next.0
image: registry.gitlab.com/openstapps/openstapps/api:3.0.0-next.4
links:
- "backend"
minimal-connector:
image: registry.gitlab.com/openstapps/minimal-connector:core-0.23
image: registry.gitlab.com/openstapps/openstapps/minimal-connector:3.0.0-next.4
container_name: minimal-connector-0.23
command: ["http://backend:3000", "minimal-connector", "f-u"]
app:
image: registry.gitlab.com/openstapps/app/executable:core-0.23
image: registry.gitlab.com/openstapps/openstapps/app:3.0.0-next.4
expose:
- 8100
ports:

View File

@@ -1,19 +0,0 @@
/*
* Copyright (C) 2020 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NGXLogger} from 'ngx-logger';
export let logger: NGXLogger;
export const initLogger = (newLogger: NGXLogger) => (logger = newLogger);

View File

@@ -21,17 +21,13 @@ import {RouteReuseStrategy} from '@angular/router';
import {IonicModule, IonicRouteStrategy, Platform} from '@ionic/angular';
import {TranslateLoader, TranslateModule, TranslateService} from '@ngx-translate/core';
import {TranslateHttpLoader} from '@ngx-translate/http-loader';
import moment from 'moment';
import 'moment/min/locales';
import {LoggerModule, NGXLogger, NgxLoggerLevel} from 'ngx-logger';
import {LoggerModule, NgxLoggerLevel} from 'ngx-logger';
import SwiperCore, {FreeMode, Navigation} from 'swiper';
import {environment} from '../environments/environment';
import {AppRoutingModule} from './app-routing.module';
import {AppComponent} from './app.component';
import {CatalogModule} from './modules/catalog/catalog.module';
import {ConfigModule} from './modules/config/config.module';
import {ConfigProvider} from './modules/config/config.provider';
import {DashboardModule} from './modules/dashboard/dashboard.module';
import {DataModule} from './modules/data/data.module';
import {HebisModule} from './modules/hebis/hebis.module';
@@ -40,11 +36,9 @@ import {MenuModule} from './modules/menu/menu.module';
import {NewsModule} from './modules/news/news.module';
import {ScheduleModule} from './modules/schedule/schedule.module';
import {SettingsModule} from './modules/settings/settings.module';
import {SettingsProvider} from './modules/settings/settings.provider';
import {StorageModule} from './modules/storage/storage.module';
import {ThingTranslateModule} from './translation/thing-translate.module';
import {UtilModule} from './util/util.module';
import {initLogger} from './_helpers/ts-logger';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {AboutModule} from './modules/about/about.module';
import {FavoritesModule} from './modules/favorites/favorites.module';
@@ -54,80 +48,22 @@ import {DebugDataCollectorService} from './modules/data/debug-data-collector.ser
import {AuthModule} from './modules/auth/auth.module';
import {BackgroundModule} from './modules/background/background.module';
import {LibraryModule} from './modules/library/library.module';
import {StorageProvider} from './modules/storage/storage.provider';
import {AssessmentsModule} from './modules/assessments/assessments.module';
import {ServiceHandlerInterceptor} from './_helpers/service-handler.interceptor';
import {RoutingStackService} from './util/routing-stack.service';
import {SCLanguageCode, SCSettingValue} from '@openstapps/core';
import {DefaultAuthService} from './modules/auth/default-auth.service';
import {PAIAAuthService} from './modules/auth/paia/paia-auth.service';
import {IonIconModule} from './util/ion-icon/ion-icon.module';
import {NavigationModule} from './modules/menu/navigation/navigation.module';
import {browserFactory, SimpleBrowser} from './util/browser.factory';
import {getDateFnsLocale} from './translation/dfns-locale';
import {setDefaultOptions} from 'date-fns';
import {DateFnsConfigurationService} from 'ngx-date-fns';
import {ConfigProvider} from './modules/config/config.provider';
import {SettingsProvider} from './modules/settings/settings.provider';
import {TranslateServiceWrapper} from './translation/translate-service-wrapper';
import {DefaultAuthService} from './modules/auth/default-auth.service';
import {PAIAAuthService} from './modules/auth/paia/paia-auth.service';
import {StorageProvider} from './modules/storage/storage.provider';
registerLocaleData(localeDe);
SwiperCore.use([FreeMode, Navigation]);
/**
* Initializes data needed on startup
*/
export function initializerFactory(
storageProvider: StorageProvider,
logger: NGXLogger,
settingsProvider: SettingsProvider,
configProvider: ConfigProvider,
translateService: TranslateService,
_routingStackService: RoutingStackService,
defaultAuthService: DefaultAuthService,
paiaAuthService: PAIAAuthService,
dateFnsConfigurationService: DateFnsConfigurationService,
) {
return async () => {
initLogger(logger);
await storageProvider.init();
await configProvider.init();
await settingsProvider.init();
try {
if (configProvider.firstSession) {
// set language from browser
await settingsProvider.setSettingValue(
'profile',
'language',
translateService.getBrowserLang() as SCSettingValue,
);
}
const languageCode = (await settingsProvider.getValue('profile', 'language')) as string;
// this language will be used as a fallback when a translation isn't found in the current language
translateService.setDefaultLang('en');
translateService.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
await defaultAuthService.init();
await paiaAuthService.init();
} catch (error) {
logger.warn(error);
}
};
}
/**
* TODO
* @param http TODO
*/
export function createTranslateLoader(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
}
/**
* TODO
*/
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
@@ -141,7 +77,6 @@ export function createTranslateLoader(http: HttpClient) {
BrowserAnimationsModule,
CatalogModule,
CommonModule,
ConfigModule,
DashboardModule,
DataModule,
HebisModule,
@@ -165,7 +100,9 @@ export function createTranslateLoader(http: HttpClient) {
loader: {
deps: [HttpClient],
provide: TranslateLoader,
useFactory: createTranslateLoader,
useFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
},
},
}),
UtilModule,
@@ -175,6 +112,30 @@ export function createTranslateLoader(http: HttpClient) {
}),
],
providers: [
{
provide: APP_INITIALIZER,
useFactory:
(...providers: Array<{beforeAppInit(): Promise<void>}>) =>
async () => {
for (const provider of providers) {
await provider.beforeAppInit();
}
},
// Declare initialization (order matters)
deps: [
StorageProvider,
ConfigProvider,
SettingsProvider,
TranslateService,
DefaultAuthService,
PAIAAuthService,
],
multi: true,
},
{
provide: TranslateService,
useClass: TranslateServiceWrapper,
},
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,
@@ -188,22 +149,6 @@ export function createTranslateLoader(http: HttpClient) {
useFactory: browserFactory,
deps: [Platform],
},
{
provide: APP_INITIALIZER,
multi: true,
deps: [
StorageProvider,
NGXLogger,
SettingsProvider,
ConfigProvider,
TranslateService,
RoutingStackService,
DefaultAuthService,
PAIAAuthService,
DateFnsConfigurationService,
],
useFactory: initializerFactory,
},
{
provide: HTTP_INTERCEPTORS,
useClass: ServiceHandlerInterceptor,

View File

@@ -0,0 +1,10 @@
/**
* Services or providers implementing this interface
* must be added to the `APP_INITIALIZER` deps
*/
export interface BeforeAppInit {
/**
* Any logic that has to run before the app is initialized
*/
beforeAppInit(): Promise<void>;
}

View File

@@ -14,10 +14,9 @@
*/
import {Component, OnInit} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {SCAboutPage, SCAppConfiguration} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {SCAboutPage} from '@openstapps/core';
import packageJson from '../../../../../package.json';
import config from 'capacitor.config';
import {ConfigProvider} from '../../config/config.provider';
@Component({
selector: 'about-page',
@@ -27,15 +26,12 @@ import config from 'capacitor.config';
export class AboutPageComponent implements OnInit {
content: SCAboutPage;
appName = config.appName;
version = packageJson.version;
constructor(private readonly route: ActivatedRoute, private readonly configProvider: ConfigProvider) {}
constructor(readonly route: ActivatedRoute, readonly config: ConfigProvider) {}
async ngOnInit() {
ngOnInit() {
const route = this.route.snapshot.url.map(it => it.path).join('/');
this.content =
(this.configProvider.getValue('aboutPages') as SCAppConfiguration['aboutPages'])[route] ?? {};
this.content = this.config.app.aboutPages[route] ?? {};
}
}

View File

@@ -25,7 +25,7 @@
</ion-toolbar>
</ion-header>
<ion-content parallax *ngIf="content">
<ion-text>{{ appName }} v{{ version }}</ion-text>
<ion-text>{{ config.app.name }} v{{ version }}</ion-text>
<div class="page-content">
<about-page-content *ngFor="let element of content.content" [content]="element"></about-page-content>
</div>

View File

@@ -19,7 +19,6 @@ import {FormsModule} from '@angular/forms';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {AboutPageComponent} from './about-page/about-page.component';
import {MarkdownModule} from 'ngx-markdown';
import {AboutPageContentComponent} from './about-page/about-page-content.component';
@@ -64,6 +63,5 @@ const settingsRoutes: Routes = [
ScrollingModule,
UtilModule,
],
providers: [ConfigProvider],
})
export class AboutModule {}

View File

@@ -13,11 +13,12 @@
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {ConfigProvider} from '../config/config.provider';
import {SCAssessment, SCUuid} from '@openstapps/core';
import {DefaultAuthService} from '../auth/default-auth.service';
import {HttpClient} from '@angular/common/http';
import {uniqBy, keyBy} from '@openstapps/collection-utils';
import {firstValueFrom} from 'rxjs';
import {ConfigProvider} from '../config/config.provider';
/**
*
@@ -65,7 +66,7 @@ export class AssessmentsProvider {
cacheMaxAge = 15 * 60 * 1000;
constructor(
readonly configProvider: ConfigProvider,
readonly config: ConfigProvider,
readonly defaultAuth: DefaultAuthService,
readonly http: HttpClient,
) {}
@@ -91,21 +92,20 @@ export class AssessmentsProvider {
return this.cache;
}
const url = this.configProvider.config.app.features.extern?.hisometry.url;
const url = this.config.app.features.extern?.hisometry.url;
if (!url) throw new Error('Config lacks url for hisometry');
this.cache = this.http
.get<{data: SCAssessment[]}>(`${url}/${this.assessmentPath}`, {
this.cache = firstValueFrom(
this.http.get<{data: SCAssessment[]}>(`${url}/${this.assessmentPath}`, {
headers: {
Authorization: `Bearer ${accessToken ?? (await this.defaultAuth.getValidToken()).accessToken}`,
},
})
.toPromise()
.then(it => {
this.cacheTimestamp = Date.now();
}),
).then(it => {
this.cacheTimestamp = Date.now();
return it?.data ?? [];
});
return it?.data ?? [];
});
this.assessments = this.cache.then(toAssessmentMap);
return this.cache;

View File

@@ -12,24 +12,18 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {IPAIAAuthAction} from './paia/paia-auth-action';
import {AuthActions, IAuthAction} from 'ionic-appauth';
import {TranslateService} from '@ngx-translate/core';
import {JSONPath} from 'jsonpath-plus';
import {
SCAuthorizationProvider,
SCAuthorizationProviderType,
SCUserConfiguration,
SCUserConfigurationMap,
} from '@openstapps/core';
import {ConfigProvider} from '../config/config.provider';
import {SCAuthorizationProviderType, SCUserConfiguration, SCUserConfigurationMap} from '@openstapps/core';
import {StorageProvider} from '../storage/storage.provider';
import {DefaultAuthService} from './default-auth.service';
import {PAIAAuthService} from './paia/paia-auth.service';
import {SimpleBrowser} from '../../util/browser.factory';
import {AlertController} from '@ionic/angular';
import {ConfigProvider} from '../config/config.provider';
const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
@@ -37,23 +31,19 @@ const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
providedIn: 'root',
})
export class AuthHelperService {
userConfigurationMap: SCUserConfigurationMap;
get userConfigurationMap(): SCUserConfigurationMap {
return this.config.auth.default!.endpoints.mapping;
}
constructor(
private translateService: TranslateService,
private configProvider: ConfigProvider,
private storageProvider: StorageProvider,
private defaultAuth: DefaultAuthService,
private paiaAuth: PAIAAuthService,
private browser: SimpleBrowser,
private alertController: AlertController,
) {
this.userConfigurationMap = (
this.configProvider.getAnyValue('auth') as {
default: SCAuthorizationProvider;
}
).default.endpoints.mapping;
}
private config: ConfigProvider,
) {}
public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction | IPAIAAuthAction) {
let message: string | undefined;

View File

@@ -175,7 +175,7 @@ export abstract class AuthService implements IAuthService {
public async init() {
this.setupAuthorizationNotifier();
this.loadTokenFromStorage();
await this.loadTokenFromStorage();
this.addActionObserver(this._actionHistory);
this.addActionObserver(this._session);
}

View File

@@ -12,29 +12,26 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AuthorizationRequestHandler,
AuthorizationServiceConfiguration,
JQueryRequestor,
LocalStorageBackend,
Requestor,
StorageBackend,
TokenRequestHandler,
} from '@openid/appauth';
import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth';
import {ConfigProvider} from '../config/config.provider';
import {SCAuthorizationProvider} from '@openstapps/core';
import {AuthActionBuilder, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth';
import {getClientConfig, getEndpointsConfig} from './auth.provider.methods';
import {Injectable} from '@angular/core';
import {AuthService} from './auth.service';
import {ConfigProvider} from '../config/config.provider';
import {BeforeAppInit} from '../../before-app-init';
const TOKEN_RESPONSE_KEY = 'token_response';
@Injectable({
providedIn: 'root',
})
export class DefaultAuthService extends AuthService {
export class DefaultAuthService extends AuthService implements BeforeAppInit {
public localConfiguration: AuthorizationServiceConfiguration;
protected tokenHandler: TokenRequestHandler;
@@ -45,13 +42,17 @@ export class DefaultAuthService extends AuthService {
protected endSessionHandler: EndSessionHandler;
constructor(
protected browser: Browser = new DefaultBrowser(),
protected storage: StorageBackend = new LocalStorageBackend(),
protected requestor: Requestor = new JQueryRequestor(),
private readonly configProvider: ConfigProvider,
) {
super(browser, storage, requestor);
constructor(private config: ConfigProvider) {
super(new DefaultBrowser(), new LocalStorageBackend(), new JQueryRequestor());
}
async beforeAppInit() {
this.authConfig = getClientConfig('default', this.config.auth);
this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('default', this.config.auth),
);
await super.init();
}
get configuration(): Promise<AuthorizationServiceConfiguration> {
@@ -60,22 +61,6 @@ export class DefaultAuthService extends AuthService {
return Promise.resolve(this.localConfiguration);
}
public async init() {
this.setupConfiguration();
this.setupAuthorizationNotifier();
await this.loadTokenFromStorage();
}
setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as {
default: SCAuthorizationProvider;
};
this.authConfig = getClientConfig('default', authConfig);
this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('default', authConfig),
);
}
public async signOut() {
await this.revokeTokens().catch(error => {
this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AuthorizationError,
AuthorizationRequest,
@@ -47,10 +46,10 @@ import {PAIAAuthorizationResponse} from './paia-authorization-response';
import {PAIAAuthorizationNotifier} from './paia-authorization-notifier';
import {PAIATokenResponse} from './paia-token-response';
import {IPAIAAuthAction, PAIAAuthActionBuilder} from './paia-auth-action';
import {SCAuthorizationProvider} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {getClientConfig, getEndpointsConfig} from '../auth.provider.methods';
import {Injectable} from '@angular/core';
import {ConfigProvider} from '../../config/config.provider';
import {BeforeAppInit} from '../../../before-app-init';
const TOKEN_RESPONSE_KEY = 'paia_token_response';
const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 minutes in seconds
@@ -64,10 +63,8 @@ export interface IAuthService {
getValidToken(buffer?: number): Promise<PAIATokenResponse>;
}
@Injectable({
providedIn: 'root',
})
export class PAIAAuthService {
@Injectable({providedIn: 'root'})
export class PAIAAuthService implements BeforeAppInit {
private _authConfig?: IAuthConfig;
private _authSubject: AuthSubject = new AuthSubject();
@@ -97,7 +94,7 @@ export class PAIAAuthService {
protected browser: Browser = new DefaultBrowser(),
protected storage: StorageBackend = new LocalStorageBackend(),
protected requestor: Requestor = new JQueryRequestor(),
private readonly configProvider: ConfigProvider,
private config: ConfigProvider,
) {
this.tokenHandler = new PAIATokenRequestHandler(requestor);
this.userInfoHandler = new IonicUserInfoHandler(requestor);
@@ -110,6 +107,16 @@ export class PAIAAuthService {
this.endSessionHandler = new IonicEndSessionHandler(browser);
}
async beforeAppInit() {
this.authConfig = getClientConfig('paia', this.config.auth);
this.localConfiguration = new AuthorizationServiceConfiguration(
getEndpointsConfig('paia', this.config.auth),
);
this.setupAuthorizationNotifier();
await this.loadTokenFromStorage();
}
get token$(): Observable<PAIATokenResponse | undefined> {
return this._tokenSubject.asObservable();
}
@@ -147,20 +154,6 @@ export class PAIAAuthService {
return Promise.resolve(this.localConfiguration);
}
public async init() {
this.setupConfiguration();
this.setupAuthorizationNotifier();
await this.loadTokenFromStorage();
}
setupConfiguration() {
const authConfig = this.configProvider.getAnyValue('auth') as {
paia: SCAuthorizationProvider;
};
this.authConfig = getClientConfig('paia', authConfig);
this.localConfiguration = new AuthorizationServiceConfiguration(getEndpointsConfig('paia', authConfig));
}
protected notifyActionListers(action: IPAIAAuthAction) {
/* eslint-disable unicorn/no-useless-undefined */
switch (action.action) {

View File

@@ -12,7 +12,6 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Calendar} from '@awesome-cordova-plugins/calendar/ngx';
import {Injectable} from '@angular/core';
import {ICalEvent} from './ical/ical';
@@ -29,18 +28,18 @@ const RECURRENCE_PATTERNS: Partial<Record<unitOfTime.Diff, string | undefined>>
day: 'daily',
};
@Injectable()
@Injectable({providedIn: 'root'})
export class CalendarService {
goToDate = new Subject<number>();
goToDateClicked = this.goToDate.asObservable();
calendarName = 'StApps';
get calendarName(): string {
return this.config.app.name ?? 'StApps';
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor(readonly calendar: Calendar, private readonly configProvider: ConfigProvider) {
this.calendarName = (this.configProvider.getValue('name') as string) ?? 'StApps';
}
constructor(readonly calendar: Calendar, readonly config: ConfigProvider) {}
async createCalendar(): Promise<CalendarInfo | undefined> {
await this.calendar.createCalendar({

View File

@@ -1,27 +0,0 @@
/*
* Copyright (C) 2019 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {NgModule} from '@angular/core';
import {DataModule} from '../data/data.module';
import {StorageModule} from '../storage/storage.module';
import {ConfigProvider} from './config.provider';
/**
* TODO
*/
@NgModule({
imports: [StorageModule, DataModule],
providers: [ConfigProvider],
})
export class ConfigModule {}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-types */
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
@@ -14,19 +15,17 @@
*/
import {Injectable} from '@angular/core';
import {Client} from '@openstapps/api';
import {SCAppConfiguration, SCIndexResponse} from '@openstapps/core';
import packageInfo from '@openstapps/core/package.json';
import {NGXLogger} from 'ngx-logger';
import {environment} from '../../../environments/environment';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
import {StorageProvider} from '../storage/storage.provider';
import {
ConfigFetchError,
ConfigInitError,
ConfigValueNotAvailable,
SavedConfigNotAvailable,
WrongConfigVersionInStorage,
} from './errors';
SCAppConfiguration,
SCAuthorizationProvider,
SCBackendConfiguration,
SCIndexResponse,
} from '@openstapps/core';
import coreInfo from '@openstapps/core/package.json';
import {environment} from '../../../environments/environment';
import {StorageProvider} from '../storage/storage.provider';
import {BeforeAppInit} from '../../before-app-init';
import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
/**
* Key to store config in storage module
@@ -35,145 +34,55 @@ import {
*/
export const STORAGE_KEY_CONFIG = 'stapps.config';
/**
* Provides configuration
*/
@Injectable({
providedIn: 'root',
})
export class ConfigProvider {
/**
* Api client
*/
client: Client;
export class ConfigProvider implements SCIndexResponse, BeforeAppInit {
private client: Client;
/**
* App configuration as IndexResponse
*/
config: SCIndexResponse;
constructor(private storageProvider: StorageProvider, httpClient: StAppsWebHttpClient) {
this.client = new Client(httpClient, environment.backend_url, environment.backend_version);
}
/**
* Version of the @openstapps/core package that app is using
*/
scVersion = packageInfo.version;
async beforeAppInit() {
this.isFirstSession = !(await this.storageProvider.has(STORAGE_KEY_CONFIG));
// Queue config update for next launch; don't block current launch
const configUpdate = this.updateConfig();
console.log('Config update queued');
/**
* First session indicator (config not found in storage)
*/
firstSession = true;
const config = await this.storageProvider
.get<SCIndexResponse>(STORAGE_KEY_CONFIG)
.then(it => it ?? configUpdate);
/**
* Constructor, initialise api client
* @param storageProvider StorageProvider to load persistent configuration
* @param swHttpClient Api client
* @param logger An angular logger
*/
constructor(
private readonly storageProvider: StorageProvider,
swHttpClient: StAppsWebHttpClient,
private readonly logger: NGXLogger,
) {
this.client = new Client(swHttpClient, environment.backend_url, environment.backend_version);
Object.assign(this, config);
console.assert(
this.backend.SCVersion === coreInfo.version,
'Wrong config version in storage.',
'Expected:',
coreInfo.version,
'Actual:',
this.backend.SCVersion,
);
}
/**
* Fetches configuration from backend
* Updates the config from remote
*/
async fetch(): Promise<SCIndexResponse> {
try {
return await this.client.handshake(this.scVersion);
} catch {
throw new ConfigFetchError();
}
}
/**
* Returns the value of an app configuration
* @param attribute requested attribute from app configuration
*/
public getValue(attribute: keyof SCAppConfiguration) {
if (this.config.app[attribute] !== undefined) {
return this.config.app[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/**
* Returns a value of the configuration (not only app configuration)
* @param attribute requested attribute from the configuration
*/
public getAnyValue(attribute: keyof SCIndexResponse) {
if (this.config[attribute] !== undefined) {
return this.config[attribute];
}
throw new ConfigValueNotAvailable(attribute);
}
/**
* Initialises the ConfigProvider
* @throws ConfigInitError if no configuration could be loaded.
* @throws WrongConfigVersionInStorage if fetch failed and saved config has wrong SCVersion
*/
async init(): Promise<void> {
let loadError;
let fetchError;
// load saved configuration
try {
this.config = await this.loadLocal();
this.firstSession = false;
this.logger.log(`initialised configuration from storage`);
if (this.config.backend.SCVersion !== this.scVersion) {
loadError = new WrongConfigVersionInStorage(this.scVersion, this.config.backend.SCVersion);
}
} catch (error) {
loadError = error;
}
// fetch remote configuration from backend
try {
const fetchedConfig: SCIndexResponse = await this.fetch();
await this.set(fetchedConfig);
this.logger.log(`initialised configuration from remote`);
} catch (error) {
fetchError = error;
}
// check for occurred errors and throw them
if (loadError !== undefined && fetchError !== undefined) {
throw new ConfigInitError();
}
if (loadError !== undefined) {
this.logger.warn(loadError);
}
if (fetchError !== undefined) {
this.logger.warn(fetchError);
}
}
/**
* Returns saved configuration from StorageModule
* @throws SavedConfigNotAvailable if no configuration could be loaded
*/
async loadLocal(): Promise<SCIndexResponse> {
// get local configuration
if (await this.storageProvider.has(STORAGE_KEY_CONFIG)) {
return this.storageProvider.get<SCIndexResponse>(STORAGE_KEY_CONFIG);
}
throw new SavedConfigNotAvailable();
}
/**
* Saves the configuration from the provider
* @param config configuration to save
*/
async save(config: SCIndexResponse): Promise<void> {
async updateConfig(): Promise<SCIndexResponse> {
const config = await this.client.handshake(coreInfo.version);
await this.storageProvider.put(STORAGE_KEY_CONFIG, config);
console.log(`Config updated`);
return config;
}
/**
* Sets the configuration in the module and writes it into app storage
* @param config SCIndexResponse to set
*/
async set(config: SCIndexResponse): Promise<void> {
this.config = config;
await this.save(this.config);
}
app: SCAppConfiguration;
auth: {default?: SCAuthorizationProvider | undefined; paia?: SCAuthorizationProvider | undefined};
backend: SCBackendConfiguration;
isFirstSession: boolean;
}

View File

@@ -1,65 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AppError} from '../../_helpers/errors';
/**
* Error that is thrown when fetching from backend fails
*/
export class ConfigFetchError extends AppError {
constructor() {
super('ConfigFetchError', 'App configuration could not be fetched!');
}
}
/**
* Error that is thrown when the ConfigProvider could be initialised
*/
export class ConfigInitError extends AppError {
constructor() {
super('ConfigInitError', 'App configuration could not be initialised!');
}
}
/**
* Error that is thrown when the requested config value is not available
*/
export class ConfigValueNotAvailable extends AppError {
constructor(valueKey: string) {
super('ConfigValueNotAvailable', `No attribute "${valueKey}" in config available!`);
}
}
/**
* Error that is thrown when no saved config is available
*/
export class SavedConfigNotAvailable extends AppError {
constructor() {
super('SavedConfigNotAvailable', 'No saved app configuration available.');
}
}
/**
* Error that is thrown when the SCVersion of the saved config is not compatible with the app
*/
export class WrongConfigVersionInStorage extends AppError {
constructor(correctVersion: string, savedVersion: string) {
super(
'WrongConfigVersionInStorage',
`The saved configs backend version ${savedVersion} ` +
`does not equal the configured backend version ${correctVersion} of the app.`,
);
}
}

View File

@@ -30,9 +30,8 @@ export class OffersInListComponent {
@Input() set offers(it: Array<SCThingThatCanBeOfferedOffer<SCAcademicPriceGroup>>) {
this._offers = it;
this.price = it[0].prices?.default;
this.settingsProvider.getSetting('profile', 'group').then(group => {
this.price = it[0].prices?.[(group.value as string).replace(/s$/, '') as never];
});
const group = this.settingsProvider.getSetting('profile', 'group');
this.price = it[0].prices?.[(group.value as string).replace(/s$/, '') as never];
const availabilities = new Set(it.map(offer => offer.availability));
this.soldOut = availabilities.has('out of stock') && availabilities.size === 1;

View File

@@ -15,16 +15,9 @@
import {Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {Keyboard} from '@capacitor/keyboard';
import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular';
import {AlertController, AnimationController} from '@ionic/angular';
import {Capacitor} from '@capacitor/core';
import {
SCFacet,
SCFeatureConfiguration,
SCSearchFilter,
SCSearchQuery,
SCSearchSort,
SCThings,
} from '@openstapps/core';
import {SCFacet, SCSearchFilter, SCSearchQuery, SCSearchSort, SCThings} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger';
import {combineLatest, Subject} from 'rxjs';
import {debounceTime, distinctUntilChanged, startWith} from 'rxjs/operators';
@@ -33,9 +26,9 @@ import {SettingsProvider} from '../../settings/settings.provider';
import {DataRoutingService} from '../data-routing.service';
import {DataProvider} from '../data.provider';
import {PositionService} from '../../map/position.service';
import {ConfigProvider} from '../../config/config.provider';
import {searchPageSwitchAnimation} from './search-page-switch-animation';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {ConfigProvider} from '../../config/config.provider';
/**
* SearchPageComponent queries things and shows list of things as search results and filter as context menu
@@ -144,21 +137,8 @@ export class SearchPageComponent implements OnInit {
destroy$ = inject(DestroyRef);
routeAnimation: AnimationBuilder;
routeAnimation = searchPageSwitchAnimation(inject(AnimationController));
/**
* Injects the providers and creates subscriptions
* @param alertController AlertController
* @param dataProvider DataProvider
* @param contextMenuService ContextMenuService
* @param settingsProvider SettingsProvider
* @param logger An angular logger
* @param dataRoutingService DataRoutingService
* @param router Router
* @param route ActivatedRoute
* @param positionService PositionService
* @param configProvider ConfigProvider
*/
constructor(
protected readonly alertController: AlertController,
protected dataProvider: DataProvider,
@@ -169,11 +149,8 @@ export class SearchPageComponent implements OnInit {
protected router: Router,
private readonly route: ActivatedRoute,
protected positionService: PositionService,
private readonly configProvider: ConfigProvider,
animationController: AnimationController,
) {
this.routeAnimation = searchPageSwitchAnimation(animationController);
}
private readonly config: ConfigProvider,
) {}
/**
* Fetches items with set query configuration
@@ -347,8 +324,7 @@ export class SearchPageComponent implements OnInit {
});
}
try {
const features = this.configProvider.getValue('features') as SCFeatureConfiguration;
this.isHebisAvailable = !!features.plugins?.['hebis-plugin']?.urlPath;
this.isHebisAvailable = !!this.config.app.features.plugins?.['hebis-plugin']?.urlPath;
} catch (error) {
this.logger.error(error);
}

View File

@@ -62,10 +62,8 @@ export class RatingProvider {
return new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
}
private get userGroup(): Promise<SCUserGroup> {
return this.settingsProvider
.getSetting('profile', 'group')
.then(it => (it as SCUserGroupSetting).value as SCUserGroup);
private get userGroup(): SCUserGroup {
return (this.settingsProvider.getSetting('profile', 'group') as SCUserGroupSetting).value as SCUserGroup;
}
private async getStoredRatings(): Promise<RatingStorage> {

View File

@@ -12,21 +12,13 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {AlertController, AnimationController} from '@ionic/angular';
import {ActivatedRoute, Router} from '@angular/router';
import {NGXLogger} from 'ngx-logger';
import {Component, inject, OnInit} from '@angular/core';
import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators';
import {combineLatest} from 'rxjs';
import {SCThingType} from '@openstapps/core';
import {FavoritesService} from './favorites.service';
import {DataRoutingService} from '../data/data-routing.service';
import {ContextMenuService} from '../menu/context/context-menu.service';
import {SearchPageComponent} from '../data/list/search-page.component';
import {DataProvider} from '../data/data.provider';
import {SettingsProvider} from '../settings/settings.provider';
import {PositionService} from '../map/position.service';
import {ConfigProvider} from '../config/config.provider';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
/**
@@ -42,34 +34,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni
showNavigation = false;
constructor(
alertController: AlertController,
dataProvider: DataProvider,
contextMenuService: ContextMenuService,
settingsProvider: SettingsProvider,
logger: NGXLogger,
dataRoutingService: DataRoutingService,
router: Router,
route: ActivatedRoute,
positionService: PositionService,
private favoritesService: FavoritesService,
configProvider: ConfigProvider,
animationController: AnimationController,
) {
super(
alertController,
dataProvider,
contextMenuService,
settingsProvider,
logger,
dataRoutingService,
router,
route,
positionService,
configProvider,
animationController,
);
}
favoritesService = inject(FavoritesService);
ngOnInit() {
super.ngOnInit(false);

View File

@@ -16,10 +16,9 @@ import {Injectable} from '@angular/core';
import {DaiaAvailabilityResponse, DaiaHolding, DaiaService} from './protocol/response';
import {StorageProvider} from '../storage/storage.provider';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {ConfigProvider} from '../config/config.provider';
import {SCFeatureConfiguration} from '@openstapps/core';
import {NGXLogger} from 'ngx-logger';
import {TranslateService} from '@ngx-translate/core';
import {ConfigProvider} from '../config/config.provider';
/**
* Generated class for the DataProvider provider.
@@ -44,18 +43,10 @@ export class DaiaDataProvider {
clientHeaders = new HttpHeaders();
/**
* TODO
* @param storageProvider TODO
* @param httpClient TODO
* @param configProvider TODO
* @param logger TODO
* @param translateService TODO
*/
constructor(
storageProvider: StorageProvider,
httpClient: HttpClient,
private configProvider: ConfigProvider,
private config: ConfigProvider,
private readonly logger: NGXLogger,
private translateService: TranslateService,
) {
@@ -67,15 +58,14 @@ export class DaiaDataProvider {
async getAvailability(id: string): Promise<DaiaHolding[] | undefined> {
if (this.daiaServiceUrl === undefined) {
try {
const features = this.configProvider.getValue('features') as SCFeatureConfiguration;
if (features.extern?.daia?.url) {
this.daiaServiceUrl = features.extern?.daia?.url;
if (this.config.app.features.extern?.daia?.url) {
this.daiaServiceUrl = this.config.app.features.extern?.daia?.url;
} else {
this.logger.error('Daia service url undefined');
return undefined;
}
if (features.extern?.hebisProxy?.url) {
this.hebisProxyUrl = features.extern?.hebisProxy?.url;
if (this.config.app.features.extern?.hebisProxy?.url) {
this.hebisProxyUrl = this.config.app.features.extern?.hebisProxy?.url;
} else {
this.logger.error('HeBIS proxy url undefined');
return undefined;

View File

@@ -12,22 +12,17 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {JQueryRequestor, Requestor} from '@openid/appauth';
import {
SCAuthorizationProviderType,
SCFeatureConfiguration,
SCFeatureConfigurationExtern,
} from '@openstapps/core';
import {SCAuthorizationProviderType} from '@openstapps/core';
import {DocumentAction, PAIADocument, PAIADocumentStatus, PAIAFees, PAIAItems, PAIAPatron} from '../types';
import {HebisDataProvider} from '../../hebis/hebis-data.provider';
import {PAIATokenResponse} from '../../auth/paia/paia-token-response';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {ConfigProvider} from '../../config/config.provider';
import {TranslateService} from '@ngx-translate/core';
import {AlertController, ToastController} from '@ionic/angular';
import {HebisSearchResponse} from '../../hebis/protocol/response';
import {ConfigProvider} from '../../config/config.provider';
@Injectable({
providedIn: 'root',
@@ -36,29 +31,26 @@ export class LibraryAccountService {
/**
* Base url of the external service
*/
baseUrl: string;
get baseUrl(): string {
return this.config.app.features.extern!['paia'].url;
}
/**
* Authorization provider type
*/
authType: SCAuthorizationProviderType;
get authType(): SCAuthorizationProviderType {
return this.config.app.features.extern!['paia'].authProvider!;
}
constructor(
protected requestor: Requestor = new JQueryRequestor(),
private readonly hebisDataProvider: HebisDataProvider,
private readonly authHelper: AuthHelperService,
readonly configProvider: ConfigProvider,
private readonly translateService: TranslateService,
private readonly alertController: AlertController,
private readonly toastController: ToastController,
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const config: SCFeatureConfigurationExtern = (
configProvider.getValue('features') as SCFeatureConfiguration
).extern!.paia;
this.baseUrl = config.url;
this.authType = config.authProvider as SCAuthorizationProviderType;
}
private readonly config: ConfigProvider,
) {}
async getProfile() {
const patron = ((await this.getValidToken()) as PAIATokenResponse).patron;

View File

@@ -19,9 +19,7 @@ import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {LeafletMarkerClusterModule} from '@asymmetrik/ngx-leaflet-markercluster';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {Polygon} from 'geojson';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {DataFacetsProvider} from '../data/data-facets.provider';
import {DataModule} from '../data/data.module';
import {DataProvider} from '../data/data.provider';
@@ -35,17 +33,6 @@ import {UtilModule} from '../../util/util.module';
import {IonIconModule} from '../../util/ion-icon/ion-icon.module';
import {GeoNavigationDirective} from './geo-navigation.directive';
/**
* Initializes the default area to show in advance (before components are initialized)
* @param configProvider An instance of the ConfigProvider to read the campus polygon from
* @param mapProvider An instance of the MapProvider to set the default polygon (area to show on the map)
*/
export function initMapConfigFactory(configProvider: ConfigProvider, mapProvider: MapProvider) {
return async () => {
mapProvider.defaultPolygon = (await configProvider.getValue('campusPolygon')) as Polygon;
};
}
const mapRoutes: Routes = [
{path: 'map', component: MapPageComponent},
{path: 'map/:uid', component: MapPageComponent},

View File

@@ -21,12 +21,11 @@ import {
SCThingType,
SCUuid,
} from '@openstapps/core';
import {Point, Polygon} from 'geojson';
import {Point} from 'geojson';
import {divIcon, geoJSON, LatLng, Map, marker, Marker} from 'leaflet';
import {DataProvider} from '../data/data.provider';
import {MapPosition, PositionService} from './position.service';
import {hasValidLocation} from '../data/types/place/place-types';
import {ConfigProvider} from '../config/config.provider';
import {SCIcon} from '../../util/ion-icon/icon';
/**
@@ -36,11 +35,6 @@ import {SCIcon} from '../../util/ion-icon/icon';
providedIn: 'root',
})
export class MapProvider {
/**
* Area to show when the map is initialized (shown for the first time)
*/
defaultPolygon: Polygon;
/**
* Provide a point marker for a leaflet map
* @param point Point to get marker for
@@ -111,13 +105,7 @@ export class MapProvider {
clearInterval(interval);
};
constructor(
private dataProvider: DataProvider,
private positionService: PositionService,
private configProvider: ConfigProvider,
) {
this.defaultPolygon = this.configProvider.getValue('campusPolygon') as Polygon;
}
constructor(private dataProvider: DataProvider, private positionService: PositionService) {}
/**
* Provide the specific place by its UID

View File

@@ -30,6 +30,7 @@ import {Capacitor} from '@capacitor/core';
import {pauseWhen} from '../../../util/rxjs/pause-when';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {startViewTransition} from '../../../util/view-transition';
import {ConfigProvider} from '../../config/config.provider';
/**
* The main page of the map
@@ -102,7 +103,7 @@ export class MapPageComponent implements OnInit {
* Options of the leaflet map
*/
options: MapOptions = {
center: geoJSON(this.mapProvider.defaultPolygon).getBounds().getCenter(),
center: geoJSON(inject(ConfigProvider).app.campusPolygon).getBounds().getCenter(),
layers: [
tileLayer('https://osm.server.uni-frankfurt.de/tiles/roads/x={x}&y={y}&z={z}', {
attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors',

View File

@@ -12,75 +12,23 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, OnInit} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {NavigationService} from './navigation.service';
import config from 'capacitor.config';
import {SettingsProvider} from '../../settings/settings.provider';
import {Component, inject} from '@angular/core';
import {BreakpointObserver} from '@angular/cdk/layout';
import {map} from 'rxjs/operators';
import {ConfigProvider} from '../../config/config.provider';
/**
* Generated class for the MenuPage page.
*
* See https://ionicframework.com/docs/components/#navigation for more info on
* Ionic pages and navigation.
*/
@Component({
selector: 'stapps-navigation',
styleUrls: ['navigation.scss'],
templateUrl: 'navigation.html',
})
export class NavigationComponent implements OnInit {
showTabbar = true;
export class NavigationComponent {
/**
* Name of the app
* TODO: What was this for???
*/
appName = config.appName;
showTabBar$ = inject(BreakpointObserver)
.observe(['(min-width: 768px)'])
.pipe(map(({matches}) => !matches));
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
/**
* Menu entries from config module
*/
menu: SCAppConfigurationMenuCategory[];
/**
* Core translator
*/
translator: SCThingTranslator;
constructor(
public translateService: TranslateService,
private navigationService: NavigationService,
private settingsProvider: SettingsProvider,
private responsive: BreakpointObserver,
) {
translateService.onLangChange.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
this.responsive.observe(['(min-width: 768px)']).subscribe(result => {
this.showTabbar = !result.matches;
});
}
async ngOnInit() {
this.language = (await this.settingsProvider.getValue(
'profile',
'language',
)) as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
this.menu = await this.navigationService.getMenu();
}
constructor(readonly config: ConfigProvider) {}
}

View File

@@ -24,8 +24,8 @@
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content *ngIf="menu">
<ion-list *ngFor="let category of menu; first as isFirst">
<ion-content *ngIf="config.app.menus">
<ion-list *ngFor="let category of config.app.menus; first as isFirst">
<ion-item
*ngIf="category.title !== ''"
[rootLink]="category.route"
@@ -34,11 +34,11 @@
class="menu-category"
>
<ion-icon slot="end" [name]="category.icon"></ion-icon>
<ion-label> {{ category.translations[language].title | titlecase }} </ion-label>
<ion-label> {{ 'title' | translateSimple: category | titlecase }} </ion-label>
</ion-item>
<ion-item *ngFor="let item of category.items" [rootLink]="item.route" [redirectedFrom]="item.route">
<ion-icon slot="end" [name]="item.icon"></ion-icon>
<ion-label> {{ item.translations[language].title | titlecase }} </ion-label>
<ion-label> {{ 'title' | translateSimple: item | titlecase }} </ion-label>
</ion-item>
</ion-list>
</ion-content>

View File

@@ -22,10 +22,11 @@ import {IonIconModule} from '../../../util/ion-icon/ion-icon.module';
import {TranslateModule} from '@ngx-translate/core';
import {RouterModule} from '@angular/router';
import {OfflineNoticeComponent} from './offline-notice.component';
import {ThingTranslateModule} from '../../../translation/thing-translate.module';
@NgModule({
declarations: [RootLinkDirective, NavigationComponent, TabsComponent, OfflineNoticeComponent],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule],
imports: [CommonModule, IonicModule, IonIconModule, TranslateModule, RouterModule, ThingTranslateModule],
exports: [TabsComponent, RootLinkDirective, NavigationComponent],
})
export class NavigationModule {}

View File

@@ -1,37 +0,0 @@
/*
* Copyright (C) 2022 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Injectable} from '@angular/core';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {NGXLogger} from 'ngx-logger';
@Injectable({
providedIn: 'root',
})
export class NavigationService {
constructor(private configProvider: ConfigProvider, private logger: NGXLogger) {}
async getMenu() {
let menu: SCAppConfigurationMenuCategory[] = [];
try {
menu = this.configProvider.getValue('menus') as SCAppConfigurationMenuCategory[];
} catch (error) {
this.logger.error(`error from loading menu entries: ${error}`);
}
return menu;
}
}

View File

@@ -12,93 +12,44 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router';
import {
SCAppConfigurationMenuCategory,
SCLanguage,
SCThingTranslator,
SCTranslations,
} from '@openstapps/core';
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {Event, NavigationEnd, Router} from '@angular/router';
import {SCAppConfigurationMenuCategory} from '@openstapps/core';
import {ConfigProvider} from '../../config/config.provider';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import {NGXLogger} from 'ngx-logger';
import {filter} from 'rxjs';
import {map, startWith} from 'rxjs/operators';
/**
* Finds a tab name based on urls
*/
function findTabFromUrl(url: string, menus: SCAppConfigurationMenuCategory[]): string {
if (url === '/') {
return menus[0]?.title ?? '';
}
return menus.find(category => url.includes(category.route))?.title ?? '';
}
/**
* Type guard for event
*/
function isNavigationEnd(event: Event): event is NavigationEnd {
return event instanceof NavigationEnd;
}
@Component({
selector: 'stapps-navigation-tabs',
templateUrl: 'tabs.template.html',
styleUrls: ['./tabs.component.scss'],
templateUrl: 'tabs.html',
styleUrls: ['./tabs.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TabsComponent {
/**
* Possible languages to be used for translation
*/
language: keyof SCTranslations<SCLanguage>;
menus = inject(ConfigProvider).app.menus.slice(0, 5);
/**
* Menu entries from config module
*/
menu: SCAppConfigurationMenuCategory[];
selectedTab$ = this.router.events.pipe(
filter(isNavigationEnd),
map(event => findTabFromUrl(event.url, this.menus)),
startWith(findTabFromUrl(this.router.url, this.menus)),
);
/**
* Core translator
*/
translator: SCThingTranslator;
/**
* Name of selected tab
*/
selectedTab: string;
constructor(
private readonly configProvider: ConfigProvider,
public translateService: TranslateService,
private readonly logger: NGXLogger,
private readonly router: Router,
) {
this.language = this.translateService.currentLang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
void this.loadMenuEntries();
this.router.events.subscribe((event: unknown) => {
if (event instanceof NavigationEnd) {
this.selectTab(event.url);
}
});
this.selectTab(router.url);
translateService.onLangChange?.subscribe((event: LangChangeEvent) => {
this.language = event.lang as keyof SCTranslations<SCLanguage>;
this.translator = new SCThingTranslator(this.language);
});
}
/**
* Loads menu entries from configProvider
*/
async loadMenuEntries() {
try {
const menus = (await this.configProvider.getValue('menus')) as SCAppConfigurationMenuCategory[];
const menu = menus.slice(0, 5);
if (menu) {
this.menu = menu;
}
} catch (error) {
this.logger.error(`error from loading menu entries: ${error}`);
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
selectTab(url: string) {
if (!this.menu) {
return;
}
if (url === '/') {
this.selectedTab = (this.menu[0] as any)?.title ?? '';
return;
}
const candidate = this.menu.slice(0, 5).find(category => url.includes(category.route));
this.selectedTab = candidate?.title ?? '';
}
constructor(private readonly router: Router) {}
}

View File

@@ -33,19 +33,19 @@
</ion-tab-bar>
-->
<ion-tab-bar slot="bottom" [selectedTab]="selectedTab">
<ion-tab-bar slot="bottom" [selectedTab]="selectedTab$ | async">
<ion-menu-toggle>
<ion-tab-button class="menu-button">
<ion-icon name="menu"></ion-icon>
</ion-tab-button>
</ion-menu-toggle>
<ion-tab-button
*ngFor="let category of menu; first as isFirst"
*ngFor="let category of menus; first as isFirst"
[rootLink]="category.route"
[redirectedFrom]="category.route"
[tab]="category.title"
>
<ion-icon [name]="category.icon"></ion-icon>
<ion-label>{{ category.translations[language].title | titlecase }}</ion-label>
<ion-label>{{ 'title' | translateSimple: category | titlecase }}</ion-label>
</ion-tab-button>
</ion-tab-bar>

View File

@@ -18,9 +18,7 @@ import {FormsModule} from '@angular/forms';
import {RouterModule, Routes} from '@angular/router';
import {IonicModule} from '@ionic/angular';
import {TranslateModule} from '@ngx-translate/core';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {ConfigProvider} from '../config/config.provider';
import {SettingsItemComponent} from './item/settings-item.component';
import {SettingsPageComponent} from './page/settings-page.component';
import {SettingTranslatePipe} from './setting-translate.pipe';
@@ -60,13 +58,6 @@ const settingsRoutes: Routes = [{path: 'settings', component: SettingsPageCompon
RouterModule.forChild(settingsRoutes),
UtilModule,
],
providers: [
ScheduleSyncService,
ConfigProvider,
SettingsProvider,
CalendarService,
ScheduleProvider,
ThingTranslatePipe,
],
providers: [ScheduleSyncService, SettingsProvider, CalendarService, ScheduleProvider, ThingTranslatePipe],
})
export class SettingsModule {}

View File

@@ -16,8 +16,9 @@ import {Injectable} from '@angular/core';
import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core';
import deepMerge from 'deepmerge';
import {Subject} from 'rxjs';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';
import {ConfigProvider} from '../config/config.provider';
import {BeforeAppInit} from '../../before-app-init';
export const STORAGE_KEY_SETTINGS = 'settings';
export const STORAGE_KEY_SETTINGS_SEPARATOR = '.';
@@ -89,19 +90,19 @@ export interface SettingsAction {
/**
* Provider for app settings
*/
@Injectable()
export class SettingsProvider {
@Injectable({
providedIn: 'root',
})
export class SettingsProvider implements BeforeAppInit {
/**
* Source of settings actions
*/
private settingsActionSource = new Subject<SettingsAction>();
private needsInit = true;
/**
* Order of the setting categories
*/
categoriesOrder: string[];
categoriesOrder: string[] = [];
/**
* Settings actions observable
@@ -111,7 +112,45 @@ export class SettingsProvider {
/**
* Cache for the imported settings
*/
settingsCache: SettingsCache;
settingsCache: SettingsCache = {};
constructor(private storageProvider: StorageProvider, private config: ConfigProvider) {}
async beforeAppInit() {
try {
for (const setting of this.config.app.settings) {
this.addSetting(setting);
}
for (const category of Object.keys(this.settingsCache)) {
if (!this.categoriesOrder.includes(category)) {
this.categoriesOrder.push(category);
}
}
} catch {
this.settingsCache = {};
}
if (await this.storageProvider.has(STORAGE_KEY_SETTING_VALUES)) {
// get setting values from StorageProvider into settingsCache
const valuesContainer: SettingValuesContainer = await this.storageProvider.get<SettingValuesContainer>(
STORAGE_KEY_SETTING_VALUES,
);
// iterate through keys of categories
for (const categoryKey of Object.keys(this.settingsCache)) {
// iterate through setting keys of category
for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
// if saved setting value exists set it, otherwise set to default value
this.settingsCache[categoryKey].settings[settingKey].value =
valuesContainer[categoryKey] !== undefined &&
valuesContainer[categoryKey][settingKey] !== undefined
? valuesContainer[categoryKey][settingKey]
: this.settingsCache[categoryKey].settings[settingKey].defaultValue;
}
}
await this.saveSettingValues();
}
}
/**
* Return true if all given values are valid to possible values in given settingInput
@@ -192,21 +231,11 @@ export class SettingsProvider {
return isValueValid;
}
/**
*
* @param storage TODO
* @param configProvider TODO
*/
constructor(private readonly storage: StorageProvider, private readonly configProvider: ConfigProvider) {
this.categoriesOrder = [];
this.settingsCache = {};
}
/**
* Add an Setting to the Cache if not exist and set undefined value to defaultValue
* @param setting Setting with categories, defaultValue, name, input type and valid values
*/
private addSetting(setting: SCSetting): void {
addSetting(setting: SCSetting): void {
if (!this.categoryExists(setting.categories[0])) {
this.provideCategory(setting.categories[0]);
}
@@ -265,9 +294,7 @@ export class SettingsProvider {
/**
* Returns copy of cached settings
*/
public async getCache(): Promise<SettingsCache> {
await this.init();
public getCache(): SettingsCache {
return JSON.parse(JSON.stringify(this.settingsCache));
}
@@ -284,8 +311,7 @@ export class SettingsProvider {
* @param name the name of requested setting
* @throws Exception if setting is not provided
*/
public async getSetting(category: string, name: string): Promise<SCSetting> {
await this.init();
public getSetting(category: string, name: string): SCSetting {
if (this.settingExists(category, name)) {
// return a copy of the settings
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name]));
@@ -299,8 +325,7 @@ export class SettingsProvider {
* @param name the name of requested setting
* @throws Exception if setting is not provided
*/
public async getValue(category: string, name: string): Promise<SCSettingValue | SCSettingValues> {
await this.init();
public getValue(category: string, name: string): SCSettingValue | SCSettingValues {
if (this.settingExists(category, name)) {
// return a copy of the settings value
return JSON.parse(JSON.stringify(this.settingsCache[category].settings[name].value));
@@ -308,47 +333,6 @@ export class SettingsProvider {
throw new Error(`Setting "${name}" not provided`);
}
/**
* Initializes settings from config and stored values if exist
*/
public async init(): Promise<void> {
if (!this.needsInit) return;
this.needsInit = false;
try {
const settings: SCSetting[] = this.configProvider.getValue('settings') as SCSetting[];
for (const setting of settings) this.addSetting(setting);
for (const category of Object.keys(this.settingsCache)) {
if (!this.categoriesOrder.includes(category)) {
this.categoriesOrder.push(category);
}
}
} catch {
this.settingsCache = {};
}
if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
// get setting values from StorageProvider into settingsCache
const valuesContainer: SettingValuesContainer = await this.storage.get<SettingValuesContainer>(
STORAGE_KEY_SETTING_VALUES,
);
// iterate through keys of categories
for (const categoryKey of Object.keys(this.settingsCache)) {
// iterate through setting keys of category
for (const settingKey of Object.keys(this.settingsCache[categoryKey].settings)) {
// if saved setting value exists set it, otherwise set to default value
this.settingsCache[categoryKey].settings[settingKey].value =
valuesContainer[categoryKey] !== undefined &&
valuesContainer[categoryKey][settingKey] !== undefined
? valuesContainer[categoryKey][settingKey]
: this.settingsCache[categoryKey].settings[settingKey].defaultValue;
}
}
await this.saveSettingValues();
}
}
/**
* Adds given setting and its category if not exist
* @param setting the setting to add
@@ -358,12 +342,10 @@ export class SettingsProvider {
}
/**
* Deletes saved values and reinitialising the settings
* Delete saved values and reinitialize the settings
*/
public async reset(): Promise<void> {
await this.storage.put(STORAGE_KEY_SETTING_VALUES, {});
this.needsInit = true;
await this.init();
await this.storageProvider.put(STORAGE_KEY_SETTING_VALUES, {});
}
/**
@@ -383,15 +365,14 @@ export class SettingsProvider {
* Saves cached settings in app storage
*/
public async saveSettingValues(): Promise<void> {
if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) {
const savedSettingsValues: SettingValuesContainer = await this.storage.get<SettingValuesContainer>(
STORAGE_KEY_SETTING_VALUES,
);
if (await this.storageProvider.has(STORAGE_KEY_SETTING_VALUES)) {
const savedSettingsValues: SettingValuesContainer =
await this.storageProvider.get<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES);
const cacheSettingsValues = this.getSettingValuesFromCache();
const mergedSettingValues = deepMerge(savedSettingsValues, cacheSettingsValues);
await this.storage.put<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES, mergedSettingValues);
await this.storageProvider.put<SettingValuesContainer>(STORAGE_KEY_SETTING_VALUES, mergedSettingValues);
} else {
await this.storage.put<SettingValuesContainer>(
await this.storageProvider.put<SettingValuesContainer>(
STORAGE_KEY_SETTING_VALUES,
this.getSettingValuesFromCache(),
);
@@ -418,7 +399,6 @@ export class SettingsProvider {
name: string,
value: SCSettingValue | SCSettingValues,
): Promise<void> {
await this.init();
if (this.settingExists(category, name)) {
const setting: SCSetting = this.settingsCache[category].settings[name];
const isValueValid = SettingsProvider.validateValue(setting, value);

View File

@@ -14,17 +14,22 @@
*/
import {Injectable} from '@angular/core';
import {Storage} from '@ionic/storage-angular';
import {BeforeAppInit} from '../../before-app-init';
/**
* Provides interaction with the (ionic) storage on the device (in the browser)
*/
@Injectable()
export class StorageProvider {
@Injectable({providedIn: 'root'})
export class StorageProvider implements BeforeAppInit {
/**
* @param storage TODO
*/
constructor(private readonly storage: Storage) {}
async beforeAppInit() {
await this.storage.create();
}
/**
* Deletes storage entries using keys used to save them
* @param keys Unique identifiers of the resources for deletion
@@ -95,13 +100,6 @@ export class StorageProvider {
return (await this.storage.keys()).includes(key);
}
/**
* Initializes the storage (waits until it's ready)
*/
async init(): Promise<void> {
await this.storage.create();
}
/**
* Provides information if storage is empty or not
*/

View File

@@ -16,7 +16,6 @@ import {Injectable, OnDestroy, Pipe, PipeTransform} from '@angular/core';
import {LangChangeEvent, TranslateService} from '@ngx-translate/core';
import moment from 'moment';
import {Subscription} from 'rxjs';
import {logger} from '../_helpers/ts-logger';
@Injectable()
@Pipe({
@@ -163,7 +162,7 @@ export class DurationLocalizedPipe implements PipeTransform, OnDestroy {
updateValue(value: string | unknown, isFrequency = false): void {
if (typeof value !== 'string') {
logger.warn(`durationLocalized pipe unable to parse input: ${value}`);
console.warn('durationLocalized pipe unable to parse input', value);
return;
}
@@ -223,7 +222,7 @@ export class MetersLocalizedPipe implements PipeTransform, OnDestroy {
updateValue(value: string | number | unknown) {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`metersLocalized pipe unable to parse input: ${value}`);
console.warn('metersLocalized pipe unable to parse input:', value);
return;
}
@@ -321,7 +320,7 @@ export class NumberLocalizedPipe implements PipeTransform, OnDestroy {
updateValue(value: string | number | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && typeof value !== 'number') {
logger.warn(`numberLocalized pipe unable to parse input: ${value}`);
console.warn('numberLocalized pipe unable to parse input:', value);
return;
}
@@ -387,7 +386,7 @@ export class DateLocalizedFormatPipe implements PipeTransform, OnDestroy {
updateValue(value: string | Date | unknown, formatOptions?: string): void {
if (typeof value !== 'string' && Object.prototype.toString.call(value) !== '[object Date]') {
logger.warn(`dateFormat pipe unable to parse input: ${value}`);
console.warn('dateFormat pipe unable to parse input:', value);
return;
}

View File

@@ -0,0 +1,38 @@
import {SCLanguageCode, SCSettingValue} from '@openstapps/core';
import moment from 'moment/moment';
import {getDateFnsLocale} from './dfns-locale';
import {setDefaultOptions} from 'date-fns';
import {SettingsProvider} from '../modules/settings/settings.provider';
import {TranslateService} from '@ngx-translate/core';
import {DateFnsConfigurationService} from 'ngx-date-fns';
import {ConfigProvider} from '../modules/config/config.provider';
import {BeforeAppInit} from '../before-app-init';
import {inject, Injectable} from '@angular/core';
@Injectable({providedIn: 'root'})
export class TranslateServiceWrapper extends TranslateService implements BeforeAppInit {
private config = inject(ConfigProvider);
private settingsProvider = inject(SettingsProvider);
private dateFnsConfigurationService = inject(DateFnsConfigurationService);
async beforeAppInit() {
if (this.config.isFirstSession) {
// set language from browser
await this.settingsProvider.setSettingValue(
'profile',
'language',
this.getBrowserLang() as SCSettingValue,
);
}
const languageCode = this.settingsProvider.getValue('profile', 'language') as string;
// this language will be used as a fallback when a translation isn't found in the current language
this.setDefaultLang('en');
this.use(languageCode);
moment.locale(languageCode);
const dateFnsLocale = await getDateFnsLocale(languageCode as SCLanguageCode);
setDefaultOptions({locale: dateFnsLocale});
this.dateFnsConfigurationService.setLocale(dateFnsLocale);
}
}

View File

@@ -21,6 +21,7 @@ import {
race,
RetryConfig,
share,
skip,
Subject,
takeUntil,
} from 'rxjs';
@@ -61,7 +62,7 @@ export class InternetConnectionService {
private doRetry(error: unknown, retryCount: number): ObservableInput<unknown> {
return race(
this.offline$.pipe(
tap(it => console.log(it)),
skip(1),
filter(it => !it),
take(1),
delay(Math.min(retryCount ** 4 + 100, 10_000)),

View File

@@ -21,7 +21,7 @@ export const environment = {
backend_url: 'https://mobile.server.uni-frankfurt.de',
app_host: 'mobile.app.uni-frankfurt.de',
custom_url_scheme: 'de.anyschool.app',
backend_version: '4.0.0',
backend_version: '0.0.0',
production: false,
};