Compare commits

...

1 Commits

Author SHA1 Message Date
Florian Rival
7232311fa3 Add support for setting a full name for a student account 2025-08-25 19:08:55 +02:00
18 changed files with 381 additions and 142 deletions

View File

@@ -65,7 +65,7 @@ export const AnnouncementsFeed = ({
const classesForClickableContainer = useStylesForClickableContainer(); const classesForClickableContainer = useStylesForClickableContainer();
if (error) { if (error && !hideLoader) {
return ( return (
<PlaceholderError onRetry={fetchAnnouncementsAndPromotions}> <PlaceholderError onRetry={fetchAnnouncementsAndPromotions}>
<Trans> <Trans>

View File

@@ -19,7 +19,10 @@ import {
} from '../../Utils/GDevelopServices/Project'; } from '../../Utils/GDevelopServices/Project';
import PlaceholderLoader from '../../UI/PlaceholderLoader'; import PlaceholderLoader from '../../UI/PlaceholderLoader';
import PlaceholderError from '../../UI/PlaceholderError'; import PlaceholderError from '../../UI/PlaceholderError';
import { getUserPublicProfilesByIds } from '../../Utils/GDevelopServices/User'; import {
getUserPublicProfilesByIds,
type UserPublicProfileByIds,
} from '../../Utils/GDevelopServices/User';
import Dialog, { DialogPrimaryButton } from '../../UI/Dialog'; import Dialog, { DialogPrimaryButton } from '../../UI/Dialog';
import FlatButton from '../../UI/FlatButton'; import FlatButton from '../../UI/FlatButton';
import LeftLoader from '../../UI/LeftLoader'; import LeftLoader from '../../UI/LeftLoader';
@@ -84,9 +87,10 @@ const InviteHome = ({ cloudProjectId }: Props) => {
| 'unexpected' | 'unexpected'
| null | null
>(null); >(null);
const [userPublicProfileByIds, setUserPublicProfileByIds] = React.useState( const [
{} userPublicProfileByIds,
); setUserPublicProfileByIds,
] = React.useState<UserPublicProfileByIds>({});
const [ const [
showCollaboratorAddDialog, showCollaboratorAddDialog,
setShowCollaboratorAddDialog, setShowCollaboratorAddDialog,
@@ -312,6 +316,7 @@ const InviteHome = ({ cloudProjectId }: Props) => {
<ColumnStackLayout expand noMargin> <ColumnStackLayout expand noMargin>
<UserLine <UserLine
username={profile.username} username={profile.username}
fullName={profile.fullName}
email={profile.email} email={profile.email}
level={currentUserLevel} level={currentUserLevel}
/> />
@@ -365,6 +370,7 @@ const InviteHome = ({ cloudProjectId }: Props) => {
projectUserAcls.map(projectUserAcl => ( projectUserAcls.map(projectUserAcl => (
<UserLine <UserLine
username={getCollaboratorUsername(projectUserAcl.userId)} username={getCollaboratorUsername(projectUserAcl.userId)}
fullName={null}
email={projectUserAcl.email} email={projectUserAcl.email}
level={projectUserAcl.level} level={projectUserAcl.level}
onDelete={() => { onDelete={() => {

View File

@@ -0,0 +1,119 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import FlatButton from '../../../../../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../../../../../UI/Dialog';
import { ColumnStackLayout } from '../../../../../UI/Layout';
import {
type User,
type EditUserChanges,
type UsernameAvailability,
} from '../../../../../Utils/GDevelopServices/User';
import LeftLoader from '../../../../../UI/LeftLoader';
import SemiControlledTextField from '../../../../../UI/SemiControlledTextField';
import { UsernameField } from '../../../../../Profile/UsernameField';
import AlertMessage from '../../../../../UI/AlertMessage';
type Props = {|
member: User,
onApply: (changes: EditUserChanges) => Promise<void>,
onClose: () => void,
isSaving: boolean,
error: ?Error,
|};
export const EditStudentDialog = ({
member,
onApply,
onClose,
isSaving,
error,
}: Props) => {
const [changes, setChanges] = React.useState<EditUserChanges | null>(null);
const [
usernameAvailability,
setUsernameAvailability,
] = React.useState<?UsernameAvailability>(null);
const [
isValidatingUsername,
setIsValidatingUsername,
] = React.useState<boolean>(false);
const canSave =
!usernameAvailability || (usernameAvailability.isAvailable && !!changes);
const triggerApply = React.useCallback(
async () => {
if (!canSave || !changes) return;
await onApply(changes);
},
[changes, canSave, onApply]
);
return (
<Dialog
open
title={<Trans>Edit student</Trans>}
actions={[
<FlatButton
key="close"
label={<Trans>Close</Trans>}
primary={false}
onClick={onClose}
disabled={isSaving}
id="close-button"
/>,
<LeftLoader isLoading={isSaving}>
<DialogPrimaryButton
key="apply"
primary
label={<Trans>Apply</Trans>}
onClick={triggerApply}
disabled={isSaving || !canSave}
id="apply-button"
/>
</LeftLoader>,
]}
onRequestClose={onClose}
onApply={triggerApply}
maxWidth="sm"
>
<ColumnStackLayout expand noMargin>
{error && (
<AlertMessage kind="error">
<Trans>
An error occurred while editing the student. Please try again.
</Trans>
</AlertMessage>
)}
<SemiControlledTextField
onChange={fullName => setChanges({ ...changes, fullName })}
value={
changes && typeof changes.fullName !== 'undefined'
? changes.fullName
: member.fullName || ''
}
disabled={isSaving}
fullWidth
commitOnBlur
floatingLabelText={<Trans>Full name</Trans>}
/>
<UsernameField
initialUsername={member.username}
value={
changes && typeof changes.username !== 'undefined'
? changes.username
: member.username || ''
}
onChange={(_, username) => setChanges({ ...changes, username })}
disabled={isSaving}
allowEmpty
onAvailabilityChecked={setUsernameAvailability}
onAvailabilityCheckLoading={setIsValidatingUsername}
isValidatingUsername={isValidatingUsername}
/>
</ColumnStackLayout>
</Dialog>
);
};

View File

@@ -3,7 +3,10 @@
import * as React from 'react'; import * as React from 'react';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import Grid from '@material-ui/core/Grid'; import Grid from '@material-ui/core/Grid';
import { type User } from '../../../../../Utils/GDevelopServices/User'; import {
type User,
type EditUserChanges,
} from '../../../../../Utils/GDevelopServices/User';
import Text from '../../../../../UI/Text'; import Text from '../../../../../UI/Text';
import { LineStackLayout } from '../../../../../UI/Layout'; import { LineStackLayout } from '../../../../../UI/Layout';
import Checkbox from '../../../../../UI/Checkbox'; import Checkbox from '../../../../../UI/Checkbox';
@@ -12,9 +15,11 @@ import CheckboxChecked from '../../../../../UI/CustomSvgIcons/CheckboxChecked';
import AsyncSemiControlledTextField from '../../../../../UI/AsyncSemiControlledTextField'; import AsyncSemiControlledTextField from '../../../../../UI/AsyncSemiControlledTextField';
import IconButton from '../../../../../UI/IconButton'; import IconButton from '../../../../../UI/IconButton';
import Key from '../../../../../UI/CustomSvgIcons/Key'; import Key from '../../../../../UI/CustomSvgIcons/Key';
import Edit from '../../../../../UI/CustomSvgIcons/Edit';
import { Line } from '../../../../../UI/Grid'; import { Line } from '../../../../../UI/Grid';
import { useResponsiveWindowSize } from '../../../../../UI/Responsive/ResponsiveWindowMeasurer'; import { useResponsiveWindowSize } from '../../../../../UI/Responsive/ResponsiveWindowMeasurer';
import { textEllipsisStyle } from '../../../../../UI/TextEllipsis'; import { textEllipsisStyle } from '../../../../../UI/TextEllipsis';
import { EditStudentDialog } from './EditStudentDialog';
const primaryTextArchivedOpacity = 0.6; const primaryTextArchivedOpacity = 0.6;
const primaryTextPlaceholderOpacity = 0.7; const primaryTextPlaceholderOpacity = 0.7;
@@ -47,6 +52,10 @@ type Props = {|
userId: string, userId: string,
newPassword: string, newPassword: string,
|}) => Promise<void>, |}) => Promise<void>,
onEdit: ({|
editedUserId: string,
changes: EditUserChanges,
|}) => Promise<void>,
|}; |};
const ManageStudentRow = ({ const ManageStudentRow = ({
@@ -55,11 +64,18 @@ const ManageStudentRow = ({
isArchived, isArchived,
onSelect, onSelect,
onChangePassword, onChangePassword,
onEdit,
}: Props) => { }: Props) => {
const { isMobile } = useResponsiveWindowSize(); const { isMobile } = useResponsiveWindowSize();
const [isEditingPassword, setIsEditingPassword] = React.useState<boolean>( const [isEditingPassword, setIsEditingPassword] = React.useState<boolean>(
false false
); );
const [isSavingUser, setIsSavingUser] = React.useState<boolean>(false);
const [isEditingUser, setIsEditingUser] = React.useState<boolean>(false);
const [editedUserError, setEditedUserError] = React.useState<Error | null>(
null
);
const [ const [
passwordEditionError, passwordEditionError,
setPasswordEditionError, setPasswordEditionError,
@@ -110,15 +126,30 @@ const ManageStudentRow = ({
uncheckedIcon={<CheckboxUnchecked />} uncheckedIcon={<CheckboxUnchecked />}
checkedIcon={<CheckboxChecked />} checkedIcon={<CheckboxChecked />}
/> />
{member.username ? ( {member.username || member.fullName ? (
<Text <Text
allowSelection allowSelection
style={{ style={{
...textEllipsisStyle, ...textEllipsisStyle,
opacity: isArchived ? primaryTextArchivedOpacity : undefined, opacity: isArchived ? primaryTextArchivedOpacity : undefined,
}} }}
tooltip={[member.fullName, member.username]
.filter(Boolean)
.join(' - ')}
> >
<b>{member.username}</b> <b>
{member.fullName ? (
member.username ? (
<Trans>
{member.fullName} ({member.username})
</Trans>
) : (
member.fullName
)
) : (
member.username
)}
</b>
</Text> </Text>
) : ( ) : (
<Text <Text
@@ -136,6 +167,15 @@ const ManageStudentRow = ({
</i> </i>
</Text> </Text>
)} )}
{!isArchived && (
<IconButton
size="small"
onClick={() => setIsEditingUser(true)}
tooltip={t`Change username or full name`}
>
<Edit fontSize="small" />
</IconButton>
)}
</LineStackLayout> </LineStackLayout>
); );
@@ -205,6 +245,31 @@ const ManageStudentRow = ({
</LineStackLayout> </LineStackLayout>
); );
const editDialog = isEditingUser && (
<EditStudentDialog
member={member}
isSaving={isSavingUser}
error={editedUserError}
onApply={async (changes: EditUserChanges) => {
try {
setEditedUserError(null);
setIsSavingUser(true);
await onEdit({ editedUserId: member.id, changes });
setIsEditingUser(false);
} catch (error) {
console.error('An error occurred while editing user:', error);
setEditedUserError(error);
} finally {
setIsSavingUser(false);
}
}}
onClose={() => {
setIsEditingUser(false);
setEditedUserError(null);
}}
/>
);
if (isMobile) { if (isMobile) {
return ( return (
<> <>
@@ -217,21 +282,23 @@ const ManageStudentRow = ({
<Grid item xs={4} style={isMobile ? styles.mobileCell : styles.cell}> <Grid item xs={4} style={isMobile ? styles.mobileCell : styles.cell}>
{passwordCell} {passwordCell}
</Grid> </Grid>
{editDialog}
</> </>
); );
} }
return ( return (
<> <>
<Grid item xs={5} style={isMobile ? styles.mobileCell : styles.cell}> <Grid item xs={9} style={isMobile ? styles.mobileCell : styles.cell}>
<LineStackLayout noMargin alignItems="center"> <LineStackLayout noMargin alignItems="center">
{usernameCell} {usernameCell}
{emailCell} {emailCell}
</LineStackLayout> </LineStackLayout>
</Grid> </Grid>
<Grid item xs={7} style={isMobile ? styles.mobileCell : styles.cell}> <Grid item xs={3} style={isMobile ? styles.mobileCell : styles.cell}>
{passwordCell} {passwordCell}
</Grid> </Grid>
{editDialog}
</> </>
); );
}; };

View File

@@ -56,6 +56,7 @@ import { selectMessageByLocale } from '../../../../../Utils/i18n/MessageByLocale
import TextButton from '../../../../../UI/TextButton'; import TextButton from '../../../../../UI/TextButton';
import Chip from '../../../../../UI/Chip'; import Chip from '../../../../../UI/Chip';
import { SubscriptionSuggestionContext } from '../../../../../Profile/Subscription/SubscriptionSuggestionContext'; import { SubscriptionSuggestionContext } from '../../../../../Profile/Subscription/SubscriptionSuggestionContext';
import { type EditUserChanges } from '../../../../../Utils/GDevelopServices/User';
const styles = { const styles = {
selectedMembersControlsContainer: { selectedMembersControlsContainer: {
@@ -324,6 +325,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
onActivateMembers, onActivateMembers,
onSetAdmin, onSetAdmin,
onRefreshAdmins, onRefreshAdmins,
onEditUser,
} = React.useContext(TeamContext); } = React.useContext(TeamContext);
React.useEffect( React.useEffect(
@@ -337,6 +339,20 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
[credentialsCopySuccess] [credentialsCopySuccess]
); );
const onEditTeamMember = React.useCallback(
async ({
editedUserId,
changes,
}: {|
editedUserId: string,
changes: EditUserChanges,
|}) => {
await onEditUser(editedUserId, changes);
await onRefreshMembers();
},
[onEditUser, onRefreshMembers]
);
const onChangeTeamMemberPassword = React.useCallback( const onChangeTeamMemberPassword = React.useCallback(
async ({ async ({
userId, userId,
@@ -354,7 +370,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
const onCopyActiveCredentials = React.useCallback( const onCopyActiveCredentials = React.useCallback(
() => { () => {
if (!members) return; if (!members) return;
let content = 'Username,Email,Password'; let content = 'Username,Full Name,Email,Password';
let membersToConsider = []; let membersToConsider = [];
if (selectedUserIds.length === 0) { if (selectedUserIds.length === 0) {
membersToConsider = members.filter(member => !member.deactivatedAt); membersToConsider = members.filter(member => !member.deactivatedAt);
@@ -364,7 +380,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
); );
} }
membersToConsider.forEach(member => { membersToConsider.forEach(member => {
content += `\n${member.username || ''},${ content += `\n${member.username || ''},${member.fullName || ''},${
member.email member.email
},${member.password || ''}`; },${member.password || ''}`;
}); });
@@ -673,12 +689,13 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
<UserLine <UserLine
key={adminUser.id} key={adminUser.id}
username={adminUser.username} username={adminUser.username}
fullName={adminUser.fullName}
email={adminUser.email} email={adminUser.email}
level={null} level={null}
onDelete={() => onRemoveAdmin(adminUser.email)} onDelete={() => onRemoveAdmin(adminUser.email)}
disabled={ disabled={
(profile && adminUser.id === profile.id) || (profile && adminUser.id === profile.id) ||
(adminEmailBeingRemoved && (!!adminEmailBeingRemoved &&
adminEmailBeingRemoved === adminUser.email) adminEmailBeingRemoved === adminUser.email)
} }
/> />
@@ -822,10 +839,10 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
size="small" size="small"
tooltip={ tooltip={
selectedUserIds.length === 0 selectedUserIds.length === 0
? t`Copy active credentials` ? t`Copy active credentials to CSV`
: t`Copy ${ : t`Copy ${
selectedUserIds.length selectedUserIds.length
} credentials` } credentials to CSV`
} }
disabled={ disabled={
hasNoActiveTeamMembers && hasNoActiveTeamMembers &&
@@ -847,12 +864,12 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
<Column> <Column>
{!isMobile && ( {!isMobile && (
<GridList cols={2} cellHeight={'auto'}> <GridList cols={2} cellHeight={'auto'}>
<Grid item xs={5}> <Grid item xs={9}>
<Text style={{ opacity: 0.7 }}> <Text style={{ opacity: 0.7 }}>
<Trans>Student</Trans> <Trans>Student</Trans>
</Text> </Text>
</Grid> </Grid>
<Grid item xs={7}> <Grid item xs={3}>
<Text style={{ opacity: 0.7 }}> <Text style={{ opacity: 0.7 }}>
<Trans>Password</Trans> <Trans>Password</Trans>
</Text> </Text>
@@ -885,6 +902,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
onChangePassword={ onChangePassword={
onChangeTeamMemberPassword onChangeTeamMemberPassword
} }
onEdit={onEditTeamMember}
isSelected={selectedUserIds.includes( isSelected={selectedUserIds.includes(
member.id member.id
)} )}
@@ -966,6 +984,7 @@ const ManageEducationAccountDialog = ({ onClose }: Props) => {
onChangePassword={ onChangePassword={
onChangeTeamMemberPassword onChangeTeamMemberPassword
} }
onEdit={onEditTeamMember}
isSelected={selectedUserIds.includes( isSelected={selectedUserIds.includes(
member.id member.id
)} )}

View File

@@ -117,9 +117,19 @@ const TeamMemberRow = ({
/> />
) : ( ) : (
<LineStackLayout noMargin alignItems="center"> <LineStackLayout noMargin alignItems="center">
{member.username && ( {(member.username || member.fullName) && (
<Text allowSelection noMargin> <Text allowSelection noMargin>
{member.username} {member.fullName ? (
member.username ? (
<Trans>
{member.fullName} ({member.username})
</Trans>
) : (
member.fullName
)
) : (
member.username
)}
</Text> </Text>
)} )}
<Text allowSelection noMargin color="secondary"> <Text allowSelection noMargin color="secondary">

View File

@@ -4,7 +4,6 @@ import {
type Profile, type Profile,
type LoginForm, type LoginForm,
type RegisterForm, type RegisterForm,
type PatchUserPayload,
type ForgotPasswordForm, type ForgotPasswordForm,
type AuthError, type AuthError,
type IdentityProvider, type IdentityProvider,
@@ -13,7 +12,10 @@ import { type PreferencesValues } from '../MainFrame/Preferences/PreferencesCont
import { type CloudProjectWithUserAccessInfo } from '../Utils/GDevelopServices/Project'; import { type CloudProjectWithUserAccessInfo } from '../Utils/GDevelopServices/Project';
import { User as FirebaseUser } from 'firebase/auth'; import { User as FirebaseUser } from 'firebase/auth';
import { type Badge, type Achievement } from '../Utils/GDevelopServices/Badge'; import { type Badge, type Achievement } from '../Utils/GDevelopServices/Badge';
import { type Recommendation } from '../Utils/GDevelopServices/User'; import {
type Recommendation,
type EditUserChanges,
} from '../Utils/GDevelopServices/User';
import { type Notification } from '../Utils/GDevelopServices/Notification'; import { type Notification } from '../Utils/GDevelopServices/Notification';
import { import {
type Limits, type Limits,
@@ -62,7 +64,7 @@ export type AuthenticatedUser = {|
preferences: PreferencesValues preferences: PreferencesValues
) => Promise<void>, ) => Promise<void>,
onEditProfile: ( onEditProfile: (
payload: PatchUserPayload, changes: EditUserChanges,
preferences: PreferencesValues preferences: PreferencesValues
) => Promise<void>, ) => Promise<void>,
onResetPassword: ForgotPasswordForm => Promise<void>, onResetPassword: ForgotPasswordForm => Promise<void>,

View File

@@ -8,6 +8,8 @@ import {
getUserEarningsBalance, getUserEarningsBalance,
} from '../Utils/GDevelopServices/Usage'; } from '../Utils/GDevelopServices/Usage';
import { import {
editUser,
type EditUserChanges,
getUserBadges, getUserBadges,
listDefaultRecommendations, listDefaultRecommendations,
listRecommendations, listRecommendations,
@@ -17,7 +19,6 @@ import { getAchievements } from '../Utils/GDevelopServices/Badge';
import Authentication, { import Authentication, {
type LoginForm, type LoginForm,
type RegisterForm, type RegisterForm,
type PatchUserPayload,
type ChangeEmailForm, type ChangeEmailForm,
type AuthError, type AuthError,
type ForgotPasswordForm, type ForgotPasswordForm,
@@ -622,10 +623,11 @@ export default class AuthenticatedUserProvider extends React.Component<
if (!userProfile.isCreator) { if (!userProfile.isCreator) {
// If the user is not a creator, then update the profile to say they now are. // If the user is not a creator, then update the profile to say they now are.
try { try {
await authentication.editUserProfile( await editUser(authentication.getAuthorizationHeader, {
authentication.getAuthorizationHeader, editedUserId: userProfile.id,
{ isCreator: true } userId: userProfile.id,
); changes: { isCreator: true },
});
} catch (error) { } catch (error) {
// Catch the error so that the user profile is still fetched. // Catch the error so that the user profile is still fetched.
console.error('Error while updating the user profile:', error); console.error('Error while updating the user profile:', error);
@@ -1206,11 +1208,13 @@ export default class AuthenticatedUserProvider extends React.Component<
}; };
_doEdit = async ( _doEdit = async (
payload: PatchUserPayload, payload: EditUserChanges,
preferences: PreferencesValues preferences: PreferencesValues
) => { ) => {
const { authentication } = this.props; const { authentication } = this.props;
if (!authentication) return; if (!authentication) return;
const { profile } = this.state.authenticatedUser;
if (!profile) return;
this.setState({ this.setState({
editInProgress: true, editInProgress: true,
@@ -1218,9 +1222,10 @@ export default class AuthenticatedUserProvider extends React.Component<
}); });
this._automaticallyUpdateUserProfile = false; this._automaticallyUpdateUserProfile = false;
try { try {
await authentication.editUserProfile( await editUser(authentication.getAuthorizationHeader, {
authentication.getAuthorizationHeader, editedUserId: profile.id,
{ userId: profile.id,
changes: {
username: payload.username, username: payload.username,
description: payload.description, description: payload.description,
getGameStatsEmail: payload.getGameStatsEmail, getGameStatsEmail: payload.getGameStatsEmail,
@@ -1231,8 +1236,8 @@ export default class AuthenticatedUserProvider extends React.Component<
githubUsername: payload.githubUsername, githubUsername: payload.githubUsername,
communityLinks: payload.communityLinks, communityLinks: payload.communityLinks,
survey: payload.survey, survey: payload.survey,
} },
); });
await this._fetchUserProfileWithoutThrowingErrors(); await this._fetchUserProfileWithoutThrowingErrors();
} catch (apiCallError) { } catch (apiCallError) {
this.setState({ apiCallError }); this.setState({ apiCallError });
@@ -1625,9 +1630,9 @@ export default class AuthenticatedUserProvider extends React.Component<
badges={this.state.authenticatedUser.badges} badges={this.state.authenticatedUser.badges}
subscription={this.state.authenticatedUser.subscription} subscription={this.state.authenticatedUser.subscription}
onClose={() => this.openEditProfileDialog(false)} onClose={() => this.openEditProfileDialog(false)}
onEdit={async form => { onEdit={async changes => {
try { try {
await this._doEdit(form, this.props.preferencesValues); await this._doEdit(changes, this.props.preferencesValues);
this.openEditProfileDialog(false); this.openEditProfileDialog(false);
} catch (error) { } catch (error) {
// Ignore errors, we will let the user retry in their profile. // Ignore errors, we will let the user retry in their profile.

View File

@@ -207,7 +207,6 @@ const CreateAccountDialog = ({
onChangeOptInNewsletterEmail={setGetNewsletterEmail} onChangeOptInNewsletterEmail={setGetNewsletterEmail}
createAccountInProgress={createAccountInProgress} createAccountInProgress={createAccountInProgress}
error={error} error={error}
usernameAvailability={usernameAvailability}
onChangeUsernameAvailability={setUsernameAvailability} onChangeUsernameAvailability={setUsernameAvailability}
isValidatingUsername={isValidatingUsername} isValidatingUsername={isValidatingUsername}
onChangeIsValidatingUsername={setIsValidatingUsername} onChangeIsValidatingUsername={setIsValidatingUsername}

View File

@@ -60,7 +60,6 @@ type Props = {|
onChangeUsername: string => void, onChangeUsername: string => void,
optInNewsletterEmail: boolean, optInNewsletterEmail: boolean,
onChangeOptInNewsletterEmail: boolean => void, onChangeOptInNewsletterEmail: boolean => void,
usernameAvailability: ?UsernameAvailability,
onChangeUsernameAvailability: (?UsernameAvailability) => void, onChangeUsernameAvailability: (?UsernameAvailability) => void,
isValidatingUsername: boolean, isValidatingUsername: boolean,
onChangeIsValidatingUsername: boolean => void, onChangeIsValidatingUsername: boolean => void,
@@ -80,7 +79,6 @@ const CreateAccountForm = ({
onChangeUsername, onChangeUsername,
optInNewsletterEmail, optInNewsletterEmail,
onChangeOptInNewsletterEmail, onChangeOptInNewsletterEmail,
usernameAvailability,
onChangeUsernameAvailability, onChangeUsernameAvailability,
isValidatingUsername, isValidatingUsername,
onChangeIsValidatingUsername, onChangeIsValidatingUsername,

View File

@@ -7,7 +7,6 @@ import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
import FlatButton from '../UI/FlatButton'; import FlatButton from '../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog'; import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import { import {
type EditForm,
type AuthError, type AuthError,
type Profile, type Profile,
type UpdateGitHubStarResponse, type UpdateGitHubStarResponse,
@@ -16,6 +15,7 @@ import {
type UpdateYoutubeSubscriptionResponse, type UpdateYoutubeSubscriptionResponse,
} from '../Utils/GDevelopServices/Authentication'; } from '../Utils/GDevelopServices/Authentication';
import { import {
type EditUserChanges,
communityLinksConfig, communityLinksConfig,
donateLinkConfig, donateLinkConfig,
discordUsernameConfig, discordUsernameConfig,
@@ -59,7 +59,7 @@ export type EditProfileDialogProps = {|
badges: ?Array<Badge>, badges: ?Array<Badge>,
subscription: ?Subscription, subscription: ?Subscription,
onClose: () => void, onClose: () => void,
onEdit: (form: EditForm) => Promise<void>, onEdit: (form: EditUserChanges) => Promise<void>,
onUpdateGitHubStar: ( onUpdateGitHubStar: (
githubUsername: string githubUsername: string
) => Promise<UpdateGitHubStarResponse>, ) => Promise<UpdateGitHubStarResponse>,

View File

@@ -5,6 +5,7 @@ import {
type TeamGroup, type TeamGroup,
type User, type User,
type TeamMembership, type TeamMembership,
type EditUserChanges,
} from '../../Utils/GDevelopServices/User'; } from '../../Utils/GDevelopServices/User';
import { type CloudProjectWithUserAccessInfo } from '../../Utils/GDevelopServices/Project'; import { type CloudProjectWithUserAccessInfo } from '../../Utils/GDevelopServices/Project';
@@ -31,6 +32,7 @@ export type TeamState = {|
userId: string, userId: string,
newPassword: string newPassword: string
) => Promise<void>, ) => Promise<void>,
onEditUser: (editedUserId: string, changes: EditUserChanges) => Promise<void>,
|}; |};
export const initialTeamState = { export const initialTeamState = {
@@ -51,6 +53,7 @@ export const initialTeamState = {
onRefreshAdmins: async () => {}, onRefreshAdmins: async () => {},
onSetAdmin: async () => {}, onSetAdmin: async () => {},
onChangeMemberPassword: async () => {}, onChangeMemberPassword: async () => {},
onEditUser: async () => {},
}; };
const TeamContext = React.createContext<TeamState>(initialTeamState); const TeamContext = React.createContext<TeamState>(initialTeamState);

View File

@@ -20,6 +20,8 @@ import {
changeTeamMemberPassword, changeTeamMemberPassword,
activateTeamMembers, activateTeamMembers,
setUserAsAdmin, setUserAsAdmin,
editUser,
type EditUserChanges,
} from '../../Utils/GDevelopServices/User'; } from '../../Utils/GDevelopServices/User';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext'; import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { listOtherUserCloudProjects } from '../../Utils/GDevelopServices/Project'; import { listOtherUserCloudProjects } from '../../Utils/GDevelopServices/Project';
@@ -150,6 +152,18 @@ const TeamProvider = ({ children }: Props) => {
[team, getAuthorizationHeader, adminUserId] [team, getAuthorizationHeader, adminUserId]
); );
const onEditUser = React.useCallback(
async (editedUserId: string, changes: EditUserChanges) => {
if (!adminUserId) return;
await editUser(getAuthorizationHeader, {
userId: adminUserId,
editedUserId,
changes,
});
},
[getAuthorizationHeader, adminUserId]
);
const onChangeMemberPassword = React.useCallback( const onChangeMemberPassword = React.useCallback(
async (userId: string, newPassword: string) => { async (userId: string, newPassword: string) => {
if (!adminUserId) return; if (!adminUserId) return;
@@ -357,6 +371,7 @@ const TeamProvider = ({ children }: Props) => {
admins, admins,
members, members,
memberships, memberships,
onEditUser,
onChangeGroupName, onChangeGroupName,
onChangeUserGroup, onChangeUserGroup,
onListUserProjects, onListUserProjects,

View File

@@ -226,11 +226,7 @@ type DialogProps = {|
export const DialogPrimaryButton = RaisedButton; export const DialogPrimaryButton = RaisedButton;
/** const DialogWithoutWindowSizeProvider = ({
* A enhanced material-ui Dialog that can have optional secondary actions
* and no margins if required.
*/
const Dialog = ({
onApply, onApply,
secondaryActions, secondaryActions,
dangerLevel, dangerLevel,
@@ -481,12 +477,16 @@ const Dialog = ({
); );
}; };
const DialogWithTopLevelWindowSize = (props: DialogProps) => { /**
* A enhanced material-ui Dialog that can have optional secondary actions
* and no margins if required.
*/
const Dialog = (props: DialogProps) => {
return ( return (
<TopLevelWindowSizeProvider> <TopLevelWindowSizeProvider>
<Dialog {...props} /> <DialogWithoutWindowSizeProvider {...props} />
</TopLevelWindowSizeProvider> </TopLevelWindowSizeProvider>
); );
}; };
export default DialogWithTopLevelWindowSize; export default Dialog;

View File

@@ -1,3 +1,6 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import Avatar from '@material-ui/core/Avatar'; import Avatar from '@material-ui/core/Avatar';
import { getGravatarUrl } from '../../UI/GravatarUrl'; import { getGravatarUrl } from '../../UI/GravatarUrl';
@@ -6,18 +9,21 @@ import Text from '../Text';
import IconButton from '../IconButton'; import IconButton from '../IconButton';
import Trash from '../CustomSvgIcons/Trash'; import Trash from '../CustomSvgIcons/Trash';
import { getTranslatableLevel } from '../../Utils/AclUtils'; import { getTranslatableLevel } from '../../Utils/AclUtils';
import { type Level } from '../../Utils/GDevelopServices/Project';
const UserLine = ({ const UserLine = ({
username, username,
fullName,
email, email,
level, level,
onDelete, onDelete,
disabled, disabled,
}: {| }: {|
username: ?string, username: ?string,
fullName: ?string,
email: string, email: string,
level: ?Level, level: ?Level,
onDelete?: () => void, onDelete?: () => Promise<void> | void,
disabled?: boolean, disabled?: boolean,
|}) => ( |}) => (
<I18n> <I18n>
@@ -26,7 +32,21 @@ const UserLine = ({
<Line noMargin expand> <Line noMargin expand>
<Avatar src={getGravatarUrl(email, { size: 40 })} /> <Avatar src={getGravatarUrl(email, { size: 40 })} />
<Column expand justifyContent="flex-end"> <Column expand justifyContent="flex-end">
{username && <Text noMargin>{username}</Text>} {(username || fullName) && (
<Text noMargin>
{fullName ? (
username ? (
<Trans>
{fullName} ({username})
</Trans>
) : (
fullName
)
) : (
username
)}
</Text>
)}
<Text noMargin color="secondary"> <Text noMargin color="secondary">
{email} {email}
</Text> </Text>

View File

@@ -35,6 +35,7 @@ export type Profile = {|
donateLink: ?string, donateLink: ?string,
discordUsername: ?string, discordUsername: ?string,
githubUsername?: ?string, githubUsername?: ?string,
fullName?: ?string,
communityLinks?: CommunityLinks, communityLinks?: CommunityLinks,
survey?: UserSurvey, survey?: UserSurvey,
@@ -62,31 +63,6 @@ export type RegisterForm = {|
getNewsletterEmail: boolean, getNewsletterEmail: boolean,
|}; |};
export type EditForm = {|
+username: string,
+description: string,
+getGameStatsEmail: boolean,
+getNewsletterEmail: boolean,
+donateLink: string,
+discordUsername: string,
+githubUsername: string,
+communityLinks: CommunityLinks,
|};
export type PatchUserPayload = {
+username?: string,
+description?: string,
+getGameStatsEmail?: boolean,
+getNewsletterEmail?: boolean,
+appLanguage?: string,
+isCreator?: boolean,
+donateLink?: string,
+discordUsername?: string,
+githubUsername?: string,
+communityLinks?: CommunityLinks,
+survey?: UserSurvey,
};
export type ChangeEmailForm = {| export type ChangeEmailForm = {|
email: string, email: string,
|}; |};
@@ -388,60 +364,6 @@ export default class Authentication {
}); });
}; };
editUserProfile = async (
getAuthorizationHeader: () => Promise<string>,
{
username,
description,
getGameStatsEmail,
getNewsletterEmail,
appLanguage,
isCreator,
donateLink,
discordUsername,
githubUsername,
communityLinks,
survey,
}: PatchUserPayload
) => {
const { currentUser } = this.auth;
if (!currentUser)
throw new Error('Tried to edit user profile while not authenticated.');
return getAuthorizationHeader()
.then(authorizationHeader => {
return axios.patch(
`${GDevelopUserApi.baseUrl}/user/${currentUser.uid}`,
{
username,
description,
getGameStatsEmail,
getNewsletterEmail,
appLanguage,
isCreator,
donateLink,
discordUsername,
githubUsername,
communityLinks,
survey,
},
{
params: {
userId: currentUser.uid,
},
headers: {
Authorization: authorizationHeader,
},
}
);
})
.then(response => response.data)
.catch(error => {
console.error('Error while editing user:', error);
throw error;
});
};
updateGitHubStar = async ( updateGitHubStar = async (
getAuthorizationHeader: () => Promise<string> getAuthorizationHeader: () => Promise<string>
): Promise<UpdateGitHubStarResponse> => { ): Promise<UpdateGitHubStarResponse> => {

View File

@@ -142,6 +142,21 @@ export type UserPublicProfile = {|
iconUrl: string, iconUrl: string,
|}; |};
export type EditUserChanges = {|
username?: string,
fullName?: string,
description?: string,
getGameStatsEmail?: boolean,
getNewsletterEmail?: boolean,
appLanguage?: string,
isCreator?: boolean,
donateLink?: string,
discordUsername?: string,
githubUsername?: string,
communityLinks?: CommunityLinks,
survey?: UserSurvey,
|};
export type UserPublicProfileByIds = {| export type UserPublicProfileByIds = {|
[key: string]: UserPublicProfile, [key: string]: UserPublicProfile,
|}; |};
@@ -801,3 +816,24 @@ export const generateCustomAuthToken = async (
); );
return response.data.customAuthToken; return response.data.customAuthToken;
}; };
export const editUser = async (
getAuthorizationHeader: () => Promise<string>,
{
editedUserId,
userId,
changes,
}: {|
editedUserId: string,
userId: string,
changes: EditUserChanges,
|}
): Promise<User> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await client.patch(`/user/${editedUserId}`, changes, {
headers: { Authorization: authorizationHeader },
params: { userId },
});
return response.data;
};

View File

@@ -9,6 +9,7 @@ import {
type TeamGroup, type TeamGroup,
type TeamMembership, type TeamMembership,
type User, type User,
type EditUserChanges,
} from '../../../Utils/GDevelopServices/User'; } from '../../../Utils/GDevelopServices/User';
import { type CloudProjectWithUserAccessInfo } from '../../../Utils/GDevelopServices/Project'; import { type CloudProjectWithUserAccessInfo } from '../../../Utils/GDevelopServices/Project';
import { testProject } from '../../GDevelopJsInitializerDecorator'; import { testProject } from '../../GDevelopJsInitializerDecorator';
@@ -57,6 +58,7 @@ const initialMembers: Array<User> = [
email: 'user1@hotmail.com', email: 'user1@hotmail.com',
username: null, username: null,
password: 'blue-chair-34', password: 'blue-chair-34',
fullName: 'John Doe',
}, },
// $FlowIgnore - the whole user object is not needed for this component // $FlowIgnore - the whole user object is not needed for this component
{ {
@@ -94,6 +96,7 @@ const initialMembers: Array<User> = [
id: 'user7', id: 'user7',
email: 'user7@mail.ru', email: 'user7@mail.ru',
username: 'Bayonetta', username: 'Bayonetta',
fullName: 'Jane Smith',
}, },
// $FlowIgnore - the whole user object is not needed for this component // $FlowIgnore - the whole user object is not needed for this component
{ {
@@ -435,6 +438,18 @@ const MockTeamProvider = ({
setMembers(newMembers); setMembers(newMembers);
}; };
const editUser = async (userId: string, changes: EditUserChanges) => {
if (!members) return;
const newMembers = [...members];
const memberIndex = newMembers.findIndex(member => member.id === userId);
if (memberIndex === -1) return;
newMembers.splice(memberIndex, 1, {
...newMembers[memberIndex],
...changes,
});
setMembers(newMembers);
};
return ( return (
<AlertProvider> <AlertProvider>
<DragAndDropContextProvider> <DragAndDropContextProvider>
@@ -460,6 +475,7 @@ const MockTeamProvider = ({
onActivateMembers: action('activateMembers'), onActivateMembers: action('activateMembers'),
onChangeMemberPassword: changeMemberPassword, onChangeMemberPassword: changeMemberPassword,
onSetAdmin: setAdmin, onSetAdmin: setAdmin,
onEditUser: editUser,
}} }}
> >
<Text allowSelection> <Text allowSelection>
@@ -474,19 +490,21 @@ const MockTeamProvider = ({
); );
}; };
export const Default = () => ( export const Default = () => {
<MockTeamProvider loading={false} teamSize={12}> return (
<FixedHeightFlexContainer height={600}> <MockTeamProvider loading={false} teamSize={12}>
<TeamSection <FixedHeightFlexContainer height={600}>
project={testProject.project} <TeamSection
currentFileMetadata={null} project={testProject.project}
onOpenRecentFile={action('onOpenRecentFile')} currentFileMetadata={null}
storageProviders={[CloudStorageProvider]} onOpenRecentFile={action('onOpenRecentFile')}
onOpenTeachingResources={action('onOpenTeachingResources')} storageProviders={[CloudStorageProvider]}
/> onOpenTeachingResources={action('onOpenTeachingResources')}
</FixedHeightFlexContainer> />
</MockTeamProvider> </FixedHeightFlexContainer>
); </MockTeamProvider>
);
};
export const WithNoGroupsYet = () => ( export const WithNoGroupsYet = () => (
<MockTeamProvider loading={false} noGroups> <MockTeamProvider loading={false} noGroups>