From eda093188945f37ef1079d5364441219914a7697 Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Sun, 15 Dec 2024 12:58:18 -0800 Subject: [PATCH 01/23] refactor: auth service and firebase auth provider --- packages/data-models/deployment.model.ts | 13 ++-- .../scripts/src/tasks/providers/appData.ts | 3 +- src/app/deployment-features.module.ts | 3 +- .../instance/template-action.service.ts | 5 -- src/app/shared/services/auth/auth.module.ts | 14 ++++ src/app/shared/services/auth/auth.service.ts | 78 ++++++++----------- .../services/auth/providers/base.auth.ts | 16 ++++ .../services/auth/providers/firebase.auth.ts | 45 +++++++++++ .../shared/services/auth/providers/index.ts | 17 ++++ src/app/shared/services/auth/types.ts | 7 ++ 10 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 src/app/shared/services/auth/auth.module.ts create mode 100644 src/app/shared/services/auth/providers/base.auth.ts create mode 100644 src/app/shared/services/auth/providers/firebase.auth.ts create mode 100644 src/app/shared/services/auth/providers/index.ts create mode 100644 src/app/shared/services/auth/types.ts diff --git a/packages/data-models/deployment.model.ts b/packages/data-models/deployment.model.ts index f884873b8b..2e518e0de8 100644 --- a/packages/data-models/deployment.model.ts +++ b/packages/data-models/deployment.model.ts @@ -3,7 +3,7 @@ import type { IGdriveEntry } from "../@idemsInternational/gdrive-tools"; import type { IAppConfig, IAppConfigOverride } from "./appConfig"; /** Update version to force recompile next time deployment set (e.g. after default config update) */ -export const DEPLOYMENT_CONFIG_VERSION = 20241111.0; +export const DEPLOYMENT_CONFIG_VERSION = 20241215.1; /** Configuration settings available to runtime application */ export interface IDeploymentRuntimeConfig { @@ -36,6 +36,11 @@ export interface IDeploymentRuntimeConfig { /** sentry/glitchtip logging dsn */ dsn: string; }; + /** Enable auth actions by specifying auth provider */ + auth: { + /** provider to use with authentication actions. actions will be disabled if no provider specified */ + provider?: "firebase" | "supabase"; + }; /** * Specify if using firebase for auth and crashlytics. * Requires firebase config available through encrypted config */ @@ -51,10 +56,6 @@ export interface IDeploymentRuntimeConfig { appId: string; measurementId: string; }; - auth: { - /** Enables `auth` actions to allow user sign-in/out */ - enabled: boolean; - }; crashlytics: { /** Enables app crash reports to firebase crashlytics */ enabled: boolean; @@ -200,9 +201,9 @@ export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = { endpoint: "https://apps-server.idems.international/analytics", }, app_config: {}, + auth: {}, firebase: { config: null, - auth: { enabled: false }, crashlytics: { enabled: true }, }, supabase: { diff --git a/packages/scripts/src/tasks/providers/appData.ts b/packages/scripts/src/tasks/providers/appData.ts index 94e9277c1c..3e4ba0b09c 100644 --- a/packages/scripts/src/tasks/providers/appData.ts +++ b/packages/scripts/src/tasks/providers/appData.ts @@ -68,7 +68,7 @@ const copyDeploymentDataToApp = async () => { const optimiseBuild = async () => new AppDataOptimiser(WorkflowRunner.config).run(); function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploymentRuntimeConfig { - const { analytics, api, app_config, error_logging, firebase, git, name, supabase, web } = + const { analytics, api, app_config, auth, error_logging, firebase, git, name, supabase, web } = deploymentConfig; return { @@ -77,6 +77,7 @@ function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploy analytics, api, app_config, + auth, error_logging, firebase, name, diff --git a/src/app/deployment-features.module.ts b/src/app/deployment-features.module.ts index e5c44bfdb8..1353224e7b 100644 --- a/src/app/deployment-features.module.ts +++ b/src/app/deployment-features.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { AnalyticsModule } from "./shared/services/analytics"; import { NavStackModule } from "./feature/nav-stack/nav-stack.module"; +import { AuthModule } from "./shared/services/auth/auth.module"; /** * Module imports required for specific deployment features @@ -14,5 +15,5 @@ import { NavStackModule } from "./feature/nav-stack/nav-stack.module"; * * This is a feature marked for future implementation */ -@NgModule({ imports: [AnalyticsModule, NavStackModule] }) +@NgModule({ imports: [AnalyticsModule, NavStackModule, AuthModule] }) export class DeploymentFeaturesModule {} diff --git a/src/app/shared/components/template/services/instance/template-action.service.ts b/src/app/shared/components/template/services/instance/template-action.service.ts index 7f8b8231d5..7e7b40f338 100644 --- a/src/app/shared/components/template/services/instance/template-action.service.ts +++ b/src/app/shared/components/template/services/instance/template-action.service.ts @@ -12,7 +12,6 @@ import { TemplateService } from "../template.service"; import { TemplateTranslateService } from "../template-translate.service"; import { EventService } from "src/app/shared/services/event/event.service"; import { DBSyncService } from "src/app/shared/services/db/db-sync.service"; -import { AuthService } from "src/app/shared/services/auth/auth.service"; import { SkinService } from "src/app/shared/services/skin/skin.service"; import { ThemeService } from "src/app/feature/theme/services/theme.service"; import { getGlobalService } from "src/app/shared/services/global.service"; @@ -65,9 +64,6 @@ export class TemplateActionService extends SyncServiceBase { private get dbSyncService() { return getGlobalService(this.injector, DBSyncService); } - private get authService() { - return getGlobalService(this.injector, AuthService); - } private get skinService() { return getGlobalService(this.injector, SkinService); } @@ -93,7 +89,6 @@ export class TemplateActionService extends SyncServiceBase { this.analyticsService, this.templateService, this.eventService, - this.authService, this.skinService, ]); } diff --git a/src/app/shared/services/auth/auth.module.ts b/src/app/shared/services/auth/auth.module.ts new file mode 100644 index 0000000000..011a4b1f8e --- /dev/null +++ b/src/app/shared/services/auth/auth.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from "@angular/core"; + +import { AuthService } from "./auth.service"; + +@NgModule({ + imports: [], + exports: [], + providers: [], +}) +export class AuthModule { + constructor(private service: AuthService) { + // include service to initialise and register handlers + } +} diff --git a/src/app/shared/services/auth/auth.service.ts b/src/app/shared/services/auth/auth.service.ts index affb1e16f9..7b63603342 100644 --- a/src/app/shared/services/auth/auth.service.ts +++ b/src/app/shared/services/auth/auth.service.ts @@ -1,53 +1,48 @@ -import { Injectable } from "@angular/core"; -import { FirebaseAuthentication, User } from "@capacitor-firebase/authentication"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { filter } from "rxjs/operators"; -import { SyncServiceBase } from "../syncService.base"; +import { effect, Injectable, Injector, signal } from "@angular/core"; import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry"; -import { FirebaseService } from "../firebase/firebase.service"; import { LocalStorageService } from "../local-storage/local-storage.service"; import { DeploymentService } from "../deployment/deployment.service"; +import { AuthProviderBase } from "./providers/base.auth"; +import { AsyncServiceBase } from "../asyncService.base"; +import { getAuthProvider } from "./providers"; +import { IAuthUser } from "./types"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { filter, firstValueFrom } from "rxjs"; @Injectable({ providedIn: "root", }) -export class AuthService extends SyncServiceBase { - private authUser$ = new BehaviorSubject(null); +export class AuthService extends AsyncServiceBase { + public authUser = signal(null); + + /** Auth provider used */ + private provider: AuthProviderBase; - // include auth import to ensure app registered constructor( private templateActionRegistry: TemplateActionRegistry, - private firebaseService: FirebaseService, private localStorageService: LocalStorageService, - private deploymentService: DeploymentService + private deploymentService: DeploymentService, + private injector: Injector ) { super("Auth"); - this.initialise(); - } - private initialise() { - const { firebase } = this.deploymentService.config; - if (firebase?.auth?.enabled && this.firebaseService.app) { - this.addAuthListeners(); - this.registerTemplateActionHandlers(); - } - } - - /** Return a promise that resolves after a signed in user defined */ - public async waitForSignInComplete() { - return firstValueFrom(this.authUser$.pipe(filter((value?: User | null) => !!value))); - } - - public async signInWithGoogle() { - return FirebaseAuthentication.signInWithGoogle(); + this.provider = getAuthProvider(this.deploymentService.config.auth?.provider); + this.registerInitFunction(this.initialise); + effect(async () => { + const authUser = this.provider.authUser(); + console.log("[Auth User]", authUser); + this.addStorageEntry(authUser); + }); } - public async signOut() { - return FirebaseAuthentication.signOut(); + /** Return a promise that resolves only after a signed in user defined */ + public async waitForUserSignedIn() { + const authUser$ = toObservable(this.authUser); + return firstValueFrom(authUser$.pipe(filter((value: IAuthUser | null) => !!value))); } - public async getCurrentUser() { - const { user } = await FirebaseAuthentication.getCurrentUser(); - return user; + private async initialise() { + await this.provider.initialise(this.injector); + this.registerTemplateActionHandlers(); } private registerTemplateActionHandlers() { @@ -55,8 +50,8 @@ export class AuthService extends SyncServiceBase { auth: async ({ args }) => { const [actionId] = args; const childActions = { - sign_in_google: async () => await this.signInWithGoogle(), - sign_out: async () => await this.signOut(), + sign_in_google: async () => await this.provider.signInWithGoogle(), + sign_out: async () => await this.provider.signOut(), }; if (!(actionId in childActions)) { console.error(`[AUTH] - No action, "${actionId}"`); @@ -69,22 +64,13 @@ export class AuthService extends SyncServiceBase { * Use `auth: sign_in_google` instead * */ google_auth: async () => { - return await this.signInWithGoogle(); + return await this.provider.signInWithGoogle(); }, }); } - /** Listen to auth state changes and update local subject accordingly */ - private addAuthListeners() { - FirebaseAuthentication.addListener("authStateChange", ({ user }) => { - // console.log("[User] updated", user); - this.addStorageEntry(user); - this.authUser$.next(user); - }); - } - /** Keep a subset of auth user info in contact fields for db lookup*/ - private addStorageEntry(user?: User) { + private addStorageEntry(user?: IAuthUser) { if (user) { const { uid } = user; this.localStorageService.setProtected("APP_AUTH_USER", JSON.stringify({ uid })); diff --git a/src/app/shared/services/auth/providers/base.auth.ts b/src/app/shared/services/auth/providers/base.auth.ts new file mode 100644 index 0000000000..1751c0333f --- /dev/null +++ b/src/app/shared/services/auth/providers/base.auth.ts @@ -0,0 +1,16 @@ +import { Injector, signal } from "@angular/core"; +import { IAuthUser } from "../types"; + +export class AuthProviderBase { + public authUser = signal(null); + + public async initialise(injector: Injector) {} + + public async signInWithGoogle() { + return this.authUser(); + } + + public async signOut() { + return this.authUser(); + } +} diff --git a/src/app/shared/services/auth/providers/firebase.auth.ts b/src/app/shared/services/auth/providers/firebase.auth.ts new file mode 100644 index 0000000000..b7d67c913f --- /dev/null +++ b/src/app/shared/services/auth/providers/firebase.auth.ts @@ -0,0 +1,45 @@ +import { Injectable, Injector } from "@angular/core"; +import { FirebaseAuthentication, User } from "@capacitor-firebase/authentication"; +import { FirebaseService } from "../../firebase/firebase.service"; +import { AuthProviderBase } from "./base.auth"; + +@Injectable({ + providedIn: "root", +}) +export class FirebaseAuthProvider extends AuthProviderBase { + public override async initialise(injector: Injector) { + const firebaseService = injector.get(FirebaseService); + // TODO - is service required here? + if (!firebaseService.app) { + throw new Error("[Firebase Auth] app not configured"); + } + this.addAuthListeners(); + // ensure any previous signed in user is loaded + await FirebaseAuthentication.getCurrentUser(); + } + + public async signInWithGoogle() { + await FirebaseAuthentication.signInWithGoogle(); + return this.authUser(); + } + + public async signOut() { + await FirebaseAuthentication.signOut(); + return this.authUser(); + } + + public async getCurrentUser() { + const { user } = await FirebaseAuthentication.getCurrentUser(); + return user; + } + + /** + * Listen to auth state changes and update authUser signal + * This helps to ensure the signal is kept in sync with automated user sign-in/out + * */ + private addAuthListeners() { + FirebaseAuthentication.addListener("authStateChange", ({ user }) => { + this.authUser.set(user); + }); + } +} diff --git a/src/app/shared/services/auth/providers/index.ts b/src/app/shared/services/auth/providers/index.ts new file mode 100644 index 0000000000..9fe03efa66 --- /dev/null +++ b/src/app/shared/services/auth/providers/index.ts @@ -0,0 +1,17 @@ +import { IAuthProvider } from "../types"; +import { AuthProviderBase } from "./base.auth"; +import { FirebaseAuthProvider } from "./firebase.auth"; +import { SupabaseAuthProvider } from "./supabase.auth"; + +// TODO - optimise for production (only include provider used) +export const getAuthProvider = (name: IAuthProvider): AuthProviderBase => { + switch (name) { + case "firebase": + return new FirebaseAuthProvider(); + case "supabase": + return new SupabaseAuthProvider(); + default: + console.warn("[Auth Provider] not configured for: ", name); + return new AuthProviderBase(); + } +}; diff --git a/src/app/shared/services/auth/types.ts b/src/app/shared/services/auth/types.ts new file mode 100644 index 0000000000..e65c35f5da --- /dev/null +++ b/src/app/shared/services/auth/types.ts @@ -0,0 +1,7 @@ +import type { IDeploymentConfig } from "packages/data-models"; + +export type IAuthProvider = IDeploymentConfig["auth"]["provider"]; + +export interface IAuthUser { + uid: string; +} From 05144aa30752a9a2d46ed2fb042fd907fa20ae88 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 16 Dec 2024 11:45:32 +0000 Subject: [PATCH 02/23] feat: expose additional header and footer configuration props to app config --- packages/data-models/appConfig.ts | 13 +++-- src/app/app.component.html | 6 ++- src/app/app.component.ts | 3 +- .../components/header/header.component.html | 15 +++--- .../shared/services/skin/skin.service.spec.ts | 51 +++---------------- 5 files changed, 28 insertions(+), 60 deletions(-) diff --git a/packages/data-models/appConfig.ts b/packages/data-models/appConfig.ts index 6e3d690c50..29bf68862a 100644 --- a/packages/data-models/appConfig.ts +++ b/packages/data-models/appConfig.ts @@ -83,13 +83,15 @@ const APP_ROUTE_DEFAULTS = { ], }; -export type IHeaderColourOptions = "primary" | "secondary" | "none"; +export type IHeaderFooterBackgroundOptions = "primary" | "secondary" | "none"; export type IHeaderVariantOptions = "default" | "compact"; const APP_HEADER_DEFAULTS = { - title: "App", collapse: false, - colour: "primary" as IHeaderColourOptions, + colour: "primary" as IHeaderFooterBackgroundOptions, + show: true, + template: null as string | null, + title: "App", // The "compact" variant reduces the header height and removes the title variant: "default" as IHeaderVariantOptions, // default only show menu button on home screen @@ -113,8 +115,9 @@ const activeRoute = (location: Location) => { return path; }; -const APP_FOOTER_DEFAULTS: { templateName: string | null } = { - templateName: null, +const APP_FOOTER_DEFAULTS = { + templateName: null as string | null, + background: "primary" as IHeaderFooterBackgroundOptions, }; const APP_SIDEMENU_DEFAULTS = { diff --git a/src/app/app.component.html b/src/app/app.component.html index 1b16ac041d..e247815129 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -41,13 +41,15 @@
- + @if (headerConfig().show) { + + }
@if (footerDefaults().templateName; as footerTemplateName) { - + this.appConfigService.appConfig().APP_SIDEMENU_DEFAULTS); + headerConfig = computed(() => this.appConfigService.appConfig().APP_HEADER_DEFAULTS); footerDefaults = computed(() => this.appConfigService.appConfig().APP_FOOTER_DEFAULTS); + sideMenuDefaults = computed(() => this.appConfigService.appConfig().APP_SIDEMENU_DEFAULTS); /** Track when app ready to render sidebar and route templates */ public renderAppTemplates = signal(false); diff --git a/src/app/shared/components/header/header.component.html b/src/app/shared/components/header/header.component.html index a66a230875..992fa936b6 100644 --- a/src/app/shared/components/header/header.component.html +++ b/src/app/shared/components/header/header.component.html @@ -14,15 +14,16 @@ - - + @if (headerConfig().template) { + + } @else if (!(headerConfig().variant === "compact")) { + {{ headerConfig().title }} - + } diff --git a/src/app/shared/services/skin/skin.service.spec.ts b/src/app/shared/services/skin/skin.service.spec.ts index 91163628c5..c500ea5387 100644 --- a/src/app/shared/services/skin/skin.service.spec.ts +++ b/src/app/shared/services/skin/skin.service.spec.ts @@ -32,6 +32,8 @@ const MOCK_SKIN_2: IAppSkin = { const MOCK_APP_CONFIG: Partial = { APP_HEADER_DEFAULTS: { + template: null, + show: true, title: "default", collapse: false, colour: "none", @@ -50,6 +52,7 @@ const MOCK_APP_CONFIG: Partial = { }, APP_FOOTER_DEFAULTS: { templateName: "mock_footer", + background: "primary", }, }; @@ -86,9 +89,9 @@ describe("SkinService", () => { }); it("does not change non-overridden values", () => { - expect(service["appConfigService"].appConfig().APP_FOOTER_DEFAULTS).toEqual({ - templateName: "mock_footer", - }); + expect(service["appConfigService"].appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual( + "mock_footer" + ); }); it("loads active skin from local storage on init if available", () => { @@ -96,37 +99,6 @@ describe("SkinService", () => { expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_2"); }); - it("generates override and revert configs", () => { - expect(service["revertOverride"]).toEqual({ - APP_HEADER_DEFAULTS: { title: "default", colour: "none" }, - }); - }); - - it("reverts previous override when applying another skin", () => { - // MOCK_SKIN_1 will already be applied on load - const override = service["generateOverrideConfig"](MOCK_SKIN_2); - // creates a deep merge of override properties on top of current - expect(override).toEqual({ - APP_HEADER_DEFAULTS: { - // revert changes only available in skin_1 - colour: "none", - // apply changes from skin_2 - title: "mock 2", - variant: "compact", - }, - }); - const revert = service["generateRevertConfig"](MOCK_SKIN_2); - - // creates config revert to undo just the skin changes - expect(revert).toEqual({ - APP_HEADER_DEFAULTS: { - // only revert changes remaining from skin_2 - title: "default", - variant: "default", - }, - }); - }); - it("sets skin: sets active skin name", () => { service["setSkin"](MOCK_SKIN_2.name); expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_2"); @@ -134,17 +106,6 @@ describe("SkinService", () => { expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_1"); }); - it("sets skin: sets revertOverride correctly", () => { - // MOCK_SKIN_1 will already be applied on load - service["setSkin"](MOCK_SKIN_2.name); - expect(service["revertOverride"]).toEqual({ - APP_HEADER_DEFAULTS: { - title: "default", - variant: "default", - }, - }); - }); - it("sets skin: updates AppConfigService.appConfig values", () => { // MOCK_SKIN_1 will already be applied on load service["setSkin"](MOCK_SKIN_2.name); From f92595b6d22ea3a7e4b50b02e0285964331841d3 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 16 Dec 2024 16:43:20 +0000 Subject: [PATCH 03/23] chore: tidy test --- src/app/shared/services/skin/skin.service.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/shared/services/skin/skin.service.spec.ts b/src/app/shared/services/skin/skin.service.spec.ts index c500ea5387..4b8b03b0a7 100644 --- a/src/app/shared/services/skin/skin.service.spec.ts +++ b/src/app/shared/services/skin/skin.service.spec.ts @@ -89,9 +89,10 @@ describe("SkinService", () => { }); it("does not change non-overridden values", () => { - expect(service["appConfigService"].appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual( - "mock_footer" - ); + expect(service["appConfigService"].appConfig().APP_FOOTER_DEFAULTS).toEqual({ + templateName: "mock_footer", + background: "primary", + }); }); it("loads active skin from local storage on init if available", () => { From 579b2093a7981ffb8887a31090a0cc1eb14d4537 Mon Sep 17 00:00:00 2001 From: Johnny McQuade Date: Mon, 16 Dec 2024 17:43:48 +0000 Subject: [PATCH 04/23] chore: handle function params --- .../shared/components/header/header.component.ts | 5 ++--- .../services/app-config/app-config.service.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/app/shared/components/header/header.component.ts b/src/app/shared/components/header/header.component.ts index 5ce984e9f1..62bc8a93f1 100644 --- a/src/app/shared/components/header/header.component.ts +++ b/src/app/shared/components/header/header.component.ts @@ -69,11 +69,10 @@ export class headerComponent implements OnInit, OnDestroy { ); effect( () => { + const config = this.headerConfig(); // when route changes handle side-effects const e = this.routeChanges(); - if (e instanceof NavigationEnd) { - this.handleRouteChange(); - } + this.handleRouteChange(); if (e instanceof NavigationStart) { this.hasBackHistory = true; } diff --git a/src/app/shared/services/app-config/app-config.service.ts b/src/app/shared/services/app-config/app-config.service.ts index a97cd8f01e..334cf151a7 100644 --- a/src/app/shared/services/app-config/app-config.service.ts +++ b/src/app/shared/services/app-config/app-config.service.ts @@ -100,12 +100,25 @@ export class AppConfigService extends SyncServiceBase { return; } + // some app config properties should be functions, but may be defined as boolean values, + // e.g. in template parameter_list to disable a certain property on that template + const functionKeys = ["should_show_back_button", "should_show_menu_button"]; + functionKeys.forEach((key) => { + mergedConfig.APP_HEADER_DEFAULTS[key] = this.normaliseToFunction( + mergedConfig.APP_HEADER_DEFAULTS[key] + ); + }); + // trigger change effects this.handleConfigSideEffects(overrides, mergedConfig); this.appConfig.set(mergedConfig); this.appConfig$.next(mergedConfig); } + private normaliseToFunction(value: any) { + return typeof value === "function" ? value : () => !!value; + } + private handleConfigSideEffects(overrides: IAppConfigOverride = {}, config: IAppConfig) { if (overrides.APP_ROUTE_DEFAULTS) { updateRoutingDefaults(config.APP_ROUTE_DEFAULTS, this.router); From b264af86e4f2eaff49646dc5e4eb35e5f2301bc6 Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Mon, 16 Dec 2024 17:27:59 -0800 Subject: [PATCH 05/23] refactor: enforce login --- packages/data-models/appConfig.ts | 1 - packages/data-models/deployment.model.ts | 2 ++ src/app/app.component.ts | 26 --------------------- src/app/app.module.ts | 4 ++-- src/app/shared/services/auth/auth.module.ts | 1 + 5 files changed, 5 insertions(+), 29 deletions(-) diff --git a/packages/data-models/appConfig.ts b/packages/data-models/appConfig.ts index 6e3d690c50..92741de10a 100644 --- a/packages/data-models/appConfig.ts +++ b/packages/data-models/appConfig.ts @@ -129,7 +129,6 @@ const APP_SIDEMENU_DEFAULTS = { }; const APP_AUTHENTICATION_DEFAULTS = { - enforceLogin: false, signInTemplate: "sign_in", }; diff --git a/packages/data-models/deployment.model.ts b/packages/data-models/deployment.model.ts index 2e518e0de8..c37eebb9fb 100644 --- a/packages/data-models/deployment.model.ts +++ b/packages/data-models/deployment.model.ts @@ -40,6 +40,8 @@ export interface IDeploymentRuntimeConfig { auth: { /** provider to use with authentication actions. actions will be disabled if no provider specified */ provider?: "firebase" | "supabase"; + /** prevent user accessing app pages without being logged in */ + enforceLogin?: boolean; }; /** * Specify if using firebase for auth and crashlytics. diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 323455791f..42335bc77b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -100,7 +100,6 @@ export class AppComponent { public templateTranslateService: TemplateTranslateService, private crashlyticsService: CrashlyticsService, private appDataService: AppDataService, - private authService: AuthService, private seoService: SeoService, private taskService: TaskService, private feedbackService: FeedbackService, @@ -125,7 +124,6 @@ export class AppComponent { this.hackSetDeveloperOptions(); const isDeveloperMode = this.templateFieldService.getField("user_mode") === false; const user = this.userMetaService.userMeta; - await this.loadAuthConfig(); if (!user.first_app_open) { await this.userMetaService.setUserMeta({ first_app_open: new Date().toISOString() }); @@ -164,29 +162,6 @@ export class AppComponent { } } - /** - * Authentication requires verified domain and app ids populated to firebase console - * Currently only run on native where specified (but can comment out for testing locally) - */ - private async loadAuthConfig() { - const { firebase } = this.deploymentService.config; - const { enforceLogin, signInTemplate } = - this.appConfigService.appConfig().APP_AUTHENTICATION_DEFAULTS; - const ensureLogin = firebase.config && enforceLogin && Capacitor.isNativePlatform(); - if (ensureLogin) { - this.authService.ready(); - const authUser = await this.authService.getCurrentUser(); - if (!authUser) { - const { modal } = await this.templateService.runStandaloneTemplate(signInTemplate, { - showCloseButton: false, - waitForDismiss: false, - }); - await this.authService.waitForSignInComplete(); - await modal.dismiss(); - } - } - } - /** * Various services set core app data which may be used in templates such as current app day, * user id etc. Make sure these services have run their initialisation logic before proceeding. @@ -231,7 +206,6 @@ export class AppComponent { this.templateService, this.templateProcessService, this.appDataService, - this.authService, this.serverService, this.seoService, this.feedbackService, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 591b129364..aedaf10727 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -38,16 +38,16 @@ export function lottiePlayerFactory() { BrowserAnimationsModule, IonicModule.forRoot(), AppRoutingModule, + TemplateComponentsModule, + DeploymentFeaturesModule, HttpClientModule, SharedModule, FormsModule, LottieModule.forRoot({ player: lottiePlayerFactory }), // NOTE CC 2021-11-04 not sure if cache causes issues or not https://github.com/ngx-lottie/ngx-lottie/issues/115 // LottieCacheModule.forRoot(), - TemplateComponentsModule, TourModule, ContextMenuModule, - DeploymentFeaturesModule, ], providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, diff --git a/src/app/shared/services/auth/auth.module.ts b/src/app/shared/services/auth/auth.module.ts index 011a4b1f8e..d81ad098ee 100644 --- a/src/app/shared/services/auth/auth.module.ts +++ b/src/app/shared/services/auth/auth.module.ts @@ -10,5 +10,6 @@ import { AuthService } from "./auth.service"; export class AuthModule { constructor(private service: AuthService) { // include service to initialise and register handlers + service.ready(); } } From 49769a5e935bc4ac9539973b39524cd57fbc3a1b Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Mon, 16 Dec 2024 17:28:27 -0800 Subject: [PATCH 06/23] chore: update deps --- package.json | 6 +++--- yarn.lock | 34 +++++++++++++++++----------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 1d96b4ad74..9aecded662 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,9 @@ "@angular/platform-browser-dynamic": "~17.2.2", "@angular/router": "~17.2.2", "@capacitor-community/file-opener": "^6.0.0", - "@capacitor-firebase/authentication": "^6.1.0", - "@capacitor-firebase/crashlytics": "^6.1.0", - "@capacitor-firebase/performance": "^6.1.0", + "@capacitor-firebase/authentication": "^6.3.1", + "@capacitor-firebase/crashlytics": "^6.3.1", + "@capacitor-firebase/performance": "^6.3.1", "@capacitor/android": "^6.0.0", "@capacitor/app": "^6.0.0", "@capacitor/clipboard": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 8a043ef189..0e1e475f22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2048,41 +2048,41 @@ __metadata: languageName: node linkType: hard -"@capacitor-firebase/authentication@npm:^6.1.0": - version: 6.1.0 - resolution: "@capacitor-firebase/authentication@npm:6.1.0" +"@capacitor-firebase/authentication@npm:^6.3.1": + version: 6.3.1 + resolution: "@capacitor-firebase/authentication@npm:6.3.1" peerDependencies: "@capacitor/core": ^6.0.0 - firebase: ^10.9.0 + firebase: ^10.9.0 || ^11.0.0 peerDependenciesMeta: firebase: optional: true - checksum: c70c82576c46333d8d56c03dbe51ceab798cddd7974dbc2aa0f6e287059deea245d17be6343302096a09bd91d8e13b0bf8d6e8417b65ecb86e62573674492df0 + checksum: 148200081ff5100e992205a19d9940e155c32e92a428405eb490e04b009f56ac364fc1c606e0ce1c6aec35003701fbff0022dd6a3057ecb3a8fbe16cacaf4bb6 languageName: node linkType: hard -"@capacitor-firebase/crashlytics@npm:^6.1.0": - version: 6.1.0 - resolution: "@capacitor-firebase/crashlytics@npm:6.1.0" +"@capacitor-firebase/crashlytics@npm:^6.3.1": + version: 6.3.1 + resolution: "@capacitor-firebase/crashlytics@npm:6.3.1" peerDependencies: "@capacitor/core": ^6.0.0 peerDependenciesMeta: firebase: optional: true - checksum: 9d20b204545e7bb6fed9b858270342634bd8772c84b516ba467d1a6b13a870edcdc1048def10c50f2edebd7fd0c896a2f7728176594cda670d807ed0b5e0f045 + checksum: b52dc7a42d2f961aa47b30b8918038fa9ae370308240e52f3b78f2d125b3318a7eed34aa845aa98470daf826878b62c8233d8dac47e33d6fcb7c89ea59201914 languageName: node linkType: hard -"@capacitor-firebase/performance@npm:^6.1.0": - version: 6.1.0 - resolution: "@capacitor-firebase/performance@npm:6.1.0" +"@capacitor-firebase/performance@npm:^6.3.1": + version: 6.3.1 + resolution: "@capacitor-firebase/performance@npm:6.3.1" peerDependencies: "@capacitor/core": ^6.0.0 - firebase: ^10.9.0 + firebase: ^10.9.0 || ^11.0.0 peerDependenciesMeta: firebase: optional: true - checksum: 8aa094e6cece67ed8101cc0d78b2c9a0f3aa9f027de765095963ad95604be4bda70155573a8b8ef190ae571c878ae3fe3f63c688a56268f79840faddd16acaac + checksum: 25b4da6e44db02520bfa9f0d89b1428c0efa275e1a270f9b567513e469e9f2ce8157c1c9ba70d8c83b21b462aaecadea1dec807f84ec036cb59be66963284a63 languageName: node linkType: hard @@ -15100,9 +15100,9 @@ __metadata: "@angular/platform-browser-dynamic": ~17.2.2 "@angular/router": ~17.2.2 "@capacitor-community/file-opener": ^6.0.0 - "@capacitor-firebase/authentication": ^6.1.0 - "@capacitor-firebase/crashlytics": ^6.1.0 - "@capacitor-firebase/performance": ^6.1.0 + "@capacitor-firebase/authentication": ^6.3.1 + "@capacitor-firebase/crashlytics": ^6.3.1 + "@capacitor-firebase/performance": ^6.3.1 "@capacitor/android": ^6.0.0 "@capacitor/app": ^6.0.0 "@capacitor/cli": ^6.0.0 From 81c9d9a5264e45cdc69a00c0619ef5c974605469 Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Mon, 16 Dec 2024 17:30:42 -0800 Subject: [PATCH 07/23] chore: code tidying --- src/app/deployment-features.module.ts | 2 +- src/app/shared/services/auth/auth.service.ts | 43 ++++++++++++++----- .../services/auth/providers/base.auth.ts | 2 + .../services/auth/providers/firebase.auth.ts | 7 +-- 4 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/app/deployment-features.module.ts b/src/app/deployment-features.module.ts index 1353224e7b..8c12edc419 100644 --- a/src/app/deployment-features.module.ts +++ b/src/app/deployment-features.module.ts @@ -15,5 +15,5 @@ import { AuthModule } from "./shared/services/auth/auth.module"; * * This is a feature marked for future implementation */ -@NgModule({ imports: [AnalyticsModule, NavStackModule, AuthModule] }) +@NgModule({ imports: [AuthModule, AnalyticsModule, NavStackModule] }) export class DeploymentFeaturesModule {} diff --git a/src/app/shared/services/auth/auth.service.ts b/src/app/shared/services/auth/auth.service.ts index 7b63603342..1a803cc934 100644 --- a/src/app/shared/services/auth/auth.service.ts +++ b/src/app/shared/services/auth/auth.service.ts @@ -6,15 +6,14 @@ import { AuthProviderBase } from "./providers/base.auth"; import { AsyncServiceBase } from "../asyncService.base"; import { getAuthProvider } from "./providers"; import { IAuthUser } from "./types"; +import { filter, firstValueFrom, tap } from "rxjs"; +import { TemplateService } from "../../components/template/services/template.service"; import { toObservable } from "@angular/core/rxjs-interop"; -import { filter, firstValueFrom } from "rxjs"; @Injectable({ providedIn: "root", }) export class AuthService extends AsyncServiceBase { - public authUser = signal(null); - /** Auth provider used */ private provider: AuthProviderBase; @@ -22,27 +21,51 @@ export class AuthService extends AsyncServiceBase { private templateActionRegistry: TemplateActionRegistry, private localStorageService: LocalStorageService, private deploymentService: DeploymentService, - private injector: Injector + private injector: Injector, + private templateService: TemplateService ) { super("Auth"); - this.provider = getAuthProvider(this.deploymentService.config.auth?.provider); + this.provider = getAuthProvider(this.config.provider); this.registerInitFunction(this.initialise); effect(async () => { const authUser = this.provider.authUser(); - console.log("[Auth User]", authUser); this.addStorageEntry(authUser); }); } - /** Return a promise that resolves only after a signed in user defined */ - public async waitForUserSignedIn() { - const authUser$ = toObservable(this.authUser); - return firstValueFrom(authUser$.pipe(filter((value: IAuthUser | null) => !!value))); + private get config() { + return this.deploymentService.config.auth || {}; } private async initialise() { await this.provider.initialise(this.injector); this.registerTemplateActionHandlers(); + if (this.config.enforceLogin) { + // NOTE - Do not await the enforce login to allow other services to initialise in background + this.enforceLogin(); + } + } + + private async enforceLogin() { + // If user already logged in simply return. If providers auto-login during then waiting to verify + // should be included during the provide init method + if (this.provider.authUser()) { + return; + } + const { signInTemplate } = this.deploymentService.config.app_config.APP_AUTHENTICATION_DEFAULTS; + const { modal } = await this.templateService.runStandaloneTemplate(signInTemplate, { + showCloseButton: false, + waitForDismiss: false, + }); + // wait for user signal to update with a signed in user before dismissing modal + const authUser$ = toObservable(this.provider.authUser, { injector: this.injector }); + await firstValueFrom( + authUser$.pipe( + tap((authUser) => console.log("auth user", authUser)), + filter((value: IAuthUser | null) => !!value) + ) + ); + await modal.dismiss(); } private registerTemplateActionHandlers() { diff --git a/src/app/shared/services/auth/providers/base.auth.ts b/src/app/shared/services/auth/providers/base.auth.ts index 1751c0333f..2eea05c2d7 100644 --- a/src/app/shared/services/auth/providers/base.auth.ts +++ b/src/app/shared/services/auth/providers/base.auth.ts @@ -7,10 +7,12 @@ export class AuthProviderBase { public async initialise(injector: Injector) {} public async signInWithGoogle() { + throw new Error("Google sign in not enabled"); return this.authUser(); } public async signOut() { + this.authUser.set(undefined); return this.authUser(); } } diff --git a/src/app/shared/services/auth/providers/firebase.auth.ts b/src/app/shared/services/auth/providers/firebase.auth.ts index b7d67c913f..6001373357 100644 --- a/src/app/shared/services/auth/providers/firebase.auth.ts +++ b/src/app/shared/services/auth/providers/firebase.auth.ts @@ -1,5 +1,6 @@ import { Injectable, Injector } from "@angular/core"; -import { FirebaseAuthentication, User } from "@capacitor-firebase/authentication"; +import { FirebaseAuthentication } from "@capacitor-firebase/authentication"; +import { getAuth } from "firebase/auth"; import { FirebaseService } from "../../firebase/firebase.service"; import { AuthProviderBase } from "./base.auth"; @@ -14,8 +15,8 @@ export class FirebaseAuthProvider extends AuthProviderBase { throw new Error("[Firebase Auth] app not configured"); } this.addAuthListeners(); - // ensure any previous signed in user is loaded - await FirebaseAuthentication.getCurrentUser(); + // use firebase authStateReady to ensure any previously logged in user is available + await getAuth().authStateReady(); } public async signInWithGoogle() { From c87078731251a66f00347074744dbc6489796b1e Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Mon, 16 Dec 2024 18:50:35 -0800 Subject: [PATCH 08/23] chore: add placeholder supabase auth provider --- src/app/shared/services/auth/providers/supabase.auth.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/app/shared/services/auth/providers/supabase.auth.ts diff --git a/src/app/shared/services/auth/providers/supabase.auth.ts b/src/app/shared/services/auth/providers/supabase.auth.ts new file mode 100644 index 0000000000..cfedac6236 --- /dev/null +++ b/src/app/shared/services/auth/providers/supabase.auth.ts @@ -0,0 +1,3 @@ +import { AuthProviderBase } from "./base.auth"; + +export class SupabaseAuthProvider extends AuthProviderBase {} From a2674461fbb4c3f8d6a9def812587154db18ae58 Mon Sep 17 00:00:00 2001 From: chrismclarke Date: Tue, 17 Dec 2024 22:00:59 -0800 Subject: [PATCH 09/23] fix: data items nested templates --- .../data-items/data-items.service.ts | 2 +- .../services/instance/template-row.service.ts | 7 ++-- .../components/template/template-component.ts | 28 +++++++++---- .../template-container.component.html | 42 +++++++++---------- .../template/template-container.component.ts | 7 +--- 5 files changed, 46 insertions(+), 40 deletions(-) diff --git a/src/app/shared/components/template/components/data-items/data-items.service.ts b/src/app/shared/components/template/components/data-items/data-items.service.ts index ffa7138976..7fc03406ca 100644 --- a/src/app/shared/components/template/components/data-items/data-items.service.ts +++ b/src/app/shared/components/template/components/data-items/data-items.service.ts @@ -117,6 +117,6 @@ export class DataItemsService { // HACK - still want to be able to use localContext from parent rows so copy to child processor processor.templateRowMap = JSON.parse(JSON.stringify(templateRowMap)); await processor.processContainerTemplateRows(); - return processor.renderedRows; + return processor.renderedRows(); } } diff --git a/src/app/shared/components/template/services/instance/template-row.service.ts b/src/app/shared/components/template/services/instance/template-row.service.ts index 460c1ab0ad..b91059efa3 100644 --- a/src/app/shared/components/template/services/instance/template-row.service.ts +++ b/src/app/shared/components/template/services/instance/template-row.service.ts @@ -1,4 +1,4 @@ -import { Injector } from "@angular/core"; +import { Injector, signal } from "@angular/core"; import { FlowTypes } from "src/app/shared/model"; import { getGlobalService } from "src/app/shared/services/global.service"; import { SyncServiceBase } from "src/app/shared/services/syncService.base"; @@ -9,6 +9,7 @@ import { mergeTemplateRows } from "../../utils/template-utils"; import { TemplateFieldService } from "../template-field.service"; import { TemplateTranslateService } from "../template-translate.service"; import { TemplateVariablesService } from "../template-variables.service"; +import { isEqual } from "packages/shared/src/utils/object-utils"; /** Logging Toggle - rewrite default functions to enable or disable inline logs */ let SHOW_DEBUG_LOGS = false; @@ -34,7 +35,7 @@ export class TemplateRowService extends SyncServiceBase { /** List of overrides set by parent templates for access during parent processing */ /** Hashmap of all rows keyed by nested row name (e.g. contentBox1.row1.title) */ public templateRowMap: ITemplateRowMap = {}; - public renderedRows: FlowTypes.TemplateRow[]; // rows processed and filtered by condition + public renderedRows = signal([], { equal: isEqual }); // rows processed and filtered by condition constructor( private injector: Injector, @@ -234,7 +235,7 @@ export class TemplateRowService extends SyncServiceBase { const renderedRows = this.filterConditionalTemplateRows( JSON.parse(JSON.stringify(processedRows)) ); - this.renderedRows = renderedRows; + this.renderedRows.set(renderedRows); log("[Rows Processed]", logName, { rows, processedRows, renderedRows }); return processedRows; } diff --git a/src/app/shared/components/template/template-component.ts b/src/app/shared/components/template/template-component.ts index 821ea072a4..2c0fa6d6e4 100644 --- a/src/app/shared/components/template/template-component.ts +++ b/src/app/shared/components/template/template-component.ts @@ -71,7 +71,8 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow this._row = row; if (this.componentRef) { log("[Component Update]", row.name, row); - this.componentRef.instance.row = row; + this.componentRef.setInput("row", row); + this.hackForceReprocessNestedTemplate(); } else { log("[Component Create]", row.name, row); } @@ -130,7 +131,7 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow // Depending on row type, either prepare instantiation of a nested template or a display component switch (row.type) { case "template": - return this.renderTemplateComponent(TemplateContainerComponent, row); + return this.renderTemplateComponent(row); default: const displayComponent = TEMPLATE_COMPONENT_MAPPING[row.type]; if (displayComponent) { @@ -145,15 +146,12 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow } /** Create and render a nested template component */ - private renderTemplateComponent( - component: typeof TemplateContainerComponent, - row: FlowTypes.TemplateRow - ) { + private renderTemplateComponent(row: FlowTypes.TemplateRow) { const viewContainerRef = this.tmplComponentHost.viewContainerRef; - const componentRef = viewContainerRef.createComponent(component); + const componentRef = viewContainerRef.createComponent(TemplateContainerComponent); // assign input variables (note template name taken from the row's value column) componentRef.instance.parent = this.parent; - componentRef.instance.row = row; + componentRef.setInput("row", row); componentRef.instance.name = row.name; // assign templatename input using signal componentRef.setInput("templatename", row.value); @@ -169,4 +167,18 @@ export class TemplateComponent implements OnInit, AfterContentInit, ITemplateRow componentRef.instance.row = row; this.componentRef = componentRef; } + + /** + * If the current template is generated as a child of data_items then updates to + * parent variables will not propagate down. Force reprocessing to workaround + * See issue https://github.com/IDEMSInternational/open-app-builder/issues/2636 + */ + private hackForceReprocessNestedTemplate() { + if (this._row.type === "template") { + const componentRef = this.componentRef as ComponentRef; + if (componentRef.instance.parent) { + componentRef.instance.templateRowService.processContainerTemplateRows(); + } + } + } } diff --git a/src/app/shared/components/template/template-container.component.html b/src/app/shared/components/template/template-container.component.html index 8e6f73efc2..c6935cf717 100644 --- a/src/app/shared/components/template/template-container.component.html +++ b/src/app/shared/components/template/template-container.component.html @@ -5,31 +5,27 @@
name: {{ name || "(undefined)" }}
- + @for (row of templateRowService.renderedRows() | filterDisplayComponent; track row._nested_name) { + + } - + @for (row of templateRowService.renderedRows() | filterDisplayComponent; track row._nested_name) { + + } diff --git a/src/app/shared/components/template/template-container.component.ts b/src/app/shared/components/template/template-container.component.ts index 410fe7dc04..809a5b9e91 100644 --- a/src/app/shared/components/template/template-container.component.ts +++ b/src/app/shared/components/template/template-container.component.ts @@ -146,15 +146,12 @@ export class TemplateContainerComponent implements OnInit, OnDestroy { if (shouldProcess) { if (full) { console.log("[Force Reload]", this.name); - // ensure angular destroys previous row components before rendering new - // (note - will cause short content flicker) - this.templateRowService.renderedRows = []; // allow time for other pending ops to finish await _wait(50); await this.renderTemplate(this.templatename()); } else { await this.templateRowService.processRowUpdates(); - console.log("[Force Reprocess]", this.name, this.templateRowService.renderedRows); + console.log("[Force Reprocess]", this.name, this.templateRowService.renderedRows()); for (const child of Object.values(this.children || {})) { await child.forceRerender(full, shouldProcess); } @@ -203,7 +200,7 @@ export class TemplateContainerComponent implements OnInit, OnDestroy { log("[Template] Rendered", this.name, { template, ctxt: { ...this }, - renderedRows: { ...this.templateRowService.renderedRows }, + renderedRows: { ...this.templateRowService.renderedRows() }, rowMap: this.templateRowService.templateRowMap, }); // if a parent exists also provide parent reference to this as a child From ebd74a7630fe6bcb3f79352395dc6ca843938f84 Mon Sep 17 00:00:00 2001 From: FaithDaka Date: Wed, 18 Dec 2024 10:09:05 +0300 Subject: [PATCH 10/23] feat: handle pop up variant --- .../template/components/layout/popup/popup.component.html | 1 + .../template/components/layout/popup/popup.component.ts | 2 ++ .../components/template/services/template-nav.service.ts | 3 +++ 3 files changed, 6 insertions(+) diff --git a/src/app/shared/components/template/components/layout/popup/popup.component.html b/src/app/shared/components/template/components/layout/popup/popup.component.html index 31958a5cfa..35b028640b 100644 --- a/src/app/shared/components/template/components/layout/popup/popup.component.html +++ b/src/app/shared/components/template/components/layout/popup/popup.component.html @@ -2,6 +2,7 @@ class="popup-backdrop" (click)="dismissOnBackdrop($event)" [attr.data-fullscreen]="props.fullscreen ? true : null" + [attr.data-variant]="props.variant ? props.variant : null" >