Skip to content

Commit

Permalink
feat: create generic credential and profile reducers (#178)
Browse files Browse the repository at this point in the history
  • Loading branch information
Fran McDade authored and Fran McDade committed Oct 9, 2024
1 parent 539e30d commit 82b5f74
Show file tree
Hide file tree
Showing 25 changed files with 757 additions and 0 deletions.
18 changes: 18 additions & 0 deletions src/config/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,23 @@ export interface AnalyticsConfig {
* Interface to define the authentication configuration for a given site.
*/
export interface AuthenticationConfig {
/**
* @deprecated - Use `provider` instead.
*/
googleGISAuthConfig?: GoogleGISAuthConfig;
provider?: ProviderConfig;
termsOfService?: ReactNode;
terraAuthConfig?: TerraAuthConfig;
text?: ReactNode;
title: string;
warning?: ReactNode;
}

export interface AuthProviderConfig {
endpoint: string;
oauth?: OAuthConfig;
}

/**
* Interface to define the set of components that will be used for the back page.
*/
Expand Down Expand Up @@ -278,6 +287,11 @@ export interface LoginNotice {
privacyUrl: string;
}

export interface OAuthConfig {
client_id: string;
scope: string;
}

/**
* Option Method.
*/
Expand All @@ -302,6 +316,10 @@ export interface Override {
withdrawn?: boolean;
}

export interface ProviderConfig {
[provider: string]: AuthProviderConfig;
}

export interface SavedFilter {
filters: SelectedFilter[];
sorting?: ColumnSort[];
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/authentication/oauth/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type Credentials = string;

export interface AuthenticationService {
login: () => void;
logout: () => void;
}

export interface OAuthResponse {
access_token: string;
}

export interface OAuthRevokeResponse {
error?: boolean;
error_description?: string;
successful?: boolean;
}
105 changes: 105 additions & 0 deletions src/hooks/authentication/oauth/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Dispatch } from "react";
import { AuthProviderConfig } from "../../../../config/entities";
import {
requestCredentials,
revokeCredentials,
} from "../../../../providers/authentication/credentials/dispatch";
import { CredentialsAction } from "../../../../providers/authentication/credentials/types";
import { USER_PROVIDER_KEY } from "../../../../providers/authentication/profile/constants";
import {
requestProfileError,
requestProfileSuccess,
resetProfile,
} from "../../../../providers/authentication/profile/dispatch";
import {
ProfileAction,
UserProfile,
} from "../../../../providers/authentication/profile/types";
import { getAuthenticationRequestOptions } from "../../../useAuthentication/common/utils";
import { fetchProfile } from "../../profile/utils";
import { OAuthResponse, OAuthRevokeResponse } from "./types";

/**
* Throws an error if the provider is not configured.
* @param provider - Provider.
*/
export function assertOAuthProvider(provider?: unknown): void {
if (isOAuthProvider(provider)) return;
throw new Error(
"Open Authentication does not have required token client configured."
);
}

/**
* Handles the OAuth request response.
* - Dispatches request response.
* @param dispatch - Credentials dispatch.
* @param response - OAuth request response.
*/
export function handleOAuthRequest(
dispatch: Dispatch<CredentialsAction<string>> | null,
response: OAuthResponse
): void {
const { access_token } = response;
dispatch?.(requestCredentials(access_token));
}

/**
* Handles the OAuth request response.
* - Fetches profile, and dispatches response.
* @param dispatch - Profile dispatch.
* @param response - OAuth request response.
* @param endpoint - OAuth profile endpoint.
* @param mapProfile - Function; maps profile response to user profile.
*/
export function handleOAuthRequestProfile(
dispatch: Dispatch<ProfileAction<UserProfile | undefined>> | null,
response: OAuthResponse,
endpoint: string,
mapProfile: (r: unknown) => UserProfile | undefined
): void {
const { access_token } = response;
fetchProfile(endpoint, getAuthenticationRequestOptions(access_token), {
onError: () => dispatch?.(requestProfileError(USER_PROVIDER_KEY)),
onSuccess: (r) => {
const profile = mapProfile(r);
dispatch?.(requestProfileSuccess(USER_PROVIDER_KEY, { profile }));
},
});
}

/**
* Handles the OAuth revoke response.
* - Dispatches revoked response.
* @param dispatch - Credentials dispatch.
* @param response - OAuth revoke response.
*/
export function handleOAuthRevoke(
dispatch: Dispatch<CredentialsAction<string>> | null,
response: OAuthRevokeResponse
): void {
const { successful: isSuccess } = response;
dispatch?.(revokeCredentials({ isSuccess }));
}

/**
* Handles resetting the user profile.
* - Dispatches reset profile.
* @param dispatch - Profile dispatch.
*/
export function handleResetProfile(
dispatch: Dispatch<ProfileAction<UserProfile>> | null
): void {
dispatch?.(resetProfile(USER_PROVIDER_KEY));
}

/**
* Returns true if the provider is an OAuth provider.
* @param provider - Provider.
* @returns provider is OAuth provider.
*/
export function isOAuthProvider(
provider: unknown
): provider is AuthProviderConfig {
return (provider as AuthProviderConfig)?.oauth !== undefined;
}
1 change: 1 addition & 0 deletions src/hooks/authentication/oauth/google/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const PROVIDER_KEY = "google";
16 changes: 16 additions & 0 deletions src/hooks/authentication/oauth/google/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface Error {
error: string;
error_description: string;
}

export interface Response {
email: string;
email_verified: boolean;
family_name: string;
given_name: string;
hd: string;
locale: string;
name: string;
picture: string;
sub: string;
}
64 changes: 64 additions & 0 deletions src/hooks/authentication/oauth/google/useGoogleAuthentication.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback } from "react";
import { useCredentials } from "../../../../providers/authentication/credentials/hook";
import { useProfile } from "../../../../providers/authentication/profile/hook";
import { UserProfile } from "../../../../providers/authentication/profile/types";
import { useAuthenticationConfig } from "../../../useAuthenticationConfig";
import {
AuthenticationService,
OAuthResponse,
OAuthRevokeResponse,
} from "../common/types";
import {
assertOAuthProvider,
handleOAuthRequest,
handleOAuthRequestProfile,
handleOAuthRevoke,
handleResetProfile,
} from "../common/utils";
import { PROVIDER_KEY } from "./constants";
import { mapProfile } from "./utils";

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO see https://github.com/clevercanary/data-browser/issues/544.
declare const google: any;

export const useGoogleAuthentication = (): AuthenticationService => {
const {
credentialsDispatch,
credentialsState: { credentials },
} = useCredentials();
const { profileDispatch } = useProfile<UserProfile | undefined>();
const { provider: { [PROVIDER_KEY]: provider } = {} } =
useAuthenticationConfig();
assertOAuthProvider(provider);

const login = useCallback(() => {
const client = google.accounts.oauth2.initTokenClient({
callback: (response: OAuthResponse) => {
handleOAuthRequest(credentialsDispatch, response);
handleOAuthRequestProfile(
profileDispatch,
response,
provider.endpoint,
mapProfile
);
},
...provider.oauth,
});
client.requestAccessToken();
}, [credentialsDispatch, profileDispatch, provider]);

const logout = useCallback(() => {
google.accounts.oauth2.revoke(
credentials,
(response: OAuthRevokeResponse) => {
handleOAuthRevoke(credentialsDispatch, response);
handleResetProfile(profileDispatch);
}
);
}, [credentials, credentialsDispatch, profileDispatch]);

return {
login,
logout,
};
};
42 changes: 42 additions & 0 deletions src/hooks/authentication/oauth/google/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { UserProfile } from "../../../../providers/authentication/profile/types";
import { Response } from "./types";

/**
* Returns full name, from given and family name.
* @param response - Google response.
* @returns full name.
*/
function getFullName(response: Response): string {
const { family_name: lastName = "", given_name: firstName = "" } = response;
return `${firstName} ${lastName}`.trim();
}

/**
* Returns true if response is a google profile.
* @param response - Google response.
* @returns response is google profile.
*/
function isProfileGoogle(response: unknown): response is Response {
return (
(response as Response)?.email !== undefined &&
(response as Response)?.family_name !== undefined &&
(response as Response)?.given_name !== undefined
);
}

/**
* Returns user profile from google response.
* @param response - Google response.
* @returns user profile.
*/
export function mapProfile(response: unknown): UserProfile | undefined {
if (isProfileGoogle(response)) {
const { email, picture } = response;
const fullName = getFullName(response);
return {
email,
fullName,
picture,
};
}
}
4 changes: 4 additions & 0 deletions src/hooks/authentication/profile/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface ProfileHandler<R, E> {
onError: (error: E) => void;
onSuccess: (response: R) => void;
}
16 changes: 16 additions & 0 deletions src/hooks/authentication/profile/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ProfileHandler } from "./types";

export function fetchProfile<R, E>(
endpoint: string,
options?: RequestInit,
handler?: ProfileHandler<R, E>
): void {
fetch(endpoint, options)
.then((response) => response.json())
.then((r: R) => {
handler?.onSuccess(r);
})
.catch((e: E) => {
handler?.onError(e);
});
}
38 changes: 38 additions & 0 deletions src/providers/authentication/credentials/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
CredentialsState,
RequestCredentialsPayload,
RevokeCredentialsPayload,
} from "./types";

/**
* Request credentials action.
* @param state - State.
* @param payload - Payload.
* @returns state.
*/
export function requestCredentialsAction<C>(
state: CredentialsState<C>,
payload: RequestCredentialsPayload<C>
): CredentialsState<C> {
return {
...state,
credentials: payload,
};
}

/**
* Revoke credentials action.
* @param state - State.
* @param payload - Payload.
* @returns state.
*/
export function revokeCredentialsAction<C>(
state: CredentialsState<C>,
payload: RevokeCredentialsPayload
): CredentialsState<C> {
if (!payload.isSuccess) throw new Error("Failed to revoke credentials.");
return {
...state,
credentials: undefined,
};
}
10 changes: 10 additions & 0 deletions src/providers/authentication/credentials/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createContext } from "react";
import { CredentialsContextProps } from "./types";
import { initCredentials } from "./utils";

export const CredentialsContext = createContext<
CredentialsContextProps<unknown>
>({
credentialsDispatch: null,
credentialsState: initCredentials<unknown>(),
});
Loading

0 comments on commit 82b5f74

Please sign in to comment.