从单个服务订阅、获取和传播 Firebase 自定义声明到 Angular 中的组件的正确方法

Proper way to subscribe to and get and propagate Firebase custom claims to components in Angular from a single service

我正在尝试 find/create 在 Angular 应用程序中获取和使用自定义声明的正确(最佳)方法。我通过云功能添加了管理员自定义声明。我现在想要拥有的(以及我之前尝试做的)是:

  1. 在一项服务中获取声明(和登录用户)(例如 auth.service
  2. 允许所有其他需要读取声明的组件通过来自该服务的简单 API 来读取声明
  3. 不要让其他组件订阅 authState 或其他任何东西(只需同步读取我的 auth.service 的属性)

我为什么要这个? - 因为我相信它更具可读性和更易于维护

(通过只在一个地方(例如authService.ts)读取(订阅)authState,从而使维护更容易,并允许其他组件同步读取[=16=中的声明] attributes/fields)


所以,我现在正在做的事情的代码(这不起作用......见POINTS_IN_CODE):

auth.service.ts

    // imports omitted for brevity...
    
    @Injectable()
    export class AuthService {
    
      user: Observable<User> = of(null);
      uid: string;
      claims: any = {};
      claimsSubject = new BehaviorSubject(0);
    
      constructor(private afAuth: AngularFireAuth,
                  private afStore: AngularFirestore,
                  private functions: AngularFireFunctions) {
    
        this.afAuth.authState
          .subscribe(
            async authUser => {
              if (authUser) { // logged in
                console.log(`Auth Service says: ${authUser.displayName} is logged in.`);
                this.uid = authUser.uid;
                this.claims = (await authUser.getIdTokenResult()).claims;
                // POINT_IN_CODE_#1
                this.claimsSubject.next(1);

                const userDocumentRef = this.afStore.doc<User>(`users/${authUser.uid}`);
                // if provider is Google (or Facebook <later> (OR any other 3rd party))
                // document doesn't exist on the first login and needs to be created
                if (authUser.providerData[0].providerId === 'google.com') {
                  userDocumentRef.get()
                  .subscribe( async snapshot => {
                    if ( ! snapshot.exists) { // if the document does not exist
                      console.log(`\nNew document being created for: ${authUser.displayName}...`); // create a user document
                      await userDocumentRef.set({name: authUser.displayName, email: authUser.email, provider: 'google.com'});
                    }
                  });
                }
                this.user = userDocumentRef.valueChanges();
              }
              else { // logged out
                console.log('Auth Service says: no User is logged in.');
              }
            }
         );
    
      }
    
      login(email, password): Promise<any> {
        return this.afAuth.auth.signInWithEmailAndPassword(email, password);
      }

      hasClaim(claim): boolean {
        return this.hasAnyClaim([claim]);
      }
    
      hasAnyClaim(paramClaims): boolean {
        for (let paramClaim of paramClaims) {
          if (this.claims[paramClaim]) {
            return true;
          }
        }
        return false;
      }
      
    }

login.component.ts

    // imports...
    
    @Component({
      selector: 'app-login',
      templateUrl: './login.component.html',
      styleUrls: ['./login.component.css']
    })
    export class LoginComponent implements OnInit {
    
      form: FormGroup;
      hide = true;
      errorMessage = '';
      loading = false;
    
      constructor(private fb: FormBuilder,
                  public authService: AuthService,
                  private router: Router) {}
    
      ngOnInit() {
        this.logout();
    
        this.form = this.fb.group({
          username: ['test@test.te', Validators.compose([Validators.required, Validators.email])],
          password: ['Asdqwe123', Validators.compose([Validators.required])]
        });
      }
    
      submit() {
        this.loading = true;
        this.authService.login(this.form.value.username, this.form.value.password)
          .then(resp => {
            this.loading = false;
            // POINT_IN_CODE_#2

            // what I am doing right now, and what doesn't work...
            this.authService.user
              .subscribe(
                resp => {
                  if (this.authService.hasClaim('admin')) {
                    this.router.navigate(['/admin']);
                  }
                  else {
                    this.router.navigate(['/items']);
                  }
                }
              );
    
            // POINT_IN_CODE_#3
            //this.authService.claimsSubject
            // .subscribe(
            //   num => {
            //     if (num === 1) {
            //       if (this.authService.hasClaim('admin')) {
            //         this.router.navigate(['/admin']);
            //       }
            //       else {
            //         this.router.navigate(['/items']);
            //       }
            //     }
            //   });
      }
    
      logout() {
        this.authService.logout();
      }
    }

POINTS_IN_CODE

auth.service.ts at POINT_IN_CODE_#1 - 我有想法从这个主题 claimsSubject 发出并在 login.component.ts at POINT_IN_CODE_#3 订阅它并知道也就是说,如果它的值为 1,则已在 auth.service.ts 中从 authState.

中检索到声明

login.component.ts at POINT_IN_CODE_#2 我知道我可以从 resp.getIdTokenResult 那里得到声明,但它 “感觉不到”对...这就是这个问题的主要内容...

我可以问的具体问题是:

如果用户有 'admin' 自定义声明,我希望能够在登录到 admin 页面后重定向用户。

我想这样做,正如我上面所说的(如果可能并且如果它是 good/improving-readability/improving_maintainability),没有 直接订阅 authState ,但是通过 auth.service.ts.

中的一些“东西”

我会使用相同的“逻辑”来创建一个 AuthGuard,它只会调用 authService.hasClaim('admin'),而不必订阅 authState 本身来执行检查。

N.B. 我想知道我这样做的方式是否好,是否有任何警告或只是简单的改进。欢迎所有建议和评论,所以请发表评论,尤其是在我的 Why do I want this? 部分!

编辑-1: 添加了打字稿代码高亮显示,并指​​出了我的代码中不按我想要的方式工作的确切位置。

编辑 2: 编辑了一些关于为什么我的 authService.user 为空的评论......(我有一些代码 运行 在登录组件检查之前将其设置为空......)

好的,所以...我找到了一种方法。

首先,澄清我 "felt" 在每个需要从中了解某些信息的组件中订阅 authState 的想法到底有什么问题(无论是登录状态用户、用户文档或声明):

维护(尤其是更改)每个组件中的代码将非常困难,因为我必须更新有关数据检索的任何逻辑。此外,每个组件都必须自己检查数据(例如,检查声明是否包含 'admin'),当然它只能在用户的 login/logout 上完成一次并传播到需要它的人。

在我做的解决方案中,这正是我所做的。与用户的声明和登录状态有关的所有内容都由 authService.

管理

我设法通过使用 RxJS Subjects 做到了这一点。

我的服务、登录组件和导航栏组件的打字稿代码现在如下所示:

auth.service.ts

// imports...

@Injectable()
export class AuthService {

  uid: string = null;
  user: User = null;
  claims: any = {};
  isAdmin = false;

  isLoggedInSubject = new Subject<boolean>();
  userSubject = new Subject();
  claimsSubject = new Subject();
  isAdminSubject = new Subject<boolean>();

  constructor(private afAuth: AngularFireAuth,
              private afStore: AngularFirestore,
              private router: Router,
              private functions: AngularFireFunctions) {

    // the only subsription to authState
    this.afAuth.authState
      .subscribe(
        authUser => {

          if (authUser) { // logged in
            this.isLoggedInSubject.next(true);
            this.uid = authUser.uid;

            this.claims = authUser.getIdTokenResult()
              .then( idTokenResult => {
                this.claims = idTokenResult.claims;
                this.isAdmin = this.hasClaim('admin');
                this.isAdminSubject.next(this.isAdmin);
                this.claimsSubject.next(this.claims);
              });

            this.afStore.doc<User>(`users/${authUser.uid}`).get()
              .subscribe( (snapshot: DocumentSnapshot<User>)  => {
                this.user = snapshot.data();
                this.userSubject.next(this.user);
              });
          }
          else { // logged out
            console.log('Auth Service says: no User is logged in.');
          }
        }
     );
  }

  login(email, password): Promise<any> {
    return this.afAuth.auth.signInWithEmailAndPassword(email, password);
  }

  logout() {
    this.resetState();
    this.afAuth.auth.signOut();
    console.log('User just signed out.');
  }

  hasClaim(claim): boolean {
    return !!this.claims[claim];
  }

  resetState() {
    this.uid = null;
    this.claims = {};
    this.user = null;
    this.isAdmin = false;

    this.isLoggedInSubject.next(false);
    this.isAdminSubject.next(false);
    this.claimsSubject.next(this.claims);
    this.userSubject.next(this.user);
  }

}


login.component.ts

// imports

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {

  providers = AuthProvider;
  form: FormGroup;

  hide = true;
  errorMessage = '';
  loading = false;

  constructor(private fb: FormBuilder,
              public authService: AuthService, // public - since we want to bind it to the HTML
              private router: Router,
              private afStore: AngularFirestore) {}

  ngOnInit() {
    this.form = this.fb.group({
      username: ['test@test.te', Validators.compose([Validators.required, Validators.email])],
      password: ['Asdqwe123', Validators.compose([Validators.required])]
    });
  }


  /**
   * Called after the user successfully logs in via Google. User is created in CloudFirestore with displayName, email etc.
   * @param user - The user received from the ngx-auth-firebase upon successful Google login.
   */
  loginWithGoogleSuccess(user) {
    console.log('\nprovidedLoginWithGoogle(user)');
    console.log(user);
    this.doClaimsNavigation();
  }

  loginWithGoogleError(err) {
    console.log('\nloginWithGoogleError');
    console.log(err);
  }

  submit() {
    this.loading = true;
    this.authService.login(this.form.value.username, this.form.value.password)
      .then(resp => {
        this.loading = false;
        this.doClaimsNavigation();
      })
      .catch(error => {
        this.loading = false;
        const errorCode = error.code;

        if (errorCode === 'auth/wrong-password') {
          this.errorMessage = 'Wrong password!';
        }
        else if (errorCode === 'auth/user-not-found') {
          this.errorMessage = 'User with given username does not exist!';
        } else {
          this.errorMessage = `Error: ${errorCode}.`;
        }

        this.form.reset({username: this.form.value.username, password: ''});
      });

  }


  /**
   * Subscribes to claimsSubject (BehaviorSubject) of authService and routes the app based on the current user's claims.
   *
   *
   * Ensures that the routing only happens AFTER the claims have been loaded to the authService's "claim" property/field.
   */
  doClaimsNavigation() {
    console.log('\nWaiting for claims navigation...')
    this.authService.isAdminSubject
      .pipe(take(1)) // completes the observable after 1 take ==> to not run this after user logs out... because the subject will be updated again
      .subscribe(
        isAdmin => {
          if (isAdmin) {
            this.router.navigate(['/admin']);
          }
          else {
            this.router.navigate(['/items']);
          }
        }
      )
  }

}


导航-bar.component.ts

    // imports

    @Component({
      selector: 'app-nav-bar',
      templateUrl: './nav-bar.component.html',
      styleUrls: ['./nav-bar.component.css']
    })
    export class NavBarComponent implements OnInit {

      navColor = 'primary';
      isLoggedIn = false;
      userSubscription = null;
      isAdmin = false;
      user = null;

      loginClicked = false;
      logoutClicked = false;

      constructor(private authService: AuthService,
                  private router: Router) {

        this.authService.isLoggedInSubject
          .subscribe( isLoggedIn => {
            this.isLoggedIn = isLoggedIn;
          });

        this.authService.isAdminSubject
          .subscribe( isAdmin => {
            this.isAdmin = isAdmin;
          });

        this.authService.userSubject
          .subscribe( user => {
            this.user = user;
          });
         }

      ngOnInit() {}

    }

我希望它能帮助分享我的 "bad feeling" 关于到处订阅 authState 的人。

注: 我会将此标记为已接受的答案,但请随时发表评论并提出问题。