src/amo/reducers/users.js (656 lines of code) (raw):
/* @flow */
import config from 'config';
import invariant from 'invariant';
import { LOCATION_CHANGE } from 'redux-first-history';
import {
ADDONS_CONTENT_REVIEW,
ADDONS_EDIT,
ADDONS_RECOMMENDED_REVIEW,
ADDONS_REVIEW,
ADDONS_REVIEW_UNLISTED,
ALL_SUPER_POWERS,
RATINGS_MODERATE,
REVIEWER_TOOLS_VIEW,
STATIC_THEMES_REVIEW,
} from 'amo/constants';
import type { AppState } from 'amo/store';
import type { ExternalSiteStatus } from 'amo/reducers/site';
export const FINISH_UPDATE_USER_ACCOUNT: 'FINISH_UPDATE_USER_ACCOUNT' =
'FINISH_UPDATE_USER_ACCOUNT';
export const UPDATE_USER_ACCOUNT: 'UPDATE_USER_ACCOUNT' = 'UPDATE_USER_ACCOUNT';
export const LOG_OUT_USER: 'LOG_OUT_USER' = 'LOG_OUT_USER';
export const LOAD_CURRENT_USER_ACCOUNT: 'LOAD_CURRENT_USER_ACCOUNT' =
'LOAD_CURRENT_USER_ACCOUNT';
export const FETCH_USER_ACCOUNT: 'FETCH_USER_ACCOUNT' = 'FETCH_USER_ACCOUNT';
export const LOAD_USER_ACCOUNT: 'LOAD_USER_ACCOUNT' = 'LOAD_USER_ACCOUNT';
export const DELETE_USER_PICTURE: 'DELETE_USER_PICTURE' = 'DELETE_USER_PICTURE';
export const FETCH_USER_NOTIFICATIONS: 'FETCH_USER_NOTIFICATIONS' =
'FETCH_USER_NOTIFICATIONS';
export const LOAD_USER_NOTIFICATIONS: 'LOAD_USER_NOTIFICATIONS' =
'LOAD_USER_NOTIFICATIONS';
export const DELETE_USER_ACCOUNT: 'DELETE_USER_ACCOUNT' = 'DELETE_USER_ACCOUNT';
export const UNLOAD_USER_ACCOUNT: 'UNLOAD_USER_ACCOUNT' = 'UNLOAD_USER_ACCOUNT';
export const UNSUBSCRIBE_NOTIFICATION: 'UNSUBSCRIBE_NOTIFICATION' =
'UNSUBSCRIBE_NOTIFICATION';
export const ABORT_UNSUBSCRIBE_NOTIFICATION: 'ABORT_UNSUBSCRIBE_NOTIFICATION' =
'ABORT_UNSUBSCRIBE_NOTIFICATION';
export const FINISH_UNSUBSCRIBE_NOTIFICATION: 'FINISH_UNSUBSCRIBE_NOTIFICATION' =
'FINISH_UNSUBSCRIBE_NOTIFICATION';
export type UserId = number;
export type NotificationType = {|
enabled: boolean,
mandatory: boolean,
name: string,
|};
export type NotificationsType = Array<NotificationType>;
export type NotificationsUpdateType = { [name: string]: boolean };
export type BaseExternalUserType = {|
id: number,
name: string,
username: string,
// Properties returned for a developer
average_addon_rating?: number,
biography?: string | null,
created?: string,
has_anonymous_display_name?: boolean,
has_anonymous_username?: boolean,
homepage?: string | null,
is_addon_developer?: boolean,
is_artist?: boolean,
location?: string | null,
num_addons_listed?: number,
occupation?: string | null,
picture_type?: string | null,
picture_url?: string | null,
// Further Properties returned if we are accessing our own profile or the current
// user has the `Users:Edit` permission.
deleted?: boolean,
display_name: string | null,
email?: string,
fxa_edit_email_url?: string,
is_verified?: boolean,
last_login?: string,
last_login_ip?: string,
permissions?: Array<string>,
read_dev_agreement?: boolean,
|};
// Basic user account object fields, returned by the API. You can update
// `BaseExternalUserType` to add attributes that you want to store in the Redux
// state. The attributes added below are part of the Users API response but we
// do not want to store them in the`UsersState`.
export type ExternalUserType = {|
...BaseExternalUserType,
site_status: ExternalSiteStatus,
|};
export type UserType = {|
...BaseExternalUserType,
notifications: NotificationsType | null,
|};
export type UsersState = {
currentUserID: UserId | null,
byID: { [userId: UserId]: UserType },
byUsername: { [username: string]: UserId },
isUpdating: boolean,
userPageBeingViewed: {
loading: boolean,
userId: UserId | null,
},
isUnsubscribedFor: {
// `undefined`: no API call has been made yet
// `null`: an error has occurred
// `false`: unsubscribing in progress
// `true`: user is unsubscribed
[key: string]: boolean | null,
},
currentUserWasLoggedOut: boolean,
resetStateOnNextChange: boolean,
};
export type UserEditableFieldsType = {|
biography?: string | null,
display_name?: string | null,
homepage?: string | null,
location?: string | null,
occupation?: string | null,
username?: string | null,
|};
export const initialState: UsersState = {
currentUserID: null,
byID: {},
byUsername: {},
isUpdating: false,
userPageBeingViewed: {
loading: false,
userId: null,
},
isUnsubscribedFor: {},
currentUserWasLoggedOut: false,
resetStateOnNextChange: false,
};
type FetchUserAccountParams = {|
errorHandlerId: string,
userId: UserId,
|};
export type FetchUserAccountAction = {|
type: typeof FETCH_USER_ACCOUNT,
payload: FetchUserAccountParams,
|};
export const fetchUserAccount = ({
errorHandlerId,
userId,
}: FetchUserAccountParams): FetchUserAccountAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(userId, 'userId is required');
return {
type: FETCH_USER_ACCOUNT,
payload: {
errorHandlerId,
userId,
},
};
};
type finishUpdateUserAccountParams = {};
type FinishUpdateUserAccountAction = {|
type: typeof FINISH_UPDATE_USER_ACCOUNT,
payload: finishUpdateUserAccountParams,
|};
export const finishUpdateUserAccount = (): FinishUpdateUserAccountAction => {
return {
type: FINISH_UPDATE_USER_ACCOUNT,
payload: {},
};
};
type UpdateUserAccountParams = {|
errorHandlerId: string,
notifications: NotificationsUpdateType,
picture: File | null,
pictureData: string | null,
userFields: UserEditableFieldsType,
userId: UserId,
|};
export type UpdateUserAccountAction = {|
type: typeof UPDATE_USER_ACCOUNT,
payload: UpdateUserAccountParams,
|};
export const updateUserAccount = ({
errorHandlerId,
notifications,
picture,
pictureData,
userFields,
userId,
}: UpdateUserAccountParams): UpdateUserAccountAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(notifications, 'notifications are required');
invariant(userFields, 'userFields are required');
invariant(userId, 'userId is required');
invariant(picture !== undefined, 'picture is required');
if (picture) {
invariant(pictureData, 'pictureData is required when picture is present');
}
return {
type: UPDATE_USER_ACCOUNT,
payload: {
errorHandlerId,
notifications,
picture,
pictureData,
userFields,
userId,
},
};
};
type LoadCurrentUserAccountParams = {|
user: ExternalUserType,
|};
type LoadCurrentUserAccountAction = {|
type: typeof LOAD_CURRENT_USER_ACCOUNT,
payload: LoadCurrentUserAccountParams,
|};
export const loadCurrentUserAccount = ({
user,
}: LoadCurrentUserAccountParams): LoadCurrentUserAccountAction => {
invariant(user, 'user is required');
return {
type: LOAD_CURRENT_USER_ACCOUNT,
payload: { user },
};
};
type LoadUserAccountParams = {|
user: ExternalUserType,
|};
type LoadUserAccountAction = {|
type: typeof LOAD_USER_ACCOUNT,
payload: LoadUserAccountParams,
|};
export const loadUserAccount = ({
user,
}: LoadUserAccountParams): LoadUserAccountAction => {
invariant(user, 'user is required');
return {
type: LOAD_USER_ACCOUNT,
payload: { user },
};
};
export type DeleteUserAccountParams = {|
errorHandlerId: string,
userId: UserId,
|};
export type DeleteUserAccountAction = {|
type: typeof DELETE_USER_ACCOUNT,
payload: DeleteUserAccountParams,
|};
export const deleteUserAccount = ({
errorHandlerId,
userId,
}: DeleteUserAccountParams): DeleteUserAccountAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(userId, 'userId is required');
return {
type: DELETE_USER_ACCOUNT,
payload: {
errorHandlerId,
userId,
},
};
};
type UnloadUserAccountParams = {|
userId: UserId,
|};
type UnloadUserAccountAction = {|
type: typeof UNLOAD_USER_ACCOUNT,
payload: UnloadUserAccountParams,
|};
export const unloadUserAccount = ({
userId,
}: UnloadUserAccountParams): UnloadUserAccountAction => {
invariant(userId, 'userId is required');
return {
type: UNLOAD_USER_ACCOUNT,
payload: { userId },
};
};
export type LogOutUserAction = {|
type: string,
payload: Object,
|};
export function logOutUser(): LogOutUserAction {
return {
type: LOG_OUT_USER,
payload: {},
};
}
export type DeleteUserPictureParams = {|
errorHandlerId: string,
userId: UserId,
|};
export type DeleteUserPictureAction = {|
type: typeof DELETE_USER_PICTURE,
payload: DeleteUserPictureParams,
|};
export const deleteUserPicture = ({
errorHandlerId,
userId,
}: DeleteUserPictureParams): DeleteUserPictureAction => {
return {
type: DELETE_USER_PICTURE,
payload: {
errorHandlerId,
userId,
},
};
};
type FetchUserNotificationsParams = {|
errorHandlerId: string,
userId: UserId,
|};
export type FetchUserNotificationsAction = {|
type: typeof FETCH_USER_NOTIFICATIONS,
payload: FetchUserNotificationsParams,
|};
export const fetchUserNotifications = ({
errorHandlerId,
userId,
}: FetchUserNotificationsParams): FetchUserNotificationsAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(userId, 'userId is required');
return {
type: FETCH_USER_NOTIFICATIONS,
payload: { errorHandlerId, userId },
};
};
type LoadUserNotificationsParams = {|
notifications: NotificationsType,
userId: UserId,
|};
type LoadUserNotificationsAction = {|
type: typeof LOAD_USER_NOTIFICATIONS,
payload: LoadUserNotificationsParams,
|};
export const loadUserNotifications = ({
notifications,
userId,
}: LoadUserNotificationsParams): LoadUserNotificationsAction => {
invariant(notifications, 'notifications is required');
invariant(userId, 'userId is required');
return {
type: LOAD_USER_NOTIFICATIONS,
payload: { notifications, userId },
};
};
type UnsubscribeNotificationParams = {|
errorHandlerId: string,
hash: string,
notification: string,
token: string,
|};
export type UnsubscribeNotificationAction = {|
type: typeof UNSUBSCRIBE_NOTIFICATION,
payload: UnsubscribeNotificationParams,
|};
export const unsubscribeNotification = ({
errorHandlerId,
hash,
notification,
token,
}: UnsubscribeNotificationParams): UnsubscribeNotificationAction => {
invariant(errorHandlerId, 'errorHandlerId is required');
invariant(hash, 'hash is required');
invariant(notification, 'notification is required');
invariant(token, 'token is required');
return {
type: UNSUBSCRIBE_NOTIFICATION,
payload: { errorHandlerId, hash, notification, token },
};
};
export const getUserById = (users: UsersState, userId: UserId): UserType => {
invariant(typeof userId === 'number', 'userId is required');
return users.byID[userId];
};
export const getUserByUsername = (
users: UsersState,
username: string,
): UserType => {
invariant(username, 'username is required');
return users.byID[users.byUsername[username.toLowerCase()]];
};
export const getCurrentUser = (users: UsersState): UserType | null => {
if (!users.currentUserID) {
return null;
}
const currentUser = getUserById(users, users.currentUserID);
invariant(
currentUser,
'currentUserID is defined but no matching user found in users state.',
);
return currentUser;
};
export const isDeveloper = (user: UserType | null): boolean => {
if (!user) {
return false;
}
return user.is_addon_developer || user.is_artist || false;
};
export const hasPermission = (state: AppState, permission: string): boolean => {
const currentUser = getCurrentUser(state.users);
// If the user isn't authenticated, they have no permissions.
if (!currentUser) {
return false;
}
const { permissions } = currentUser;
if (!permissions) {
return false;
}
// Admins have absolutely all permissions.
if (permissions.includes(ALL_SUPER_POWERS)) {
return true;
}
// Match exact permissions.
if (permissions.includes(permission)) {
return true;
}
// See: https://github.com/mozilla/addons-frontend/issues/8575
const appsWithAllPermissions = permissions
// Only consider permissions with wildcards.
.filter((perm) => perm.endsWith(':*'))
// Return the permission "app".
// See: https://github.com/mozilla/addons-server/blob/3a15aafb703349923ee2eb9a9f7b527ba9b16c03/src/olympia/constants/permissions.py#L4
.map((perm) => perm.replace(':*', ''));
const app = permission.split(':')[0];
return appsWithAllPermissions.includes(app);
};
export const hasAnyReviewerRelatedPermission = (state: AppState): boolean => {
const currentUser = getCurrentUser(state.users);
// If the user isn't authenticated, they have no permissions.
if (!currentUser) {
return false;
}
const { permissions } = currentUser;
if (!permissions) {
return false;
}
// Admins have absolutely all permissions.
if (permissions.includes(ALL_SUPER_POWERS)) {
return true;
}
return (
permissions.includes(ADDONS_CONTENT_REVIEW) ||
permissions.includes(ADDONS_EDIT) ||
permissions.includes(ADDONS_RECOMMENDED_REVIEW) ||
permissions.includes(ADDONS_REVIEW) ||
permissions.includes(ADDONS_REVIEW_UNLISTED) ||
permissions.includes(RATINGS_MODERATE) ||
permissions.includes(REVIEWER_TOOLS_VIEW) ||
permissions.includes(STATIC_THEMES_REVIEW)
);
};
export const addUserToState = ({
state,
user,
}: {
user: ExternalUserType,
state: UsersState,
}): {|
byID: { [userId: UserId]: UserType },
byUsername: { [username: string]: UserId },
|} => {
invariant(user, 'user is required');
const existingUser = getUserById(state, user.id) || {
notifications: null,
};
const byID = {
...state.byID,
[user.id]: {
...existingUser,
...user,
},
};
const byUsername = {
...state.byUsername,
[user.username.toLowerCase()]: user.id,
};
return { byID, byUsername };
};
type FinishUnsubscribeNotificationParams = {|
hash: string,
notification: string,
token: string,
|};
export type FinishUnsubscribeNotificationAction = {|
type: typeof FINISH_UNSUBSCRIBE_NOTIFICATION,
payload: FinishUnsubscribeNotificationParams,
|};
export const finishUnsubscribeNotification = ({
hash,
notification,
token,
}: FinishUnsubscribeNotificationParams): FinishUnsubscribeNotificationAction => {
invariant(hash, 'hash is required');
invariant(notification, 'notification is required');
invariant(token, 'token is required');
return {
type: FINISH_UNSUBSCRIBE_NOTIFICATION,
payload: { hash, notification, token },
};
};
type AbortUnsubscribeNotificationParams = {|
hash: string,
notification: string,
token: string,
|};
export type AbortUnsubscribeNotificationAction = {|
type: typeof ABORT_UNSUBSCRIBE_NOTIFICATION,
payload: AbortUnsubscribeNotificationParams,
|};
export const abortUnsubscribeNotification = ({
hash,
notification,
token,
}: AbortUnsubscribeNotificationParams): AbortUnsubscribeNotificationAction => {
invariant(hash, 'hash is required');
invariant(notification, 'notification is required');
invariant(token, 'token is required');
return {
type: ABORT_UNSUBSCRIBE_NOTIFICATION,
payload: { hash, notification, token },
};
};
type GetUnsubscribeKeyParams = {|
hash: string,
notification: string,
token: string,
|};
export const getUnsubscribeKey = ({
hash,
notification,
token,
}: GetUnsubscribeKeyParams): string => {
invariant(hash, 'hash is required');
invariant(notification, 'notification is required');
invariant(token, 'token is required');
return `${hash}-${notification}-${token}`;
};
export const isUnsubscribedFor = (
usersState: UsersState,
hash: string,
notification: string,
token: string,
): null | boolean | void => {
return usersState.isUnsubscribedFor[
getUnsubscribeKey({ hash, notification, token })
];
};
type Action =
| AbortUnsubscribeNotificationAction
| FetchUserAccountAction
| FetchUserNotificationsAction
| FinishUnsubscribeNotificationAction
| FinishUpdateUserAccountAction
| LoadCurrentUserAccountAction
| LoadUserAccountAction
| LoadUserNotificationsAction
| LogOutUserAction
| UnsubscribeNotificationAction
| UpdateUserAccountAction;
const reducer = (
// eslint-disable-next-line default-param-last
state: UsersState = initialState,
action: Action,
_config: typeof config = config,
): UsersState => {
switch (action.type) {
case UPDATE_USER_ACCOUNT:
return {
...state,
isUpdating: true,
};
case FINISH_UPDATE_USER_ACCOUNT:
return {
...state,
isUpdating: false,
};
case LOAD_CURRENT_USER_ACCOUNT: {
const { user } = action.payload;
return {
...state,
...addUserToState({ state, user }),
currentUserID: user.id,
};
}
case LOAD_USER_ACCOUNT: {
const { user } = action.payload;
return {
...state,
...addUserToState({ state, user }),
};
}
case LOAD_USER_NOTIFICATIONS: {
const { notifications, userId } = action.payload;
const user = getUserById(state, userId);
invariant(user, 'user is required');
invariant(notifications, 'notifications are required');
return {
...state,
byID: {
...state.byID,
[user.id]: {
...user,
notifications,
},
},
};
}
case LOG_OUT_USER:
return {
...state,
currentUserID: null,
currentUserWasLoggedOut: true,
};
case UNLOAD_USER_ACCOUNT: {
const { userId } = action.payload;
if (state.byID[userId]) {
const { username } = state.byID[userId];
return {
...state,
currentUserID:
state.currentUserID === userId ? null : state.currentUserID,
byID: {
...state.byID,
[userId]: undefined,
},
byUsername: {
...state.byUsername,
[username]: undefined,
},
};
}
return state;
}
case UNSUBSCRIBE_NOTIFICATION: {
const { hash, notification, token } = action.payload;
return {
...state,
isUnsubscribedFor: {
...state.isUnsubscribedFor,
[getUnsubscribeKey({ hash, notification, token })]: false,
},
};
}
case ABORT_UNSUBSCRIBE_NOTIFICATION: {
const { hash, notification, token } = action.payload;
return {
...state,
isUnsubscribedFor: {
...state.isUnsubscribedFor,
[getUnsubscribeKey({ hash, notification, token })]: null,
},
};
}
case FINISH_UNSUBSCRIBE_NOTIFICATION: {
const { hash, notification, token } = action.payload;
return {
...state,
isUnsubscribedFor: {
...state.isUnsubscribedFor,
[getUnsubscribeKey({ hash, notification, token })]: true,
},
};
}
case LOCATION_CHANGE: {
if (_config.get('server')) {
// We only care about client side navigation.
return state;
}
// When the client initializes, it updates its location. On next location
// change, we want to reset `currentUserWasLoggedOut`.
if (state.resetStateOnNextChange) {
return {
...state,
resetStateOnNextChange: false,
currentUserWasLoggedOut: false,
};
}
return {
...state,
// This will only be set *after* a single location change on the client.
resetStateOnNextChange: true,
};
}
default:
return state;
}
};
export default reducer;