Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Create User" button to admin dashboard #736

Merged
merged 45 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a3aed11
Add "Create User" button to admin dashboard
rmunn Apr 18, 2024
8d71023
Extract register page into component
rmunn Apr 18, 2024
02bc9e3
Use register page in create-user modal for admins
rmunn Apr 18, 2024
8536fd2
Don't auto-login user if created by admin
rmunn Apr 18, 2024
5d72b13
Hide create-user modal when submit button clicked
rmunn Apr 18, 2024
8a6a9e3
Merge branch 'develop' into feat/admins-can-create-users
rmunn May 13, 2024
cc86680
Add helpful tips to top of create-user modal
rmunn May 13, 2024
2606313
Don't change browser title in modal
rmunn May 13, 2024
b9e0b8e
De-indent CreateUser component HTML
rmunn May 13, 2024
4b0d557
Admins now create guest users
rmunn May 13, 2024
a3e2e5d
Set up Create User modal help for translation
rmunn May 13, 2024
cf11cd7
Remove now-redundant "how to create uesrs" link
rmunn May 13, 2024
efd1699
Give Create User modal a proper title
rmunn May 13, 2024
77c5853
Fix submit button text in Create User modal
rmunn May 13, 2024
0499c4d
Fix lint errors
rmunn May 14, 2024
974743a
Improve alert-link contrast
myieye May 14, 2024
908ecb3
Adapt wording and style it
myieye May 14, 2024
33c1a39
Merge remote-tracking branch 'origin/develop' into feat/admins-can-cr…
myieye May 14, 2024
f9d2be7
Only add jwt-token projects to invited users
myieye May 14, 2024
e21373d
Add GQL mutation for creating guest users
rmunn May 16, 2024
1e1bf8d
Add flow for accepting invitation email
rmunn May 16, 2024
4c7da1d
Refactor some common code in user controller
rmunn May 16, 2024
f19c43a
Require registerAccount flow to be unauthenticated
rmunn May 16, 2024
04cc4eb
Changed my mind about unauth in register flow
rmunn May 16, 2024
327e863
Prep bulk add for being used with no project ID
rmunn May 17, 2024
9a3eb02
Make refactored method private
rmunn May 17, 2024
2b6c2b8
Update API endpoints for register and invite flows
rmunn May 17, 2024
f35f8d0
Another change for optional proj ID in bulk add GQL
rmunn May 17, 2024
5cd69c2
Remove autoLogin from backend API
rmunn May 17, 2024
6b19e6c
CreateUser UI now optionally allows usernames
rmunn May 17, 2024
8c5d0b0
Validate usernames in CreateUser form
rmunn May 20, 2024
57b5ab3
Remove redundant username check in CreateUser
rmunn May 20, 2024
adebdc6
Prettiness
rmunn May 20, 2024
1e3cb44
Remove completed TODO
rmunn May 20, 2024
b7f04bc
Move onSubmit handler to CreateUser callers
rmunn May 20, 2024
328c3bc
Make name a required field for CreateUser GQL
rmunn May 20, 2024
0b9fd48
Skip turnstile token in admin dashboard
rmunn May 20, 2024
b385c69
Fix lint error
rmunn May 20, 2024
50fe0ce
Switch endpoint prop in CreateUser for function
rmunn May 20, 2024
57b4571
Passsword strength now required in CreateGuestUser GQL
rmunn May 20, 2024
024113c
Address review comments
rmunn May 22, 2024
e563e4a
Make RegisterResponse error properties optional
rmunn May 22, 2024
f027c6b
Address review comments about error message
rmunn May 23, 2024
d6564f0
Merge remote-tracking branch 'origin/develop' into feat/admins-can-cr…
rmunn May 23, 2024
f2d011a
Fix and organize email/username validation messages
myieye May 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public async Task<ActionResult<LexAuthUser>> RegisterAccount(RegisterAccountInpu

var jwtUser = _loggedInContext.MaybeUser;
var emailVerified = jwtUser?.Email == accountInput.Email;
var createdByAdmin = jwtUser?.IsAdmin ?? false;

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
Expand All @@ -80,20 +81,24 @@ public async Task<ActionResult<LexAuthUser>> RegisterAccount(RegisterAccountInpu
PasswordStrength = UserService.ClampPasswordStrength(accountInput.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
CreatedById = createdByAdmin ? jwtUser?.Id : null,
Locked = false,
CanCreateProjects = false
};
registerActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
if (jwtUser is not null && jwtUser.Projects.Length > 0)
if (jwtUser is not null && !createdByAdmin && jwtUser.Projects.Length > 0)
rmunn marked this conversation as resolved.
Show resolved Hide resolved
{
userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList();
}
await _lexBoxDbContext.SaveChangesAsync();

var user = new LexAuthUser(userEntity);
await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
new AuthenticationProperties { IsPersistent = true });
if (accountInput.AutoLogin)
{
await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
new AuthenticationProperties { IsPersistent = true });
}

if (!emailVerified) await _emailService.SendVerifyAddressEmail(userEntity);
return Ok(user);
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/Models/RegisterAccountInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public record RegisterAccountInput([Required(AllowEmptyStrings = false)] string
[Required(AllowEmptyStrings = false)] string Locale,
[Required(AllowEmptyStrings = false)] string PasswordHash,
int? PasswordStrength,
string TurnstileToken);
string TurnstileToken,
bool AutoLogin = true);
rmunn marked this conversation as resolved.
Show resolved Hide resolved
91 changes: 91 additions & 0 deletions frontend/src/lib/components/Users/CreateUser.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { SubmitButton, FormError, Input, ProtectedForm, lexSuperForm, passwordFormRules, DisplayLanguageSelect } from '$lib/forms';
import t, { getLanguageCodeFromNavigator, locale } from '$lib/i18n';
import { register } from '$lib/user';
import { getSearchParamValues } from '$lib/util/query-params';
import { onMount } from 'svelte';
import { z } from 'zod';

export let autoLogin = true;
export let onSubmit: (() => void) | undefined = undefined;
export let submitButtonText = $t('register.button_register');

type RegisterPageQueryParams = {
name: string;
email: string;
};
let turnstileToken = '';
// $locale is the locale that our i18n is using for them (i.e. the best available option we have for them)
// getLanguageCodeFromNavigator() gives us the language/locale they probably actually want. Maybe we'll support it in the future.
const userLocale = getLanguageCodeFromNavigator() ?? $locale;
const formSchema = z.object({
name: z.string().trim().min(1, $t('register.name_missing')),
email: z.string().email($t('form.invalid_email')),
password: passwordFormRules($t),
score: z.number(),
locale: z.string().trim().min(2).default(userLocale),
});

let { form, errors, message, enhance, submitting } = lexSuperForm(formSchema, async () => {
const { user, error } = await register($form.password, $form.score, $form.name, $form.email, $form.locale, turnstileToken, autoLogin);
if (error) {
if (error.turnstile) {
$message = $t('turnstile.invalid');
}
if (error.accountExists) {
$errors.email = [$t('register.account_exists')];
}
return;
}
if (user) {
if (onSubmit) onSubmit();
if (autoLogin) await goto('/home', { invalidateAll: true }); // invalidate so we get the user from the server
rmunn marked this conversation as resolved.
Show resolved Hide resolved
return;
}
throw new Error('Unknown error, no error from server, but also no user.');
});
onMount(() => { // query params not available during SSR
const urlValues = getSearchParamValues<RegisterPageQueryParams>();
form.update((form) => {
if (urlValues.name) form.name = urlValues.name;
if (urlValues.email) form.email = urlValues.email;
return form;
}, { taint: true });
});
</script>

<ProtectedForm {enhance} bind:turnstileToken>
rmunn marked this conversation as resolved.
Show resolved Hide resolved
<Input autofocus id="name" label={$t('register.label_name')} bind:value={$form.name} error={$errors.name} />
<div class="contents email">
<Input
id="email"
label={$t('register.label_email')}
description={$t('register.description_email')}
type="email"
bind:value={$form.email}
error={$errors.email}
/>
</div>
<Input
id="password"
label={$t('register.label_password')}
type="password"
bind:value={$form.password}
error={$errors.password}
autocomplete="new-password"
/>
<PasswordStrengthMeter bind:score={$form.score} password={$form.password} />
<DisplayLanguageSelect
bind:value={$form.locale}
/>
<FormError error={$message} />
<SubmitButton loading={$submitting}>{submitButtonText}</SubmitButton>
</ProtectedForm>

<style lang="postcss">
.email :global(.description) {
@apply text-success;
}
</style>
21 changes: 21 additions & 0 deletions frontend/src/lib/components/Users/CreateUserModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { Modal } from '$lib/components/modals';
import t from '$lib/i18n';
import { helpLinks } from '$lib/components/help';
import CreateUser from '$lib/components/Users/CreateUser.svelte';
import Markdown from 'svelte-exmarkdown';
import { NewTabLinkRenderer } from '$lib/components/Markdown';

let createUserModal: Modal;

export async function open(): Promise<void> {
await createUserModal.openModal(true, true);
}
</script>

<Modal bind:this={createUserModal} bottom>
<Markdown md={$t('admin_dashboard.create_user_modal.help_create_single_guest_user', helpLinks)} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
<Markdown md={$t('admin_dashboard.create_user_modal.help_create_bulk_guest_users', helpLinks)} plugins={[{ renderer: { a: NewTabLinkRenderer } }]} />
<h1 class="text-center text-xl">{$t('admin_dashboard.create_user_modal.create_user')}</h1>
<CreateUser autoLogin={false} onSubmit={() => createUserModal.submitModal()} submitButtonText={$t('admin_dashboard.create_user_modal.create_user')} />
</Modal>
5 changes: 5 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
}
}
},
"create_user_modal": {
"create_user": "Create User",
"help_create_single_guest_user": "Did you know you can also [create users on a project page]({addProjectMember})? That also allows you to make those users members of the project in one step; users created here will not be members of any project and will need a second step to add them to a project.",
"help_create_bulk_guest_users": "If you want to create multiple users at once, the best way is probably to use the [Bulk Add/Create Project Members]({bulkAddCreate}) tool.",
rmunn marked this conversation as resolved.
Show resolved Hide resolved
},
"user_details_modal": {
"registered": "Registered",
"locked": "Locked",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export async function login(userId: string, password: string): Promise<LoginResu
}

type RegisterResponse = { error?: { turnstile: boolean, accountExists: boolean }, user?: LexAuthUser };
export async function register(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string): Promise<RegisterResponse> {
export async function register(password: string, passwordStrength: number, name: string, email: string, locale: string, turnstileToken: string, autoLogin: boolean): Promise<RegisterResponse> {
const response = await fetch('/api/User/registerAccount', {
method: 'post',
headers: {
Expand All @@ -94,6 +94,7 @@ export async function register(password: string, passwordStrength: number, name:
locale,
turnstileToken,
passwordStrength,
autoLogin,
passwordHash: await hash(password),
})
});
Expand Down
16 changes: 8 additions & 8 deletions frontend/src/routes/(authenticated)/admin/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
import { Button } from '$lib/forms';
import { PageBreadcrumb } from '$lib/layout';
import AdminTabs, { type AdminTabId } from './AdminTabs.svelte';
import CreateUserModal from '$lib/components/Users/CreateUserModal.svelte';
import type { Confidentiality } from '$lib/components/Projects';
import { helpLinks } from '$lib/components/help';

export let data: PageData;
$: projects = data.projects;
Expand Down Expand Up @@ -70,6 +70,7 @@
}

let userModal: UserModal;
let createUserModal: CreateUserModal;
let deleteUserModal: DeleteUserModal;
let formModal: EditUserAccount;

Expand Down Expand Up @@ -125,15 +126,13 @@
</Badge>
</div>
</div>
<a class="btn btn-sm btn-success btn-outline max-xs:btn-square group"
href={helpLinks.bulkAddCreate}
target="_blank" rel="external">
<button class="btn btn-sm btn-success max-xs:btn-square"
on:click={() => createUserModal.open()}>
<span class="admin-tabs:hidden">
{$t('admin_dashboard.how_to_create_users')}
{$t('admin_dashboard.create_user_modal.create_user')}
</span>
<span class="i-mdi-plus text-2xl group-hover:i-mdi-open-in-new" />
</a>
</div>
<span class="i-mdi-plus text-2xl" />
</button> </div>
rmunn marked this conversation as resolved.
Show resolved Hide resolved
</AdminTabs>
<div class="mt-4">
<FilterBar
Expand Down Expand Up @@ -246,4 +245,5 @@
<EditUserAccount bind:this={formModal} {deleteUser} currUser={data.user} />
<DeleteUserModal bind:this={deleteUserModal} i18nScope="admin_dashboard.form_modal.delete_user" />
<UserModal bind:this={userModal}/>
<CreateUserModal bind:this={createUserModal}/>
</main>
86 changes: 3 additions & 83 deletions frontend/src/routes/(unauthenticated)/register/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,89 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PasswordStrengthMeter from '$lib/components/PasswordStrengthMeter.svelte';
import { SubmitButton, FormError, Input, ProtectedForm, lexSuperForm, passwordFormRules, DisplayLanguageSelect } from '$lib/forms';
import t, { getLanguageCodeFromNavigator, locale } from '$lib/i18n';
import { TitlePage } from '$lib/layout';
import { register } from '$lib/user';
import { getSearchParamValues } from '$lib/util/query-params';
import { onMount } from 'svelte';
import { z } from 'zod';

type RegisterPageQueryParams = {
name: string;
email: string;
};
let turnstileToken = '';
// $locale is the locale that our i18n is using for them (i.e. the best available option we have for them)
// getLanguageCodeFromNavigator() gives us the language/locale they probably actually want. Maybe we'll support it in the future.
const userLocale = getLanguageCodeFromNavigator() ?? $locale;
const formSchema = z.object({
name: z.string().trim().min(1, $t('register.name_missing')),
email: z.string().email($t('form.invalid_email')),
password: passwordFormRules($t),
score: z.number(),
locale: z.string().trim().min(2).default(userLocale),
});

let { form, errors, message, enhance, submitting } = lexSuperForm(formSchema, async () => {
const { user, error } = await register($form.password, $form.score, $form.name, $form.email, $form.locale, turnstileToken);
if (error) {
if (error.turnstile) {
$message = $t('turnstile.invalid');
}
if (error.accountExists) {
$errors.email = [$t('register.account_exists')];
}
return;
}
if (user) {
await goto('/home', { invalidateAll: true }); // invalidate so we get the user from the server
return;
}
throw new Error('Unknown error, no error from server, but also no user.');
});
onMount(() => { // query params not available during SSR
const urlValues = getSearchParamValues<RegisterPageQueryParams>();
form.update((form) => {
if (urlValues.name) form.name = urlValues.name;
if (urlValues.email) form.email = urlValues.email;
return form;
}, { taint: true });
});
import t from '$lib/i18n';
import CreateUser from '$lib/components/Users/CreateUser.svelte';
</script>

<TitlePage title={$t('register.title')}>
<ProtectedForm {enhance} bind:turnstileToken>
<Input autofocus id="name" label={$t('register.label_name')} bind:value={$form.name} error={$errors.name} />
<div class="contents email">
<Input
id="email"
label={$t('register.label_email')}
description={$t('register.description_email')}
type="email"
bind:value={$form.email}
error={$errors.email}
/>
</div>
<Input
id="password"
label={$t('register.label_password')}
type="password"
bind:value={$form.password}
error={$errors.password}
autocomplete="new-password"
/>
<PasswordStrengthMeter bind:score={$form.score} password={$form.password} />
<DisplayLanguageSelect
bind:value={$form.locale}
/>
<FormError error={$message} />
<SubmitButton loading={$submitting}>{$t('register.button_register')}</SubmitButton>
</ProtectedForm>
<CreateUser />
</TitlePage>

<style lang="postcss">
.email :global(.description) {
@apply text-success;
}
</style>
Loading