Skip to content

Commit

Permalink
feat(user): auto-approve username changes for casing (#3123)
Browse files Browse the repository at this point in the history
  • Loading branch information
wescopeland authored Jan 25, 2025
1 parent 72b5b47 commit 54b6eb6
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 11 deletions.
14 changes: 10 additions & 4 deletions app/Community/Controllers/UserSettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,16 @@ public function storeUsernameChangeRequest(StoreUsernameChangeRequest $request):
/** @var User $user */
$user = $request->user();

UserUsername::create([
'user_id' => $user->id,
'username' => $data->newDisplayName,
]);
$isOnlyCapitalizationChange = strtolower($user->display_name) === strtolower($data->newDisplayName);
if ($isOnlyCapitalizationChange) {
$user->display_name = $data->newDisplayName;
$user->save();
} else {
UserUsername::create([
'user_id' => $user->id,
'username' => $data->newDisplayName,
]);
}

return response()->json(['success' => true]);
}
Expand Down
2 changes: 1 addition & 1 deletion app/Community/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ protected function mapWebRoutes(): void
Route::put('email', [UserSettingsController::class, 'updateEmail'])->name('api.settings.email.update');

Route::post('username-change-request', [UserSettingsController::class, 'storeUsernameChangeRequest'])
->name('api.settings.username-change-request.store');
->name('api.settings.name-change-request.store');

Route::delete('keys/web', [UserSettingsController::class, 'resetWebApiKey'])->name('api.settings.keys.web.destroy');
Route::delete('keys/connect', [UserSettingsController::class, 'resetConnectApiKey'])->name('api.settings.keys.connect.destroy');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { requestedUsernameAtom } from '../../state/settings.atoms';
import { ChangeUsernameSectionCard } from './ChangeUsernameSectionCard';

describe('Component: ChangeUsernameSectionCard', () => {
const originalLocation = window.location;

afterEach(() => {
window.location = originalLocation;
});

it('renders without crashing', () => {
// ARRANGE
const { container } = render(<ChangeUsernameSectionCard />, {
Expand Down Expand Up @@ -154,7 +160,7 @@ describe('Component: ChangeUsernameSectionCard', () => {
await userEvent.click(screen.getByRole('button', { name: /update/i }));

// ASSERT
expect(postSpy).toHaveBeenCalledWith(route('api.settings.username-change-request.store'), {
expect(postSpy).toHaveBeenCalledWith(route('api.settings.name-change-request.store'), {
newDisplayName: 'NewName',
});
});
Expand Down Expand Up @@ -216,4 +222,73 @@ describe('Component: ChangeUsernameSectionCard', () => {
expect(screen.getByText(/something went wrong/i)).toBeVisible();
});
});

it('given the user submits a username that only differs in case, auto-approves without confirmation', async () => {
// ARRANGE
delete (window as any).location;
window.location = {
...originalLocation,
reload: vi.fn(),
};

const confirmSpy = vi.spyOn(window, 'confirm');
const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
const postSpy = vi.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
success: true,
},
});

render(<ChangeUsernameSectionCard />, {
pageProps: {
auth: { user: createAuthenticatedUser({ displayName: 'testuser' }) },
can: { createUsernameChangeRequest: true },
},
});

// ACT
await userEvent.type(screen.getAllByLabelText(/new username/i)[0], 'TestUser');
await userEvent.type(screen.getByLabelText(/confirm new username/i), 'TestUser');
await userEvent.click(screen.getByRole('button', { name: /update/i }));

// ASSERT
expect(confirmSpy).not.toHaveBeenCalled();
expect(postSpy).toHaveBeenCalledWith(route('api.settings.name-change-request.store'), {
newDisplayName: 'TestUser',
});
expect(reloadSpy).toHaveBeenCalled();
});

it('given the API returns success for a case change, reloads the page', async () => {
// ARRANGE
delete (window as any).location;
window.location = {
...originalLocation,
reload: vi.fn(),
};

const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
vi.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
success: true,
},
});

render(<ChangeUsernameSectionCard />, {
pageProps: {
auth: { user: createAuthenticatedUser({ displayName: 'testuser' }) },
can: { createUsernameChangeRequest: true },
},
});

// ACT
await userEvent.type(screen.getAllByLabelText(/new username/i)[0], 'TestUser');
await userEvent.type(screen.getByLabelText(/confirm new username/i), 'TestUser');
await userEvent.click(screen.getByRole('button', { name: /update/i }));

// ASSERT
await waitFor(() => {
expect(reloadSpy).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,19 @@ export function useChangeUsernameForm() {

const mutation = useMutation({
mutationFn: (formValues: FormValues) => {
return axios.post(route('api.settings.username-change-request.store'), {
return axios.post(route('api.settings.name-change-request.store'), {
newDisplayName: formValues.newUsername,
});
},
onSuccess: (_, { newUsername }) => {
const wasAutoApproved = auth!.user.displayName.toLowerCase() === newUsername.toLowerCase();

if (wasAutoApproved) {
window.location.reload();

return;
}

setRequestedUsername(newUsername);
},
});
Expand All @@ -73,7 +81,12 @@ export function useChangeUsernameForm() {
'You can only request a new username once every 30 days, even if your new username is not approved. Are you sure you want to do this?',
);

if (!confirm(confirmationMessage)) {
// If the user just wants a case change, no need to confirm.
// We'll make the change instantly without needing approval.
const mustShowConfirm =
auth!.user.displayName.toLowerCase() !== formValues.newUsername.toLowerCase();

if (mustShowConfirm && !confirm(confirmationMessage)) {
return;
}

Expand Down
2 changes: 1 addition & 1 deletion resources/js/types/generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ declare namespace App.Community.Data {
export type UserSettingsPageProps = {
userSettings: App.Data.User;
can: App.Data.UserPermissions;
requestedUsername: string | null;
displayableRoles: Array<App.Data.Role>;
requestedUsername: string | null;
};
}
declare namespace App.Community.Enums {
Expand Down
2 changes: 1 addition & 1 deletion resources/js/ziggy.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ declare module 'ziggy-js' {
"api.settings.preferences.update": [],
"api.settings.password.update": [],
"api.settings.email.update": [],
"api.settings.username-change-request.store": [],
"api.settings.name-change-request.store": [],
"api.settings.keys.web.destroy": [],
"api.settings.keys.connect.destroy": [],
"api.active-player.index": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ public function testUpdateUsername(): void

// Act
$response = $this->actingAs($user)
->postJson(route('api.settings.username-change-request.store'), [
->postJson(route('api.settings.name-change-request.store'), [
'newDisplayName' => 'Scott123456712',
]);

Expand All @@ -139,6 +139,35 @@ public function testUpdateUsername(): void
]);
}

public function testUpdateUsernameWithOnlyCapitalizationChange(): void
{
// Arrange
$this->withoutMiddleware();

/** @var User $user */
$user = User::factory()->create([
'User' => 'scott',
'display_name' => 'scott',
]);

// Act
$response = $this->actingAs($user)
->postJson(route('api.settings.name-change-request.store'), [
'newDisplayName' => 'Scott',
]);

// Assert
$response->assertStatus(200);

$user = $user->fresh();
$this->assertEquals('Scott', $user->display_name); // instantly makes the update

$this->assertDatabaseMissing('user_usernames', [ // does not make an approval request record
'user_id' => $user->id,
'username' => 'Scott',
]);
}

public function testUpdateProfile(): void
{
// Arrange
Expand Down

0 comments on commit 54b6eb6

Please sign in to comment.