Repository: temporalio/ui
Pull request #348 (2022-03-16 17:13:52)

Login page
<!--- Note to EXTERNAL Contributors -->
<!-- Thanks for opening a PR! 
If it is a significant code change, please **make sure there is an open issue** for this. 
We work best with you when we have accepted the idea first before you code. -->

<!--- For ALL Contributors 👇 -->

## What was changed
<!-- Describe what has changed in this PR -->

## Why?
<!-- Tell your future self why have you made these changes -->

## Checklist
<!--- add/delete as needed --->

1. Closes <!-- add issue number here -->

2. How was this tested:
<!--- Please describe how you tested your changes/how we can test them -->

3. Any docs updates needed?
<!--- update README if applicable
      or point out where to update docs.temporal.io -->


cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>ab2b17c</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://ea7f5727.ui-c3x.pages.dev'>https://ea7f5727.ui-c3x.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/temporal-pizza/ea7f5727-9f8f-4d00-9707-a518c9c8f860)


Patch:
diff --git a/src/lib/utilities/__mocks__/$app/env.ts b/__mocks__/$app/env.ts
similarity index 100%
rename from src/lib/utilities/__mocks__/$app/env.ts
rename to __mocks__/$app/env.ts
diff --git a/__mocks__/$app/navigation/goto.ts b/__mocks__/$app/navigation/goto.ts
new file mode 100644
index 00000000..1a00ce1b
--- /dev/null
+++ b/__mocks__/$app/navigation/goto.ts
@@ -0,0 +1,3 @@
+export const goto = jest.fn(() => '');
+
+export default goto;
diff --git a/jest.config.js b/jest.config.js
index 5c1a385e..746ce1b8 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -11,7 +11,7 @@ export default {
     '^\\$lib/(.*)$': '<rootDir>/src/lib/$1',
     '^\\$types/(.*)$': '<rootDir>/src/types/$1',
     '^\\$components/(.*)$': '<rootDir>/src/components/$1',
-    '^\\$app(.*)$': '<rootDir>/.svelte-kit/dev/runtime/app$1',
+    '^\\$app(.*)$': ['<rootDir>/.svelte-kit/runtime/app$1'],
   },
 
   coverageDirectory: './coverage',
diff --git a/src/global.d.ts b/src/global.d.ts
index 7d21ccd3..c613169c 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -52,6 +52,7 @@ type Settings = {
     enabled: boolean;
     options: string[];
   };
+  baseUrl: string;
   defaultNamespace: string;
   runtimeEnvironment: {
     isCloud: boolean;
diff --git a/src/lib/components/button.svelte b/src/lib/components/button.svelte
index 29034332..82ad35dd 100644
--- a/src/lib/components/button.svelte
+++ b/src/lib/components/button.svelte
@@ -7,21 +7,24 @@
   export let secondary: boolean = false;
   export let destroy: boolean = false;
   export let loading: boolean = false;
-  export let as: string = 'button';
+  export let login: boolean = false;
+  export let as: 'button' | 'anchor' = 'button';
   export let active: boolean = false;
   export let large: boolean = false;
   export let href: string = null;
   export let icon: IconDefinition = null;
+  export let classes: string = '';
 </script>
 
 {#if as === 'button'}
   <button
     on:click
-    class="flex items-center justify-center text-sm primary"
+    class={`flex items-center justify-center text-sm primary ${classes}`}
     class:selected={active}
     class:large
     class:secondary
     class:destroy
+    class:login
     {disabled}
   >
     {#if icon}
@@ -35,19 +38,22 @@
   <a
     {href}
     on:click
-    class="flex items-center justify-center text-sm primary"
+    class={`flex items-center justify-center text-sm primary w-fit ${classes}`}
     class:selected={active}
     class:large
     class:secondary
     class:destroy
     class:disabled
+    class:login
     {disabled}
-    >{#if loading}
-      <span class="animate-spin"> <Icon {icon} scale={1} /></span>
-    {:else}
-      <slot />
-    {/if}</a
   >
+    {#if icon}
+      <span class:animate-spin={loading} class="pr-2">
+        <Icon icon={loading ? faSpinner : icon} scale={1} />
+      </span>
+    {/if}
+    <slot />
+  </a>
 {/if}
 
 <style lang="postcss">
@@ -94,4 +100,8 @@
   .selected {
     @apply text-white bg-purple-600 border-purple-200;
   }
+
+  .login {
+    @apply bg-gray-900 mx-auto py-4;
+  }
 </style>
diff --git a/src/lib/components/pagination.svelte b/src/lib/components/pagination.svelte
index 3b8c4e65..5d3bc53f 100644
--- a/src/lib/components/pagination.svelte
+++ b/src/lib/components/pagination.svelte
@@ -19,7 +19,7 @@
 
   $: queryKeyValue = $page.url.searchParams.get(queryKey);
   $: perPage = queryKeyValue || '25';
-  $: pageRange = Number(perPage);
+  $: pageRange = !isNaN(Number(perPage)) ? Math.abs(Number(perPage)) : 25;
 
   $: previousEnabled = pageNumber > 1;
   $: nextEnabled = pageNumber * pageRange < items.length;
@@ -32,8 +32,8 @@
   }
 
   $: {
-    if (!options.includes(perPage.toString())) {
-      options = [perPage.toString(), ...options];
+    if (!options.includes(pageRange.toString())) {
+      options = [pageRange.toString(), ...options];
     }
   }
 </script>
@@ -45,7 +45,7 @@
       <FilterSelect
         label="Per Page"
         parameter={queryKey}
-        value={perPage}
+        value={String(pageRange)}
         {options}
       />
     </div>
diff --git a/src/lib/services/settings-service.ts b/src/lib/services/settings-service.ts
index a7f315fa..06f29edb 100644
--- a/src/lib/services/settings-service.ts
+++ b/src/lib/services/settings-service.ts
@@ -1,3 +1,4 @@
+import { browser } from '$app/env';
 import { getEnvironment } from '$lib/utilities/get-environment';
 import { requestFromAPI } from '$lib/utilities/request-from-api';
 import { routeForApi } from '$lib/utilities/route-for-api';
@@ -26,6 +27,8 @@ export const fetchSettings = async ({
       enabled: !!settings?.Auth?.Enabled,
       options: settings?.Auth?.Options,
     },
+    baseUrl:
+      import.meta?.env?.VITE_API ?? browser ? window.location.origin : '',
     defaultNamespace: settings?.DefaultNamespace || 'default', // API returns an empty string if default namespace is not configured
     runtimeEnvironment: {
       get isCloud() {
diff --git a/src/lib/utilities/get-login-url.ts b/src/lib/utilities/get-login-url.ts
new file mode 100644
index 00000000..6717540b
--- /dev/null
+++ b/src/lib/utilities/get-login-url.ts
@@ -0,0 +1,16 @@
+export const getLoginUrl = (
+  settings: Settings,
+  currentSearchParams: URLSearchParams,
+): string => {
+  const login = new URL('/auth/sso', settings.baseUrl);
+  const opts = settings.auth.options ?? [];
+
+  opts.forEach((option) => {
+    const searchParam = currentSearchParams.get(option);
+    if (searchParam) {
+      login.searchParams.set(option, searchParam);
+    }
+  });
+
+  return login.toString();
+};
diff --git a/src/lib/utilities/handle-error.ts b/src/lib/utilities/handle-error.ts
index c019cb62..23e4154d 100644
--- a/src/lib/utilities/handle-error.ts
+++ b/src/lib/utilities/handle-error.ts
@@ -1,9 +1,15 @@
+import { browser } from '$app/env';
+import { goto } from '$app/navigation';
 import { networkError } from '$lib/stores/error';
 import { notifications } from '../stores/notifications';
 import { isNetworkError } from './is-network-error';
 
 // This will eventually be expanded on.
 export const handleError = (error: unknown): void => {
+  if (isUnauthorized(error) && browser) {
+    goto('/login');
+    return;
+  }
   if (isForbidden(error)) {
     notifications.add('error', `${error.statusCode} ${error.statusText}`);
     return;
@@ -25,9 +31,17 @@ export const handleError = (error: unknown): void => {
   }
 };
 
+const isUnauthorized = (error: unknown): error is NetworkError => {
+  if (isNetworkError(error)) {
+    return error.statusCode === 401;
+  }
+
+  return false;
+};
+
 const isForbidden = (error: unknown): error is NetworkError => {
   if (isNetworkError(error)) {
-    return error.statusCode === 403 || error.statusCode === 401;
+    return error.statusCode === 403;
   }
 
   return false;
diff --git a/src/routes/_header.svelte b/src/routes/_header.svelte
index 08d15c5c..d46cfd46 100644
--- a/src/routes/_header.svelte
+++ b/src/routes/_header.svelte
@@ -10,20 +10,6 @@
   import { goto } from '$app/navigation';
 
   import NavigationLink from './_navigation-link.svelte';
-  import { page } from '$app/stores';
-
-  $: settings = $page.stuff.settings;
-
-  let loginUrl = '';
-
-  $: {
-    const query = (settings.auth.options ?? [])
-      .filter((option) => !!$page.url.searchParams.get(option))
-      .map((option) => `&${option}=${$page.url.searchParams.get(option)}`)
-      .join('');
-
-    loginUrl = import.meta.env.VITE_API + '/auth/sso?' + encodeURI(query);
-  }
 </script>
 
 <header
@@ -54,24 +40,19 @@
     >
       Report Bug/Give Feedback
     </a>
-    {#if settings.auth.enabled}
-      {#if $user?.email}
-        <button
-          class="header-button min-w-min"
-          on:click={() => goto(import.meta.env.VITE_API + '/auth/logout')}
-        >
-          Logout
-          <img
-            src={$user.picture}
-            alt="User Avatar"
-            class="rounded-full h-6 w-6 ml-2.5"
-          />
-        </button>
-      {:else}
-        <button class="header-button" on:click={() => goto(loginUrl)}>
-          Login
-        </button>
-      {/if}
+
+    {#if $user?.email}
+      <button
+        class="header-button min-w-min"
+        on:click={() => goto(import.meta.env.VITE_API + '/auth/logout')}
+      >
+        Logout
+        <img
+          src={$user.picture}
+          alt="User Avatar"
+          class="rounded-full h-6 w-6 ml-2.5"
+        />
+      </button>
     {/if}
   </div>
 </header>
diff --git a/src/routes/login/__layout.reset.svelte b/src/routes/login/__layout.reset.svelte
new file mode 100644
index 00000000..4fa864ce
--- /dev/null
+++ b/src/routes/login/__layout.reset.svelte
@@ -0,0 +1,1 @@
+<slot />
diff --git a/src/routes/login/index.svelte b/src/routes/login/index.svelte
new file mode 100644
index 00000000..fb429326
--- /dev/null
+++ b/src/routes/login/index.svelte
@@ -0,0 +1,54 @@
+<script context="module" lang="ts">
+  import { goto } from '$app/navigation';
+  import { page } from '$app/stores';
+
+  import Button from '$lib/components/button.svelte';
+  import { getLoginUrl } from '$lib/utilities/get-login-url';
+  import { faLock } from '@fortawesome/free-solid-svg-icons';
+
+  import { fetchSettings } from '$lib/services/settings-service';
+
+  import type { Load } from '@sveltejs/kit';
+
+  export const load: Load = async function ({ url }) {
+    const settings: Settings = await fetchSettings({ url });
+
+    if (!settings.auth.enabled) {
+      return {
+        status: 404,
+      };
+    }
+
+    return {
+      props: { settings },
+      stuff: { settings },
+    };
+  };
+</script>
+
+<script lang="ts">
+  export let settings: Settings;
+</script>
+
+<header
+  class="grid grid-rows-1 grid-cols-12 px-10 items-center bg-gray-900 shadow-lg gap-6"
+>
+  <a href="/" class="flex my-4 max-h-8">
+    <img src="/logo.svg" alt="Temporal Logo" class="max-h-8" />
+  </a>
+</header>
+
+<section class="text-center my-[20vh]">
+  <h1 class="text-8xl font-semibold">Welcome back.</h1>
+  <p class="my-7">Lets get you signed in.</p>
+  <div class="mx-auto">
+    <Button
+      classes=""
+      login
+      icon={faLock}
+      on:click={() => {
+        goto(getLoginUrl(settings, $page.url.searchParams));
+      }}>Continue to SSO</Button
+    >
+  </div>
+</section>
diff --git a/src/routes/namespaces/[namespace]/workers/[queue]/__layout.svelte b/src/routes/namespaces/[namespace]/workers/[queue]/__layout.svelte
deleted file mode 100644
index 45be6b5e..00000000
--- a/src/routes/namespaces/[namespace]/workers/[queue]/__layout.svelte
+++ /dev/null
@@ -1,33 +0,0 @@
-<script context="module" lang="ts">
-  import type { Load } from '@sveltejs/kit';
-
-  import { getPollers } from '$lib/services/pollers-service';
-  export const load: Load = async function ({ fetch, params }) {
-    const { namespace, queue } = params;
-
-    return await getPollers({ queue, namespace }, fetch).then((pollers) => ({
-      props: { pollers },
-    }));
-  };
-</script>
-
-<script lang="ts">
-  import WorkersTable from '$lib/components/workers-table.svelte';
-  import WorkersRow from '$lib/components/workers-row.svelte';
-  import EmptyState from '$lib/components/empty-state.svelte';
-
-  import type { GetPollersResponse } from '$lib/services/pollers-service';
-
-  export let pollers: GetPollersResponse;
-</script>
-
-<section>
-  <h1 class="text-4xl mb-4">Workers</h1>
-  {#each pollers.pollers as poller}
-    <WorkersTable>
-      <WorkersRow {poller} />
-    </WorkersTable>
-  {:else}
-    <EmptyState title={'No Workers Found'} />
-  {/each}
-</section>
diff --git a/src/routes/namespaces/[namespace]/workers/[queue]/index.svelte b/src/routes/namespaces/[namespace]/workers/[queue]/index.svelte
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/routes/signin/__layout.reset.svelte b/src/routes/signin/__layout.reset.svelte
new file mode 100644
index 00000000..4fa864ce
--- /dev/null
+++ b/src/routes/signin/__layout.reset.svelte
@@ -0,0 +1,1 @@
+<slot />
diff --git a/src/routes/signin/index.svelte b/src/routes/signin/index.svelte
new file mode 100644
index 00000000..fb429326
--- /dev/null
+++ b/src/routes/signin/index.svelte
@@ -0,0 +1,54 @@
+<script context="module" lang="ts">
+  import { goto } from '$app/navigation';
+  import { page } from '$app/stores';
+
+  import Button from '$lib/components/button.svelte';
+  import { getLoginUrl } from '$lib/utilities/get-login-url';
+  import { faLock } from '@fortawesome/free-solid-svg-icons';
+
+  import { fetchSettings } from '$lib/services/settings-service';
+
+  import type { Load } from '@sveltejs/kit';
+
+  export const load: Load = async function ({ url }) {
+    const settings: Settings = await fetchSettings({ url });
+
+    if (!settings.auth.enabled) {
+      return {
+        status: 404,
+      };
+    }
+
+    return {
+      props: { settings },
+      stuff: { settings },
+    };
+  };
+</script>
+
+<script lang="ts">
+  export let settings: Settings;
+</script>
+
+<header
+  class="grid grid-rows-1 grid-cols-12 px-10 items-center bg-gray-900 shadow-lg gap-6"
+>
+  <a href="/" class="flex my-4 max-h-8">
+    <img src="/logo.svg" alt="Temporal Logo" class="max-h-8" />
+  </a>
+</header>
+
+<section class="text-center my-[20vh]">
+  <h1 class="text-8xl font-semibold">Welcome back.</h1>
+  <p class="my-7">Lets get you signed in.</p>
+  <div class="mx-auto">
+    <Button
+      classes=""
+      login
+      icon={faLock}
+      on:click={() => {
+        goto(getLoginUrl(settings, $page.url.searchParams));
+      }}>Continue to SSO</Button
+    >
+  </div>
+</section>


Test patch:
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 0821b654..c3659c50 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -13,5 +13,7 @@ jobs:
       - uses: actions/checkout@v2
       - name: Install modules
         run: npm ci
+      - name: Populate .svelte-dir with files for jest
+        run: npm run build:local
       - name: Run tests
         run: npm test
diff --git a/src/lib/utilities/get-login-url.test.ts b/src/lib/utilities/get-login-url.test.ts
new file mode 100644
index 00000000..5c90f582
--- /dev/null
+++ b/src/lib/utilities/get-login-url.test.ts
@@ -0,0 +1,70 @@
+import { getLoginUrl } from './get-login-url';
+
+describe('Get login url ', () => {
+  it('Options added through settings should be passed in the url', () => {
+    const settings = {
+      auth: {
+        options: ['one'],
+      },
+      baseUrl: 'https://localhost/',
+    };
+
+    const params = new URLSearchParams();
+    params.set('one', '1');
+    params.set('two', '2');
+
+    const login = getLoginUrl(settings, params);
+
+    const loginUrl = new URL(login);
+
+    expect(loginUrl.searchParams.get('one')).toBe('1');
+    expect(loginUrl.searchParams.get('two')).toBeNull();
+  });
+  it("We should not add the options from the search param if they don't exist in the current url params", () => {
+    const settings = {
+      auth: {
+        options: ['one'],
+      },
+      baseUrl: 'https://localhost/',
+    };
+
+    const params = new URLSearchParams();
+
+    const login = getLoginUrl(settings, params);
+
+    const loginUrl = new URL(login);
+
+    expect(loginUrl.searchParams.get('one')).toBeNull();
+    expect(login).toEqual('https://localhost/auth/sso');
+  });
+  it('Should render a login url', () => {
+    const settings = { auth: {}, baseUrl: 'https://localhost' };
+
+    const params = new URLSearchParams();
+
+    const login = getLoginUrl(settings, params);
+
+    expect(login).toEqual('https://localhost/auth/sso');
+  });
+  it('test of the signin flow', () => {
+    const settings = {
+      auth: {
+        options: ['organization_name', 'invitation'],
+      },
+      baseUrl: 'https://localhost/',
+    };
+
+    const params = new URLSearchParams(
+      'invitation=Wwv6g2cKkfjyqoLxnCPUCfiKcjHKpK%5B%E2%80%A6%5Dn9ipxcao0jKYH0I3&organization_name=temporal-cloud',
+    );
+
+    const login = getLoginUrl(settings, params);
+
+    const loginUrl = new URL(login);
+
+    expect(loginUrl.searchParams.get('one')).toBeNull();
+    expect(login).toEqual(
+      'https://localhost/auth/sso?organization_name=temporal-cloud&invitation=Wwv6g2cKkfjyqoLxnCPUCfiKcjHKpK%5B%E2%80%A6%5Dn9ipxcao0jKYH0I3',
+    );
+  });
+});
diff --git a/src/lib/utilities/paginated.test.ts b/src/lib/utilities/paginated.test.ts
index 2c1f16b0..1f8c2ba1 100644
--- a/src/lib/utilities/paginated.test.ts
+++ b/src/lib/utilities/paginated.test.ts
@@ -1,3 +1,4 @@
+jest.mock('$app/navigation', () => jest.fn({ goto: () => false } as any));
 import { paginated } from './paginated';
 
 const fetchMock = async (token: string | Uint8Array = null) => {