feat: implement sign up with GitHub for the first user (#16629)

Second PR to address https://github.com/coder/coder/issues/16230. See
the issue for more context and discussion.

It adds a "Continue with GitHub" button to the `/setup` page, so the
deployment's admin can sign up with it. It also removes the "Username"
and "Full Name" fields to make signing up with email faster. In the
email flow, the username is now auto-generated based on the email, and
full name is left empty.

<img width="1512" alt="Screenshot 2025-02-21 at 17 51 22"
src="https://github.com/user-attachments/assets/e7c6986b-c05e-458b-bb01-c3aea3b74c0e"
/>

There's a separate, follow up issue to visually align the `/setup` page
with the new design system: https://github.com/coder/coder/issues/16653
This commit is contained in:
Hugo Dutka
2025-02-25 15:54:38 +01:00
committed by GitHub
parent b419b36ada
commit 67d89bb102
7 changed files with 171 additions and 59 deletions
@@ -16,7 +16,6 @@ test("setup deployment", async ({ page }) => {
}
// Setup first user
await page.getByLabel(Language.usernameLabel).fill(users.admin.username);
await page.getByLabel(Language.emailLabel).fill(users.admin.email);
await page.getByLabel(Language.passwordLabel).fill(users.admin.password);
await page.getByTestId("create").click();
@@ -13,7 +13,6 @@ import { SetupPage } from "./SetupPage";
import { Language as PageViewLanguage } from "./SetupPageView";
const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "password",
}: {
@@ -21,10 +20,8 @@ const fillForm = async ({
email?: string;
password?: string;
} = {}) => {
const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel);
const emailField = screen.getByLabelText(PageViewLanguage.emailLabel);
const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel);
await userEvent.type(usernameField, username);
await userEvent.type(emailField, email);
await userEvent.type(passwordField, password);
const submitButton = screen.getByRole("button", {
+4 -2
View File
@@ -1,5 +1,5 @@
import { buildInfo } from "api/queries/buildInfo";
import { createFirstUser } from "api/queries/users";
import { authMethods, createFirstUser } from "api/queries/users";
import { Loader } from "components/Loader/Loader";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
@@ -19,6 +19,7 @@ export const SetupPage: FC = () => {
isSignedIn,
isSigningIn,
} = useAuthContext();
const authMethodsQuery = useQuery(authMethods());
const createFirstUserMutation = useMutation(createFirstUser());
const setupIsComplete = !isConfiguringTheFirstUser;
const { metadata } = useEmbeddedMetadata();
@@ -34,7 +35,7 @@ export const SetupPage: FC = () => {
});
}, [buildInfoQuery.data]);
if (isLoading) {
if (isLoading || authMethodsQuery.isLoading) {
return <Loader fullscreen />;
}
@@ -54,6 +55,7 @@ export const SetupPage: FC = () => {
<title>{pageTitle("Set up your account")}</title>
</Helmet>
<SetupPageView
authMethods={authMethodsQuery.data}
isLoading={isSigningIn || createFirstUserMutation.isLoading}
error={createFirstUserMutation.error}
onSubmit={async (firstUser) => {
+62 -22
View File
@@ -1,6 +1,8 @@
import GitHubIcon from "@mui/icons-material/GitHub";
import LoadingButton from "@mui/lab/LoadingButton";
import AlertTitle from "@mui/material/AlertTitle";
import Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import Link from "@mui/material/Link";
import MenuItem from "@mui/material/MenuItem";
@@ -15,8 +17,7 @@ import { PasswordField } from "components/PasswordField/PasswordField";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useEffect } from "react";
import { type ChangeEvent, type FC, useCallback } from "react";
import { docs } from "utils/docs";
import {
getFormHelpers,
@@ -33,7 +34,8 @@ export const Language = {
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
create: "Create account",
create: "Continue with email",
githubCreate: "Continue with GitHub",
welcomeMessage: <>Welcome to Coder</>,
firstNameLabel: "First name",
lastNameLabel: "Last name",
@@ -50,13 +52,29 @@ export const Language = {
developersRequired: "Please select the number of developers in your company.",
};
const usernameValidator = nameValidator(Language.usernameLabel);
const usernameFromEmail = (email: string): string => {
try {
const emailPrefix = email.split("@")[0];
const username = emailPrefix.toLowerCase().replace(/[^a-z0-9]/g, "-");
usernameValidator.validateSync(username);
return username;
} catch (error) {
console.warn(
"failed to automatically generate username, defaulting to 'admin'",
error,
);
return "admin";
}
};
const validationSchema = Yup.object({
email: Yup.string()
.trim()
.email(Language.emailInvalid)
.required(Language.emailRequired),
password: Yup.string().required(Language.passwordRequired),
username: nameValidator(Language.usernameLabel),
username: usernameValidator,
trial: Yup.bool(),
trial_info: Yup.object().when("trial", {
is: true,
@@ -81,16 +99,23 @@ const numberOfDevelopersOptions = [
"2500+",
];
const iconStyles = {
width: 16,
height: 16,
};
export interface SetupPageViewProps {
onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void;
error?: unknown;
isLoading?: boolean;
authMethods: TypesGen.AuthMethods | undefined;
}
export const SetupPageView: FC<SetupPageViewProps> = ({
onSubmit,
error,
isLoading,
authMethods,
}) => {
const form: FormikContextType<TypesGen.CreateFirstUserRequest> =
useFormik<TypesGen.CreateFirstUserRequest>({
@@ -112,6 +137,10 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
},
validationSchema,
onSubmit,
// With validate on blur set to true, the form lights up red whenever
// you click out of it. This is a bit jarring. We instead validate
// on submit and change.
validateOnBlur: false,
});
const getFieldHelpers = getFormHelpers<TypesGen.CreateFirstUserRequest>(
form,
@@ -142,23 +171,36 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
</header>
<VerticalForm onSubmit={form.handleSubmit}>
<FormFields>
<TextField
autoFocus
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
autoComplete="username"
fullWidth
label={Language.usernameLabel}
/>
<TextField
{...getFieldHelpers("name")}
autoComplete="name"
fullWidth
label={Language.nameLabel}
/>
{authMethods?.github.enabled && (
<>
<Button
fullWidth
component="a"
href="/api/v2/users/oauth2/github/callback"
variant="contained"
startIcon={<GitHubIcon css={iconStyles} />}
type="submit"
size="xlarge"
>
{Language.githubCreate}
</Button>
<div className="flex items-center gap-4">
<div className="h-[1px] w-full bg-border" />
<div className="shrink-0 text-xs uppercase text-content-secondary tracking-wider">
or
</div>
<div className="h-[1px] w-full bg-border" />
</div>
</>
)}
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
onChange={(event) => {
const email = event.target.value;
const username = usernameFromEmail(email);
form.setFieldValue("username", username);
onChangeTrimmed(form)(event as ChangeEvent<HTMLInputElement>);
}}
autoComplete="email"
fullWidth
label={Language.emailLabel}
@@ -340,9 +382,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
loading={isLoading}
type="submit"
data-testid="create"
size="large"
variant="contained"
color="primary"
size="xlarge"
>
{Language.create}
</LoadingButton>