Repository: GBSL-Informatik/teaching-dev
Pull request #148: [developers] Multiple dev users with no-auth.
Author: login: SilasBerger
Created: 2025-05-01T18:23:19Z | Merged: 2025-05-02T09:03:52Z

Allow developers to specify multiple dev users for no-auth operation, and change accounts on the user page.

This makes it easier to test features and interactions involving more than one user / role, without having to spawn multiple frontends with different configs (which can be a pain, especially regarding CORS).


Diff:
diff --git a/.env.example b/.env.example
index 5292ad20c..2ea04a65c 100644
--- a/.env.example
+++ b/.env.example
@@ -4,4 +4,4 @@ APP_URL="http://localhost:3000"
 BACKEND_URL="http://localhost:3002"
 API_URI="api://xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/access_as_user"
 STUDENT_USERNAME_PATTERN="@edu"
-TEST_USERNAME="foo.bar@gbsl.ch"
\ No newline at end of file
+TEST_USERNAMES="adam.admin@gbsl.ch;foo.bar@gbsl.ch"
\ No newline at end of file
diff --git a/README.md b/README.md
index 9eb4bb954..7e1ec22ff 100644
--- a/README.md
+++ b/README.md
@@ -7,19 +7,19 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
 
 ## ENV
 
-| Variable                   | For         | Default                 | Example             | Description                                                                                                                                                        |
-|:---------------------------|:------------|:------------------------|:--------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `APP_URL`                  | Production  | `http://localhost:3000` |                     | Domain of the hosted app                                                                                                                                           |
-| `BACKEND_URL`              | Production  | `http://localhost:3002` |                     | Url of the API Endpoint                                                                                                                                            |
-| `CLIENT_ID`                | Production  |                         |                     | Azure ID: Client ID                                                                                                                                                |
-| `TENANT_ID`                | Production  |                         |                     | Azure AD: Tenant Id                                                                                                                                                |
-| `API_URI`                  | Production  |                         |                     | Azure AD: API Url                                                                                                                                                  |
-| `STUDENT_USERNAME_PATTERN` | Production  |                         | `@edu`              | Users with usernames matching this RegExp pattern are displayed as students (regardless of admin status). If unset, all non-admin users are displayed as students. |
-| `TEST_USERNAME`            | Development |                         | `admin.bar@bazz.ch` | To log in offline. Must correspond to a user email found in the API's database.\*                                                                                  |
-| `SENTRY_DSN`               | Production  |                         |                     | Sentry DSN for error tracking                                                                                                                                      |
-| `SENTRY_AUTH_TOKEN`        | Production  |                         |                     | Sentry Auth Token for error tracking                                                                                                                               |
-| `SENTRY_ORG`               | Production  |                         |                     | Sentry Org for error tracking                                                                                                                                      |
-| `SENTRY_PROJECT`           | Production  |                         |                     | Sentry Project for error tracking                                                                                                                                  |
+| Variable                   | For         | Default                 | Example                          | Description                                                                                                                                                        |
+| :------------------------- | :---------- | :---------------------- | :------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `APP_URL`                  | Production  | `http://localhost:3000` |                                  | Domain of the hosted app                                                                                                                                           |
+| `BACKEND_URL`              | Production  | `http://localhost:3002` |                                  | Url of the API Endpoint                                                                                                                                            |
+| `CLIENT_ID`                | Production  |                         |                                  | Azure ID: Client ID                                                                                                                                                |
+| `TENANT_ID`                | Production  |                         |                                  | Azure AD: Tenant Id                                                                                                                                                |
+| `API_URI`                  | Production  |                         |                                  | Azure AD: API Url                                                                                                                                                  |
+| `STUDENT_USERNAME_PATTERN` | Production  |                         | `@edu`                           | Users with usernames matching this RegExp pattern are displayed as students (regardless of admin status). If unset, all non-admin users are displayed as students. |
+| `TEST_USERNAMES`           | Development |                         | `admin.bar@bazz.ch;test@user.ch` | To log in offline. First user is selected as default. Must all correspond to a user emails found in the API's database.\*                                          |
+| `SENTRY_DSN`               | Production  |                         |                                  | Sentry DSN for error tracking                                                                                                                                      |
+| `SENTRY_AUTH_TOKEN`        | Production  |                         |                                  | Sentry Auth Token for error tracking                                                                                                                               |
+| `SENTRY_ORG`               | Production  |                         |                                  | Sentry Org for error tracking                                                                                                                                      |
+| `SENTRY_PROJECT`           | Production  |                         |                                  | Sentry Project for error tracking                                                                                                                                  |
 
 \* To change users, clear LocalStorage to delete the API key created upon first authentication.<br />
 
diff --git a/docusaurus.config.ts b/docusaurus.config.ts
index 8083206fc..b742eee3f 100644
--- a/docusaurus.config.ts
+++ b/docusaurus.config.ts
@@ -125,6 +125,8 @@ const getCopyPlugin = (
 
 const ORGANIZATION_NAME = 'gbsl-informatik';
 const PROJECT_NAME = 'teaching-dev';
+const TEST_USERNAMES = (process.env.TEST_USERNAMES?.split(';') || []).map((u) => u.trim()).filter(u => !!u);
+
 
 const config: Config = {
   title: 'Teaching-Dev',
@@ -147,10 +149,10 @@ const config: Config = {
 
   customFields: {
     /** Use Testuser in local dev: set TEST_USERNAME to the test users email adress*/
-    TEST_USERNAME: process.env.TEST_USERNAME,
+    TEST_USERNAMES: TEST_USERNAMES,
     /** User.ts#isStudent returns `true` for users matching this pattern. If unset, it returns `true` for all non-admin users. */
     STUDENT_USERNAME_PATTERN: process.env.STUDENT_USERNAME_PATTERN,
-    NO_AUTH: process.env.NODE_ENV !== 'production' && !!process.env.TEST_USERNAME,
+    NO_AUTH: process.env.NODE_ENV !== 'production' && TEST_USERNAMES.length > 0,
     /** The Domain Name where the api is running */
     APP_URL: process.env.NETLIFY
       ? process.env.CONTEXT === 'production' 
diff --git a/src/api/base.ts b/src/api/base.ts
index 194429cb3..4b67e395b 100644
--- a/src/api/base.ts
+++ b/src/api/base.ts
@@ -3,7 +3,8 @@ import { BACKEND_URL, apiConfig } from '../authConfig';
 import { InteractionRequiredAuthError } from '@azure/msal-browser';
 import { msalInstance } from '../theme/Root';
 import siteConfig from '@generated/docusaurus.config';
-const { TEST_USERNAME } = siteConfig.customFields as { TEST_USERNAME?: string };
+import Storage from '@tdev-stores/utils/Storage';
+const { NO_AUTH } = siteConfig.customFields as { NO_AUTH?: boolean };
 
 export namespace Api {
     export const BASE_API_URL = eventsApiUrl();
@@ -40,7 +41,7 @@ export const setupMsalAxios = () => {
     api.interceptors.request.clear();
     api.interceptors.request.use(
         async (config: InternalAxiosRequestConfig) => {
-            if (process.env.NODE_ENV !== 'production' && TEST_USERNAME) {
+            if (process.env.NODE_ENV !== 'production' && NO_AUTH) {
                 return config;
             }
             // This will only return a non-null value if you have logic somewhere else that calls the setActiveAccount API
@@ -90,7 +91,7 @@ export const setupMsalAxios = () => {
     );
 };
 
-export const setupNoAuthAxios = () => {
+export const setupNoAuthAxios = (userEmail: string) => {
     if (process.env.NODE_ENV === 'production') {
         return;
     }
@@ -98,7 +99,9 @@ export const setupNoAuthAxios = () => {
     api.interceptors.request.clear();
     api.interceptors.request.use(
         async (config: InternalAxiosRequestConfig) => {
-            config.headers['Authorization'] = JSON.stringify({ email: TEST_USERNAME });
+            config.headers['Authorization'] = JSON.stringify({
+                email: userEmail
+            });
             return config;
         },
         (error) => {
diff --git a/src/pages/user/index.tsx b/src/pages/user/index.tsx
index 154c9b721..b60c8f04b 100644
--- a/src/pages/user/index.tsx
+++ b/src/pages/user/index.tsx
@@ -16,7 +16,14 @@ import DefinitionList from '@tdev-components/DefinitionList';
 import Icon from '@mdi/react';
 import UserTable from '@tdev-components/Admin/UserTable';
 import NavReloadRequest from '@tdev-components/Admin/ActionRequest/NavReloadRequest';
-const { NO_AUTH } = siteConfig.customFields as { TEST_USERNAME?: string; NO_AUTH?: boolean };
+import Storage from '@tdev-stores/utils/Storage';
+import { logout } from '@tdev-api/user';
+import SelectInput from '@tdev-components/shared/SelectInput';
+
+const { NO_AUTH, TEST_USERNAMES } = siteConfig.customFields as {
+    NO_AUTH?: boolean;
+    TEST_USERNAMES: string[];
+};
 
 const LeftAlign = (text: String) => {
     return text
@@ -166,6 +173,26 @@ const UserPage = observer(() => {
                             iconSide="left"
                         />
                     </dd>
+                    {NO_AUTH && (
+                        <>
+                            <dt>Test-User wechseln</dt>
+                            <dd>
+                                <SelectInput
+                                    options={TEST_USERNAMES}
+                                    onChange={(username) => {
+                                        sessionStore.setAccount({ username: username } as any);
+                                        Storage.set('SessionStore', {
+                                            user: { email: username }
+                                        });
+                                        logout(new AbortController().signal);
+                                        window.location.reload();
+                                    }}
+                                    value={(sessionStore.account as any)?.username}
+                                    placeholder=".env TEST_USERNAMES"
+                                />
+                            </dd>
+                        </>
+                    )}
                     <dt>Ausloggen</dt>
                     <dd>
                         <Button
diff --git a/src/stores/SessionStore.ts b/src/stores/SessionStore.ts
index cadc7c98f..34513fdb4 100644
--- a/src/stores/SessionStore.ts
+++ b/src/stores/SessionStore.ts
@@ -3,9 +3,7 @@ import { action, computed, observable } from 'mobx';
 import { RootStore } from '@tdev-stores/rootStore';
 import { logout } from '@tdev-api/user';
 import Storage, { PersistedData, StorageKey } from '@tdev-stores/utils/Storage';
-import siteConfig from '@generated/docusaurus.config';
 import iStore from '@tdev-stores/iStore';
-const { NO_AUTH, TEST_USERNAME } = siteConfig.customFields as { TEST_USERNAME?: string; NO_AUTH?: boolean };
 
 class State {
     @observable.ref accessor account: AccountInfo | undefined | null = undefined;
diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx
index 12fa2036c..24b1c9a88 100644
--- a/src/theme/Root.tsx
+++ b/src/theme/Root.tsx
@@ -12,15 +12,18 @@ import { reaction, runInAction } from 'mobx';
 import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
 import scheduleMicrotask from '@tdev-components/util/scheduleMicrotask';
 import { useHistory } from '@docusaurus/router';
-const { NO_AUTH, TEST_USERNAME, SENTRY_DSN } = siteConfig.customFields as {
-    TEST_USERNAME?: string;
+import Storage from '@tdev-stores/utils/Storage';
+const { NO_AUTH, TEST_USERNAMES, SENTRY_DSN } = siteConfig.customFields as {
+    TEST_USERNAMES: string[];
     NO_AUTH?: boolean;
     SENTRY_DSN?: string;
 };
 export const msalInstance = new PublicClientApplication(msalConfig);
 
+const currentTestUsername = Storage.get('SessionStore', { user: { email: TEST_USERNAMES[0] } })?.user?.email;
+
 if (NO_AUTH) {
-    const n = (TEST_USERNAME?.length || 0) >= 40 ? 0 : 40 - (TEST_USERNAME?.length || 0);
+    const n = (currentTestUsername?.length || 0) >= 32 ? 0 : 32 - (currentTestUsername?.length || 0);
     console.log(
         [
             '',
@@ -34,9 +37,9 @@ if (NO_AUTH) {
             '│  |_| \\_|\\___/  /_/    \\_\\__,_|\\__|_| |_|                 │',
             '│                                                          │',
             '│                                                          │',
-            `│   TEST_USERNAME: ${TEST_USERNAME + ' '.repeat(n)}│`,
+            `│   Current test username: ${currentTestUsername + ' '.repeat(n)}│`,
             '│                                                          │',
-            '│   --> enable authentication by removing "TEST_USERNAME"  │',
+            '│  --> enable authentication by removing "TEST_USERNAMES"  │',
             '│       from the environment (or the .env file)            │',
             '└──────────────────────────────────────────────────────────┘'
         ].join('\n')
@@ -46,8 +49,8 @@ if (NO_AUTH) {
 const MsalWrapper = observer(({ children }: { children: React.ReactNode }) => {
     const sessionStore = useStore('sessionStore');
     React.useEffect(() => {
-        if (NO_AUTH && process.env.NODE_ENV !== 'production' && TEST_USERNAME) {
-            setupNoAuthAxios();
+        if (NO_AUTH && process.env.NODE_ENV !== 'production' && currentTestUsername) {
+            setupNoAuthAxios(currentTestUsername);
         }
     }, []);
     React.useEffect(() => {
@@ -55,11 +58,17 @@ const MsalWrapper = observer(({ children }: { children: React.ReactNode }) => {
          * DEV MODE
          * - no auth
          */
-        if (NO_AUTH && !sessionStore?.isLoggedIn) {
+        if (NO_AUTH && !sessionStore?.isLoggedIn && currentTestUsername) {
             runInAction(() => {
                 sessionStore.authMethod = 'msal';
             });
-            rootStore.sessionStore.setAccount({ username: TEST_USERNAME } as any);
+
+            scheduleMicrotask(() => {
+                rootStore.sessionStore.setAccount({
+                    username: currentTestUsername
+                } as any);
+            });
+
             rootStore.load();
             return;
         }


Reviews:

Review comments:

PR comments:
user: login: netlify[bot]
created_at: 2025-05-01T18:23:38Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *teaching-dev* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | a4c962ed3c3e5ae40bc5b3feecb05381b174a4f8 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/teaching-dev/deploys/681489d96ef68e000813052c |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-148--teaching-dev.netlify.app](https://deploy-preview-148--teaching-dev.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTE0OC0tdGVhY2hpbmctZGV2Lm5ldGxpZnkuYXBwIn0._a_4yFc4RkpGUqX8vUbi_EmXYn3cZtR-fkiIg5Km1vQ)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [teaching-dev Preview](https://deploy-preview-148--teaching-dev.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/teaching-dev/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: SilasBerger
created_at: 2025-05-01T19:53:08Z
body: 
https://github.com/user-attachments/assets/bce9c6eb-020c-46b7-8d15-a63f03ee091b


author_association: CONTRIBUTOR
user: login: lebalz
created_at: 2025-05-02T07:13:20Z
body: Really nice addition, this improves DX a lot 🥳

Since i had to setup the PR anyway to test it, i did some minor changes directly and pushed them as a PR for this PR #151 

Nice job!
author_association: CONTRIBUTOR
user: login: SilasBerger
created_at: 2025-05-02T09:02:18Z
body: Great, thanks for the changes / fixes – works perfectly :). I'm merging the PR then.
author_association: CONTRIBUTOR