从单个服务订阅、获取和传播 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 应用程序中获取和使用自定义声明的正确(最佳)方法。我通过云功能添加了管理员自定义声明。我现在想要拥有的(以及我之前尝试做的)是:
- 在一项服务中获取声明(和登录用户)(例如
auth.service
)
- 允许所有其他需要读取声明的组件通过来自该服务的简单 API 来读取声明
- 不要让其他组件订阅 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
的人。
注:
我会将此标记为已接受的答案,但请随时发表评论并提出问题。
我正在尝试 find/create 在 Angular 应用程序中获取和使用自定义声明的正确(最佳)方法。我通过云功能添加了管理员自定义声明。我现在想要拥有的(以及我之前尝试做的)是:
- 在一项服务中获取声明(和登录用户)(例如
auth.service
) - 允许所有其他需要读取声明的组件通过来自该服务的简单 API 来读取声明
- 不要让其他组件订阅 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
的人。
注: 我会将此标记为已接受的答案,但请随时发表评论并提出问题。