From d1b28af94144c7b4ceb92a017418163e4499d72d Mon Sep 17 00:00:00 2001
From: Your Name <5115845+dauglyon@users.noreply.github.com>
Date: Fri, 29 May 2026 15:32:06 -0700
Subject: [PATCH 1/3] Validate signup username against backend format rules
The frontend availability check compared availablename to
username.toLowerCase(), so a username like "John" passed validation
but was then rejected by auth2 for the uppercase letter. Inputs with
characters auth2 strips (dots, hyphens, etc.) failed with a misleading
"Username is not available" message.
Mirror the kbase/auth2 NewUserName rules on the form: must start with
a lowercase letter, only [a-z0-9_], no repeating or trailing
underscores, at most 100 chars. Show a specific error for format
violations and only treat availability mismatches as collisions.
---
.../signup/AccountInformation.test.tsx | 68 +++++++++++++++++++
src/features/signup/AccountInformation.tsx | 41 +++++++++--
2 files changed, 105 insertions(+), 4 deletions(-)
diff --git a/src/features/signup/AccountInformation.test.tsx b/src/features/signup/AccountInformation.test.tsx
index d491669b..82911794 100644
--- a/src/features/signup/AccountInformation.test.tsx
+++ b/src/features/signup/AccountInformation.test.tsx
@@ -135,4 +135,72 @@ describe('AccountInformation', () => {
expect(mockNavigate).toHaveBeenCalledWith('/signup/3');
});
+
+ test.each([
+ ['uppercase letters', 'BadUser'],
+ ['a leading digit', '1baduser'],
+ ['a hyphen', 'bad-user'],
+ ['a period', 'bad.user'],
+ ['repeating underscores', 'bad__user'],
+ ['a trailing underscore', 'baduser_'],
+ ])(
+ 'blocks submission and shows format error for username with %s',
+ async (_label, badName) => {
+ const store = createTestStore();
+ store.dispatch(
+ setLoginData({
+ creationallowed: true,
+ expires: 0,
+ login: [],
+ provider: 'Google',
+ create: [
+ {
+ provemail: 'test@test.com',
+ provfullname: 'Test User',
+ availablename: 'testuser',
+ id: '123',
+ provusername: 'testuser',
+ },
+ ],
+ })
+ );
+ renderWithProviders(, { store });
+
+ await act(() => {
+ fireEvent.change(screen.getByRole('textbox', { name: /Full Name/i }), {
+ target: { value: 'Test User' },
+ });
+ });
+ await act(() => {
+ fireEvent.change(screen.getByRole('textbox', { name: /Email/i }), {
+ target: { value: 'test@test.com' },
+ });
+ });
+ await act(() => {
+ fireEvent.change(
+ screen.getByRole('textbox', { name: /KBase Username/i }),
+ { target: { value: badName } }
+ );
+ });
+ await act(() => {
+ fireEvent.change(
+ screen.getByRole('textbox', { name: /Organization/i }),
+ { target: { value: 'Test Org' } }
+ );
+ });
+ await act(() => {
+ fireEvent.change(screen.getByRole('textbox', { name: /Department/i }), {
+ target: { value: 'Test Dept' },
+ });
+ });
+ await act(() => {
+ fireEvent.submit(screen.getByTestId('accountinfoform'));
+ });
+
+ expect(
+ screen.getByText(/may contain only lowercase letters/i)
+ ).toBeInTheDocument();
+ expect(mockNavigate).not.toHaveBeenCalledWith('/signup/3');
+ }
+ );
});
diff --git a/src/features/signup/AccountInformation.tsx b/src/features/signup/AccountInformation.tsx
index 156bd49a..6aeeefaf 100644
--- a/src/features/signup/AccountInformation.tsx
+++ b/src/features/signup/AccountInformation.tsx
@@ -58,8 +58,14 @@ export const AccountInformation: FC<{}> = () => {
const [username, setUsername] = useState(account.username ?? '');
const userAvail = loginUsernameSuggest.useQuery(username);
const nameShort = username.length < 3;
- const nameAvail =
- userAvail.currentData?.availablename === username.toLowerCase();
+ const nameTooLong = username.length > 100;
+ // Mirrors backend rules in kbase/auth2 NewUserName: must start with a
+ // lowercase letter; only [a-z0-9_]; no repeating or trailing underscores.
+ const nameFormatValid =
+ /^[a-z][a-z0-9_]*$/.test(username) &&
+ !username.includes('__') &&
+ !username.endsWith('_');
+ const nameAvail = userAvail.currentData?.availablename === username;
const surveyQuestion = 'How did you hear about us? (select all that apply)';
const [optionalText, setOptionalText] = useState>({});
@@ -199,7 +205,11 @@ export const AccountInformation: FC<{}> = () => {
required: true,
onChange: (e) => setUsername(e.currentTarget.value),
validate: () =>
- !nameShort && !userAvail.isFetching && nameAvail,
+ !nameShort &&
+ !nameTooLong &&
+ nameFormatValid &&
+ !userAvail.isFetching &&
+ nameAvail,
})}
defaultValue={account.username}
helperText={
@@ -209,6 +219,24 @@ export const AccountInformation: FC<{}> = () => {
Username is too short.
+ ) : nameTooLong ? (
+
+ Username must be at most 100 characters.
+
+
+ ) : !nameFormatValid ? (
+
+ Username may contain only lowercase letters, digits, and
+ underscores, and must start with a letter. Underscores
+ cannot repeat or end the username.
+ {userAvail.currentData?.availablename ? (
+ <>
+ {' '}
+ Suggested: "{userAvail.currentData.availablename}".
+ >
+ ) : null}
+
+
) : !nameAvail && !userAvail.isFetching ? (
Username is not available. Suggested: "
@@ -224,7 +252,12 @@ export const AccountInformation: FC<{}> = () => {
>
}
- error={nameShort || (!userAvail.isFetching && !nameAvail)}
+ error={
+ nameShort ||
+ nameTooLong ||
+ !nameFormatValid ||
+ (!userAvail.isFetching && !nameAvail)
+ }
/>
From ec1804fd53fb3d7e99033dfd1bf1337811c28ece Mon Sep 17 00:00:00 2001
From: Your Name <5115845+dauglyon@users.noreply.github.com>
Date: Fri, 29 May 2026 15:32:20 -0700
Subject: [PATCH 2/3] Gate CDM sidebar link on BERDL_USER role
The CDM nav item was gated on CDM_JUPYTERHUB_ADMIN, so only admins
saw the link. Per the BERDL platform docs, BERDL_USER is the role
that gates access to the lakehouse; CDM_JUPYTERHUB_ADMIN is a
separate admin role for approving access requests.
---
src/features/layout/LeftNavBar.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/features/layout/LeftNavBar.tsx b/src/features/layout/LeftNavBar.tsx
index 32817209..e6274be1 100644
--- a/src/features/layout/LeftNavBar.tsx
+++ b/src/features/layout/LeftNavBar.tsx
@@ -72,7 +72,7 @@ const LeftNavBar: FC = () => {
icon={faDatabase}
badge={'alpha'}
badgeColor={'warning'}
- requiredRole="CDM_JUPYTERHUB_ADMIN"
+ requiredRole="BERDL_USER"
/>
From 349922d74a8328b117e28a9b87cb2dbeb4882243 Mon Sep 17 00:00:00 2001
From: Your Name <5115845+dauglyon@users.noreply.github.com>
Date: Mon, 1 Jun 2026 09:17:07 -0700
Subject: [PATCH 3/3] Skip user username pre-fill on ORCID signup
ORCID's provider-supplied username is the numeric ORCID iD (e.g.
0000-0002-1825-0097). auth2's NewUserName.sanitizeName strips all of
that to empty and getAvailableUserName falls back to user, so every
ORCID signup landed on the form with user1 (or user2, ...) already in
the username field.
Leave the username blank when the provider is OrcID so the user picks
their own. Display name and email pre-fill are unchanged.
---
src/features/signup/SignupSlice.test.tsx | 43 ++++++++++++++++++++++++
src/features/signup/SignupSlice.tsx | 13 +++++--
2 files changed, 53 insertions(+), 3 deletions(-)
create mode 100644 src/features/signup/SignupSlice.test.tsx
diff --git a/src/features/signup/SignupSlice.test.tsx b/src/features/signup/SignupSlice.test.tsx
new file mode 100644
index 00000000..9762bf9d
--- /dev/null
+++ b/src/features/signup/SignupSlice.test.tsx
@@ -0,0 +1,43 @@
+import { createTestStore } from '../../app/store';
+import { setLoginData } from './SignupSlice';
+
+const makeLoginData = (
+ provider: string,
+ availablename: string
+): Parameters[0] => ({
+ creationallowed: true,
+ expires: 0,
+ login: [],
+ provider,
+ create: [
+ {
+ provemail: 'jane@example.com',
+ provfullname: 'Jane Doe',
+ availablename,
+ id: '123',
+ provusername: '0000-0002-1825-0097',
+ },
+ ],
+});
+
+describe('signup setLoginData', () => {
+ test('pre-fills username from availablename for non-ORCID providers', () => {
+ const store = createTestStore();
+ store.dispatch(setLoginData(makeLoginData('Google', 'janedoe')));
+ expect(store.getState().signup.account.username).toBe('janedoe');
+ });
+
+ test('leaves username blank for ORCID logins to avoid user default', () => {
+ const store = createTestStore();
+ store.dispatch(setLoginData(makeLoginData('OrcID', 'user1')));
+ expect(store.getState().signup.account.username).toBeUndefined();
+ });
+
+ test('still pre-fills display name and email for ORCID', () => {
+ const store = createTestStore();
+ store.dispatch(setLoginData(makeLoginData('OrcID', 'user1')));
+ const account = store.getState().signup.account;
+ expect(account.display).toBe('Jane Doe');
+ expect(account.email).toBe('jane@example.com');
+ });
+});
diff --git a/src/features/signup/SignupSlice.tsx b/src/features/signup/SignupSlice.tsx
index 7c398b45..ec5d6fbd 100644
--- a/src/features/signup/SignupSlice.tsx
+++ b/src/features/signup/SignupSlice.tsx
@@ -44,9 +44,16 @@ export const signupSlice = createSlice({
// Set provider creeation data
state.loginData = action.payload;
// Set account defaults from provider
- state.account.display = action.payload?.create[0].provfullname;
- state.account.email = action.payload?.create[0].provemail;
- state.account.username = action.payload?.create[0].availablename;
+ const detail = action.payload?.create[0];
+ state.account.display = detail?.provfullname;
+ state.account.email = detail?.provemail;
+ // ORCID's provusername is the numeric ORCID iD, which auth2 cannot
+ // sanitize into a valid username and falls back to user. Leave the
+ // field blank for ORCID so the user picks their own.
+ state.account.username =
+ action.payload?.provider === 'OrcID'
+ ? undefined
+ : detail?.availablename;
},
setAccount: (
state,