In Windows PowerShell, write $env:MIP_URL = "…" instead of export.
-
-
Then sign in, one of two ways:
-
-
-
No browser on that machine
-
- This token works like your password for up to 180 days. Keep it only in
- mip login --token; never put it in notebooks, .env files,
- repositories or chat. Lost it or your computer?
- See and remove computers signed in to MIP.
-
-
-
You confirm with your MIP sign-in. The token is shown only once.
- @if (tokenError()) {
-
{{ tokenError() }}
- }
- @if (cliToken()) {
-
-
{{ cliToken() }}
-
-
- {{ tokenCopyStatus() }}
-
- It lasts up to 180 days, or until it is unused for 30 days, or until you run
- mip logout. Lost it or your computer?
- See and remove computers signed in to MIP.
-
- }
-
- On that machine, run mip login --token and paste it. Scripts can set
- MIP_API_TOKEN instead, but every program started from there, AI assistants
- included, can read it.
-
-
-
-
-
A browser on any device
-
- Run mip login. It shows a link and a code: open the link, sign in, check
- that the page shows the same code, and approve.
-
-
Never approve a code that someone else sent you.
-
-
-
-
Then, in Python:
-
import mip
-client = mip.Client.from_env()
+
Access token
- Each sign-in lasts at most 180 days, and ends after 30 days without use. To end it sooner,
- run mip logout on that computer. Without that computer, remove
- "MIP command line" under Applications in your
- sign-in account: that ends every computer's sign-in.
+ Signs in the MIP client on a computer without a browser. This token works like your
+ password for up to 180 days. Keep it only in mip login --token; never put it
+ in notebooks, .env files, repositories or chat.
+
+
You confirm with your MIP sign-in. The token is shown only once.
+ @if (tokenError()) {
+
{{ tokenError() }}
+ }
+ @if (cliToken()) {
+
+
{{ cliToken() }}
+
+
+ {{ tokenCopyStatus() }}
+
+ It lasts up to 180 days, or until it is unused for 30 days, or until you run
+ mip logout. Lost it or your computer?
+ See and remove computers signed in to MIP.
+
+ }
+
+
+ Setup instructions
+
+
}
diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts
index 8aef1453..a8927145 100644
--- a/src/app/pages/account-page/account-page.component.spec.ts
+++ b/src/app/pages/account-page/account-page.component.spec.ts
@@ -50,27 +50,28 @@ describe('AccountPageComponent', () => {
expect(el.querySelector('.remote-access')).toBeNull();
});
- it('explains both ways to sign in and never sends users to the JupyterHub token page', () => {
+ it('keeps only the access token and a way to the setup instructions, never the JupyterHub token page', () => {
const el: HTMLElement = render().nativeElement;
- const text = el.querySelector('.remote-access')!.textContent!;
- expect(text).toContain('mip login --token');
- expect(text).toContain('MIP_API_TOKEN');
- expect(text).toContain('mip logout');
- expect(text).not.toContain('custom:mip-platform-token');
+ const section = el.querySelector('.remote-access')!;
+ expect(section.querySelector('h3')!.textContent).toBe('Access token');
+ expect(section.querySelector('.create-token-btn')).not.toBeNull();
+ expect(section.querySelector('.guide-link')!.getAttribute('href')).toBe('/account/remote-access');
+ expect(section.textContent).not.toContain('pip install');
+ expect(section.textContent).not.toContain('custom:mip-platform-token');
expect(el.querySelector('a[href*="/hub/token"]')).toBeNull();
expect(el.querySelector('.token-value')).toBeNull();
});
- it('links to the Keycloak applications page wherever it offers to remove computers', () => {
- const links = (render().nativeElement as HTMLElement).querySelectorAll('.account-link');
+ it('links to the Keycloak applications page next to the shown token', () => {
+ const fixture = render();
+ fixture.componentInstance.cliToken.set('offline-token');
+ fixture.detectChanges();
- expect(links.length).toBeGreaterThanOrEqual(2);
- links.forEach((link) => {
- expect(link.getAttribute('href')).toBe('https://idp.example/realms/MIP/account/applications');
- expect(link.target).toBe('_blank');
- expect(link.rel).toBe('noopener');
- });
+ const link = (fixture.nativeElement as HTMLElement).querySelector('a.account-link')!;
+ expect(link.getAttribute('href')).toBe('https://idp.example/realms/MIP/account/applications');
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener');
});
it('warns above the button that the token is a 180-day password kept only in mip login --token', () => {
@@ -181,39 +182,4 @@ describe('AccountPageComponent', () => {
fixture.detectChanges();
expect(button.disabled).toBe(false);
});
-
- it('copies a setup snippet built from the current origin, with no real token', async () => {
- const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
- const fixture = render();
-
- (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
- await fixture.whenStable();
-
- expect(writeText).toHaveBeenCalledWith(
- `pip install mip-client\nexport MIP_URL=${window.location.origin}`,
- );
- expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied');
- });
-
- it('clears the copy status so the next copy is announced again', async () => {
- vi.useFakeTimers();
- vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
- const fixture = render();
-
- const page = fixture.componentInstance;
- await page.copy(page.setupSnippet, page.copyStatus);
- expect(page.copyStatus()).toBe('Copied');
- vi.advanceTimersByTime(2400);
- expect(page.copyStatus()).toBe('');
- });
-
- it('tells the user to copy by hand when the clipboard refuses', async () => {
- vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'));
- const fixture = render();
-
- (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
- await fixture.whenStable();
-
- expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy');
- });
});
diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts
index 945f78f2..015aac4c 100644
--- a/src/app/pages/account-page/account-page.component.ts
+++ b/src/app/pages/account-page/account-page.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, signal, WritableSignal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, Location } from '@angular/common';
import { HttpClient } from '@angular/common/http';
@@ -6,6 +6,7 @@ import { RouterModule } from '@angular/router';
import { catchError, of } from 'rxjs';
import { AuthService } from "../../services/auth.service";
import { AuthConfig, cliTokenSignInUrl, finishCliTokenSignIn, isSignInReturn } from './cli-token';
+import { copyToClipboard } from './copy-to-clipboard';
@Component({
selector: 'app-account-page',
@@ -23,7 +24,7 @@ export class AccountPageComponent implements OnDestroy {
userEmail = computed(() => this.authService.authState().user?.email ?? '');
// The platform's public Keycloak client for the mip client; the backend answers 404
- // when there is none, and the remote access guide stays hidden.
+ // when there is none, and the access token section stays hidden.
readonly authConfig = toSignal(
this.http.get('/services/auth-config').pipe(catchError(() => of(null))),
{ initialValue: null },
@@ -35,8 +36,7 @@ export class AccountPageComponent implements OnDestroy {
readonly tokenError = signal('');
readonly creatingToken = signal(false);
readonly tokenCopyStatus = signal('');
- readonly setupSnippet = ['pip install mip-client', `export MIP_URL=${window.location.origin}`].join('\n');
- readonly copyStatus = signal('');
+ readonly copy = copyToClipboard;
constructor() {
const query = new URLSearchParams(window.location.search);
@@ -83,15 +83,4 @@ export class AccountPageComponent implements OnDestroy {
goBack(): void {
this.location.back();
}
-
- async copy(text: string, status: WritableSignal): Promise {
- try {
- await navigator.clipboard.writeText(text);
- status.set('Copied');
- } catch {
- status.set('Could not copy, select the text instead');
- }
- // Cleared after a while so the live region changes, and is announced, on every click.
- setTimeout(() => status.set(''), 2400);
- }
}
diff --git a/src/app/pages/account-page/copy-to-clipboard.ts b/src/app/pages/account-page/copy-to-clipboard.ts
new file mode 100644
index 00000000..25c8a3c1
--- /dev/null
+++ b/src/app/pages/account-page/copy-to-clipboard.ts
@@ -0,0 +1,13 @@
+import { WritableSignal } from '@angular/core';
+
+/** Copies text and reports the outcome in `status`, an aria-live region's text. */
+export async function copyToClipboard(text: string, status: WritableSignal): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ status.set('Copied');
+ } catch {
+ status.set('Could not copy, select the text instead');
+ }
+ // Cleared after a while so the live region changes, and is announced, on every click.
+ setTimeout(() => status.set(''), 2400);
+}
diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css
new file mode 100644
index 00000000..b888be5f
--- /dev/null
+++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.css
@@ -0,0 +1,120 @@
+/* Operating system switch: one tray, the chosen system raised. */
+.platform-tabs {
+ display: inline-flex;
+ gap: 4px;
+ padding: 4px;
+ border-radius: 999px;
+ background: #f3f4f6;
+}
+
+.platform-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 14px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: #4b5563;
+ font: inherit;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.platform-tab.active {
+ background: #ffffff;
+ color: var(--primary-color);
+ box-shadow: var(--shadow-sm);
+}
+
+.platform-tab:focus-visible {
+ outline: 2px solid var(--color-focus);
+ outline-offset: 2px;
+}
+
+/* Numbered steps; the number sits in the gutter beside each step title. */
+.guide-steps {
+ margin: 20px 0 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: guide-step;
+}
+
+.guide-steps > li {
+ position: relative;
+ padding: 0 0 24px 40px;
+ counter-increment: guide-step;
+}
+
+.guide-steps > li::before {
+ content: counter(guide-step);
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--primary-color) 10%, white);
+ color: var(--primary-color);
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 26px;
+ text-align: center;
+}
+
+.guide-steps h3 {
+ margin: 0 0 8px;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 26px;
+ color: #111827;
+}
+
+.guide-steps .remote-note {
+ margin: 0 0 8px;
+}
+
+.guide-steps .remote-option .remote-note {
+ margin: 8px 0 0;
+}
+
+.guide-footnote {
+ padding-top: 16px;
+ border-top: 1px solid #e5e7eb;
+}
+
+a.back-btn {
+ text-decoration: none;
+}
+
+/* Body text and the two sign-in options. */
+.remote-text {
+ margin: 14px 0 8px;
+}
+
+.remote-options {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: 12px 16px;
+}
+
+.remote-option {
+ padding: 14px 16px;
+ border-radius: 10px;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ min-width: 0;
+}
+
+.remote-option pre {
+ margin-top: 10px;
+ background: #ffffff;
+}
+
+.remote-option :not(pre) > code {
+ background: #e5e7eb;
+}
+
+.remote-option .remote-text {
+ margin: 10px 0 0;
+}
diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html
new file mode 100644
index 00000000..3b3722e6
--- /dev/null
+++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.html
@@ -0,0 +1,88 @@
+
+ Scripts can set {{ tokenVariable() }} instead, but every program started
+ from there, AI assistants included, can read it.
+
+
+
+
+
A browser on any device
+
+ Run mip login. It shows a link and a code: open the link, sign in, check
+ that the page shows the same code, and approve.
+
+
Never approve a code that someone else sent you.
+
+
+
+
+
+
Use it in Python
+
import mip
+client = mip.Client.from_env()
+
+
+
+
+ Each sign-in lasts at most 180 days, and ends after 30 days without use. To end it sooner,
+ run mip logout on that computer. Without that computer, remove
+ "MIP command line" under Applications in your
+ sign-in account: that ends every computer's sign-in.
+
+ } @else if (authConfig() === null) {
+
This portal does not offer access from your own computer.
+ }
+
+
+
diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts
new file mode 100644
index 00000000..9472025a
--- /dev/null
+++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.spec.ts
@@ -0,0 +1,100 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideZonelessChangeDetection } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { RemoteAccessGuideComponent } from './remote-access-guide.component';
+
+describe('RemoteAccessGuideComponent', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ // offered: whether the backend has a Keycloak client for `mip login` (GET /services/auth-config).
+ function render(offered = true) {
+ TestBed.configureTestingModule({
+ imports: [RemoteAccessGuideComponent],
+ providers: [provideZonelessChangeDetection(), provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
+ });
+ const fixture = TestBed.createComponent(RemoteAccessGuideComponent);
+ const authConfig = TestBed.inject(HttpTestingController).expectOne('/services/auth-config');
+ if (offered) {
+ authConfig.flush({ issuer: 'https://idp.example/realms/MIP', clientId: 'mip-cli' });
+ } else {
+ authConfig.flush(null, { status: 404, statusText: 'Not Found' });
+ }
+ fixture.componentInstance.platform.set('linux');
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ it('says so when the platform does not offer remote access', () => {
+ const el: HTMLElement = render(false).nativeElement;
+
+ expect(el.querySelector('.guide-steps')).toBeNull();
+ expect(el.textContent).toContain('does not offer access from your own computer');
+ });
+
+ it('explains both ways to sign in and sends the user to the profile page for a token', () => {
+ const el: HTMLElement = render().nativeElement;
+
+ const text = el.querySelector('.guide-steps')!.textContent!;
+ expect(text).toContain('mip login --token');
+ expect(text).toContain('MIP_API_TOKEN');
+ expect(text).toContain('mip.Client.from_env()');
+ expect(el.textContent).toContain('mip logout');
+ expect(el.querySelector('.remote-option a.account-link')!.getAttribute('href')).toBe('/account');
+ expect(el.querySelector('.guide-footnote a')!.getAttribute('href'))
+ .toBe('https://idp.example/realms/MIP/account/applications');
+ expect(el.querySelector('a[href*="/hub/token"]')).toBeNull();
+ });
+
+ it('switches the commands between Linux / macOS and Windows', () => {
+ const fixture = render();
+ const el: HTMLElement = fixture.nativeElement;
+ expect(el.querySelector('.snippet code')!.textContent).toContain(`export MIP_URL=${window.location.origin}`);
+
+ const windows = Array.from(el.querySelectorAll('.platform-tab'))
+ .find((tab) => tab.textContent!.includes('Windows'))!;
+ windows.click();
+ fixture.detectChanges();
+
+ expect(windows.getAttribute('aria-pressed')).toBe('true');
+ expect(el.querySelector('.snippet code')!.textContent).toContain(`$env:MIP_URL = "${window.location.origin}"`);
+ expect(el.textContent).toContain('$env:MIP_API_TOKEN');
+ expect(el.textContent).toContain('PowerShell');
+ });
+
+ it('copies a setup snippet built from the current origin, with no real token', async () => {
+ const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
+ const fixture = render();
+
+ (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
+ await fixture.whenStable();
+
+ expect(writeText).toHaveBeenCalledWith(`pip install mip-client\nexport MIP_URL=${window.location.origin}`);
+ expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied');
+ });
+
+ it('clears the copy status so the next copy is announced again', async () => {
+ vi.useFakeTimers();
+ vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
+ const page = render().componentInstance;
+
+ await page.copy(page.setupSnippet(), page.copyStatus);
+ expect(page.copyStatus()).toBe('Copied');
+ vi.advanceTimersByTime(2400);
+ expect(page.copyStatus()).toBe('');
+ });
+
+ it('tells the user to copy by hand when the clipboard refuses', async () => {
+ vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'));
+ const fixture = render();
+
+ (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
+ await fixture.whenStable();
+
+ expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy');
+ });
+});
diff --git a/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts
new file mode 100644
index 00000000..996d0456
--- /dev/null
+++ b/src/app/pages/account-page/remote-access-guide/remote-access-guide.component.ts
@@ -0,0 +1,43 @@
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { HttpClient } from '@angular/common/http';
+import { RouterModule } from '@angular/router';
+import { catchError, map, of } from 'rxjs';
+import { AuthConfig } from '../cli-token';
+import { copyToClipboard } from '../copy-to-clipboard';
+
+type Platform = 'linux' | 'windows';
+
+/** How to set up the mip client on the user's own computer, per operating system. */
+@Component({
+ selector: 'app-remote-access-guide',
+ imports: [RouterModule],
+ templateUrl: './remote-access-guide.component.html',
+ styleUrls: ['../account-page.component.css', './remote-access-guide.component.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RemoteAccessGuideComponent {
+ // undefined while loading; null when the platform has no Keycloak client for the mip client.
+ readonly authConfig = toSignal(
+ inject(HttpClient).get('/services/auth-config').pipe(
+ map((config): AuthConfig | null => config),
+ catchError(() => of(null)),
+ ),
+ );
+ readonly platforms: { id: Platform; label: string; icon: string }[] = [
+ { id: 'linux', label: 'Linux / macOS', icon: 'fab fa-linux' },
+ { id: 'windows', label: 'Windows', icon: 'fab fa-windows' },
+ ];
+ readonly platform = signal(navigator.userAgent.includes('Windows') ? 'windows' : 'linux');
+ readonly shellName = computed(() => (this.platform() === 'windows' ? 'PowerShell' : 'Terminal'));
+ readonly setupSnippet = computed(() => [
+ 'pip install mip-client',
+ this.platform() === 'windows'
+ ? `$env:MIP_URL = "${window.location.origin}"`
+ : `export MIP_URL=${window.location.origin}`,
+ ].join('\n'));
+ readonly tokenVariable = computed(() =>
+ this.platform() === 'windows' ? '$env:MIP_API_TOKEN = "…"' : 'export MIP_API_TOKEN=…');
+ readonly copyStatus = signal('');
+ readonly copy = copyToClipboard;
+}
diff --git a/src/app/pages/notebook/notebook.component.spec.ts b/src/app/pages/notebook/notebook.component.spec.ts
index f0eaa3e6..290dfc37 100644
--- a/src/app/pages/notebook/notebook.component.spec.ts
+++ b/src/app/pages/notebook/notebook.component.spec.ts
@@ -63,11 +63,6 @@ describe('NotebookComponent', () => {
beforeEach(async () => {
localStorage.clear();
- // The Hub-login redirect navigates via window.location and would pull the Vitest
- // browser iframe away from the harness ("Cannot connect to the iframe"), killing
- // the whole run when CI timing lets a wait exhaust. Throttle it for every test —
- // none of them expects the redirect; they expect ready or a spawn error.
- sessionStorage.setItem('mip_notebook_hub_login_redirect_ts', String(Date.now()));
(window as Window & { __env?: Record }).__env = {
JUPYTER_CONTEXT_PATH: '/notebook',
};
@@ -94,6 +89,12 @@ describe('NotebookComponent', () => {
fixture = TestBed.createComponent(NotebookComponent);
component = fixture.componentInstance;
+ // The Hub-login redirect navigates via window.location and would pull the Vitest browser
+ // iframe away from the harness ("Cannot connect to the iframe"), killing the whole run
+ // when a Hub wait exhausts. No test expects it (they expect ready or a spawn error), so
+ // tell the component its page is gone: ensureHubSession then answers 'failed' instead.
+ // A sessionStorage throttle is not enough, spec files share it and clear it mid-run.
+ (component as unknown as { active: boolean }).active = false;
});
afterEach(() => {
@@ -245,7 +246,6 @@ describe('NotebookComponent', () => {
});
it('shows an error when the Hub session never becomes available', async () => {
- sessionStorage.setItem('mip_notebook_hub_login_redirect_ts', String(Date.now()));
fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/hub/api/user') || url.includes('/hub/home')) {