Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions e2e/experiment-studio.flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,14 @@ test.describe('filters and transformations', () => {

test('nominal column and k-means cluster column together', async ({ page, api }) => {
await openAgeAndSex(page);
await addNominalColumn(page, 'sex_group', 'female', 'other');
await addNominalColumn(page, 'sex_group', 'female');
await addKMeansColumn(page, { stepAdded: true });
await expect(page.getByText('Nominal column · sex_group')).toBeVisible();
await expect(page.locator('app-station-card', { hasText: 'Nominal column · sex_group' })).toContainText('Applied');
await expect(page.getByText('Column kmeans_cluster')).toBeVisible();
// Choosing the K-means card folds the nominal one; opening it again shows its rule.
await expect(page.getByText('Sex is Female')).toBeHidden();
await page.getByRole('button', { name: /^Nominal column · sex_group/ }).click();
await expect(page.getByText('Sex is Female')).toBeVisible();

const steps = lastRequest(api, 'POST', PREVIEW, 'describe').analysis.preprocessing;
Expand All @@ -377,7 +380,6 @@ test.describe('filters and transformations', () => {
new_column_names: ['sex_group'],
strategy: 'filter_rules',
rules: { female: { condition: 'AND', rules: [expect.objectContaining({ field: 'sex', operator: 'equal', value: 'F' })], valid: true } },
default_enumeration: 'other',
},
});
expect(steps).toContainEqual({
Expand Down
3 changes: 1 addition & 2 deletions e2e/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export async function applyFilters(page: Page, { female = false } = {}) {
await page.locator('.filter-workflow-section').getByRole('button', { name: 'Apply filters' }).click();
}

export async function addNominalColumn(page: Page, code: string, category: string, fallback: string) {
export async function addNominalColumn(page: Page, code: string, category: string) {
await page.getByTitle('Add and open a derived column').click();
await page.getByRole('button', { name: /Nominal column from rules/ }).click();
await page.getByPlaceholder('e.g. mrs_good_outcome').fill(code);
Expand All @@ -216,7 +216,6 @@ export async function addNominalColumn(page: Page, code: string, category: strin
await fields.getByPlaceholder('Type to find a variable').fill('Sex');
await fields.locator('select.filter-field-input').last().selectOption({ label: 'Female' });
await editor.getByRole('button', { name: 'Done' }).click();
await page.getByPlaceholder('unknown').fill(fallback);
await expect(page.getByText('Sex is Female')).toBeVisible();
}

Expand Down
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const appRoutes: Routes = [
loadComponent: () => import('./pages/account-page/account-page.component').then(m => m.AccountPageComponent),
canActivate: [AuthGuard, TermsGuard],
},
{
path: 'account/remote-access',
loadComponent: () => import('./pages/account-page/remote-access-guide/remote-access-guide.component').then(m => m.RemoteAccessGuideComponent),
canActivate: [AuthGuard, TermsGuard],
},
{
path: 'experiment-studio',
loadComponent: () => import('./pages/experiment-studio/experiment-studio.component').then(m => m.ExperimentStudioComponent),
Expand Down
50 changes: 25 additions & 25 deletions src/app/pages/account-page/account-page.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,6 @@
font-size: 0.92em;
}

.remote-text {
margin: 14px 0 8px;
}

.remote-note {
margin: 6px 0 0;
Expand All @@ -532,32 +529,10 @@
color: var(--primary-color);
}

.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;
}

.remote-access h4 {
margin: 0 0 10px;
Expand Down Expand Up @@ -614,6 +589,7 @@
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-top: 10px;
}

.create-token-btn:hover:not(:disabled) {
Expand All @@ -634,3 +610,27 @@
margin: 8px 0 0;
color: #991b1b;
}

/* Way into the setup instructions page. */
.guide-link {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 18px;
padding: 8px 14px;
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
color: var(--primary-color);
font-size: 14px;
font-weight: 500;
text-decoration: none;
}

.guide-link:hover {
background: color-mix(in srgb, var(--primary-color) 6%, white);
}

.guide-link:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
109 changes: 34 additions & 75 deletions src/app/pages/account-page/account-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,83 +46,42 @@ <h3>Profile Information</h3>

@if (authConfig()) {
<section class="info-section remote-access">
<h3>Use MIP from your own computer</h3>

<p class="remote-text">Install the client and point it at this portal:</p>
<div class="snippet">
<pre><code>{{ setupSnippet }}</code></pre>
<button class="copy-btn snippet-copy-btn" type="button" (click)="copy(setupSnippet, copyStatus)"
aria-label="Copy setup commands">
<i class="fas fa-copy"></i>
<span>Copy</span>
</button>
</div>
<span class="copy-status" aria-live="polite">{{ copyStatus() }}</span>
<p class="remote-note">In Windows PowerShell, write <code>$env:MIP_URL = "…"</code> instead of <code>export</code>.</p>

<p class="remote-text">Then sign in, one of two ways:</p>
<div class="remote-options">
<div class="remote-option">
<h4>No browser on that machine</h4>
<p class="remote-note">
This token works like your password for up to 180 days. Keep it only in
<code>mip login --token</code>; never put it in notebooks, <code>.env</code> files,
repositories or chat. Lost it or your computer?
<a class="account-link" [href]="authConfig()!.issuer + '/account/applications'" target="_blank"
rel="noopener">See and remove computers signed in to MIP</a>.
</p>
<button class="create-token-btn" type="button" (click)="createToken()" [disabled]="creatingToken()">
<i class="fas fa-key"></i>
<span>{{ creatingToken() ? 'Creating…' : 'Create access token' }}</span>
</button>
<p class="remote-note">You confirm with your MIP sign-in. The token is shown only once.</p>
@if (tokenError()) {
<p class="token-error" role="alert">{{ tokenError() }}</p>
}
@if (cliToken()) {
<div class="snippet">
<pre class="token-value"><code>{{ cliToken() }}</code></pre>
<button class="copy-btn token-copy-btn" type="button" (click)="copy(cliToken(), tokenCopyStatus)"
aria-label="Copy access token">
<i class="fas fa-copy"></i>
<span>Copy</span>
</button>
</div>
<span class="copy-status" aria-live="polite">{{ tokenCopyStatus() }}</span>
<p class="remote-note">
It lasts up to 180 days, or until it is unused for 30 days, or until you run
<code>mip logout</code>. Lost it or your computer?
<a class="account-link" [href]="authConfig()!.issuer + '/account/applications'" target="_blank"
rel="noopener">See and remove computers signed in to MIP</a>.
</p>
}
<p class="remote-text">
On that machine, run <code>mip login --token</code> and paste it. Scripts can set
<code>MIP_API_TOKEN</code> instead, but every program started from there, AI assistants
included, can read it.
</p>
</div>

<div class="remote-option">
<h4>A browser on any device</h4>
<p class="remote-text">
Run <code>mip login</code>. It shows a link and a code: open the link, sign in, check
that the page shows the same code, and approve.
</p>
<p class="remote-note">Never approve a code that someone else sent you.</p>
</div>
</div>

<p class="remote-text">Then, in Python:</p>
<pre><code>import mip
client = mip.Client.from_env()</code></pre>
<h3>Access token</h3>
<p class="remote-note">
Each sign-in lasts at most 180 days, and ends after 30 days without use. To end it sooner,
run <code>mip logout</code> on that computer. Without that computer, remove
"MIP command line" under Applications in your
<a class="account-link" [href]="authConfig()!.issuer + '/account/applications'" target="_blank"
rel="noopener">sign-in account</a>: 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 <code>mip login --token</code>; never put it
in notebooks, <code>.env</code> files, repositories or chat.
</p>
<button class="create-token-btn" type="button" (click)="createToken()" [disabled]="creatingToken()">
<i class="fas fa-key"></i>
<span>{{ creatingToken() ? 'Creating…' : 'Create access token' }}</span>
</button>
<p class="remote-note">You confirm with your MIP sign-in. The token is shown only once.</p>
@if (tokenError()) {
<p class="token-error" role="alert">{{ tokenError() }}</p>
}
@if (cliToken()) {
<div class="snippet">
<pre class="token-value"><code>{{ cliToken() }}</code></pre>
<button class="copy-btn token-copy-btn" type="button" (click)="copy(cliToken(), tokenCopyStatus)"
aria-label="Copy access token">
<i class="fas fa-copy"></i>
<span>Copy</span>
</button>
</div>
<span class="copy-status" aria-live="polite">{{ tokenCopyStatus() }}</span>
<p class="remote-note">
It lasts up to 180 days, or until it is unused for 30 days, or until you run
<code>mip logout</code>. Lost it or your computer?
<a class="account-link" [href]="authConfig()!.issuer + '/account/applications'" target="_blank"
rel="noopener">See and remove computers signed in to MIP</a>.
</p>
}
<a class="guide-link" routerLink="/account/remote-access">
<i class="fas fa-book-open"></i>
<span>Setup instructions</span>
<i class="fas fa-arrow-right"></i>
</a>
</section>
}
</section>
Expand Down
64 changes: 15 additions & 49 deletions src/app/pages/account-page/account-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAnchorElement>('.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<HTMLAnchorElement>('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', () => {
Expand Down Expand Up @@ -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');
});
});
19 changes: 4 additions & 15 deletions src/app/pages/account-page/account-page.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';
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',
Expand All @@ -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<AuthConfig>('/services/auth-config').pipe(catchError(() => of(null))),
{ initialValue: null },
Expand All @@ -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);
Expand Down Expand Up @@ -83,15 +83,4 @@ export class AccountPageComponent implements OnDestroy {
goBack(): void {
this.location.back();
}

async copy(text: string, status: WritableSignal<string>): Promise<void> {
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);
}
}
13 changes: 13 additions & 0 deletions src/app/pages/account-page/copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -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<string>): Promise<void> {
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);
}
Loading
Loading