Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,6 @@ dmypy.json

# Yarn cache
.yarn/

# Test results
junit.xml
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,32 @@ In development mode, you will also need to remove the symlink created by `jupyte
command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
folder is located. Then you can remove the symlink named `jupyter-secrets-manager` within that folder.

### Docker Development

In project root folder, use the following command to create a docker container with a volumn of this project.

```bash
docker run -d --name jupyter-secrets-manager -p 8888:8888 -p 8000:8000 -v $(pwd):/workspace --user root quay.io/jupyter/base-notebook:latest jupyter lab --ip=0.0.0.0 --allow-root --no-browser --NotebookApp.token='my-token'
```

then you could open localhost:8888 to see the jupyterlab page.

open a terminal in JupyterLab, run the following command to develop install jupyter secrets manager package

```bash
cd /workspace
pip install -e "."
jupyter labextension develop . --overwrite
```

then run the following in laptop terminal to restart the docker container

```bash
docker stop jupyter-secrets-manager && docker start jupyter-secrets-manager
```

After restart, you will see jupyter-secrets-manger is already installed, and your changes for python file will automatically take effect,for typescript change, you will need to either rebuild it by `jlpm build` or watch it by `jlpm watch`

### Testing the extension

#### Frontend tests
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@
"@jupyterlab/application": "^4.0.0",
"@jupyterlab/statedb": "^4.0.0",
"@lumino/algorithm": "^2.0.0",
"@lumino/coreutils": "^2.1.2"
"@lumino/coreutils": "^2.1.2",
"@lumino/widgets": "^2.0.0"
},
"devDependencies": {
"@jupyterlab/builder": "^4.0.0",
Expand Down
146 changes: 146 additions & 0 deletions src/components/SecretsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import { ReactWidget } from '@jupyterlab/apputils';

import '../../style/base.css';
import { ISecret, ISecretsManager } from '../token';

interface ISecretsPanelProps {
manager: ISecretsManager;
}

const EyeIcon = () => (
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find a good import for these eye icons, please let me know if any better eye icons

<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</svg>
);

const EyeOffIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
</svg>
);

const SecretsPanel: React.FC<ISecretsPanelProps> = ({ manager }) => {
const [secrets, setSecrets] = useState<ISecret[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [namespaces, setNamespaces] = useState<string[]>([]);
const [currentNamespace, setCurrentNamespace] = useState('default');
const [visibleSecrets, setVisibleSecrets] = useState<Set<string>>(new Set());

const fetchNamespaces = async () => {
try {
const namespacesList = await manager.listNamespaces();
if (namespacesList) {
setNamespaces(namespacesList);
}
} catch (error) {
console.error('Error fetching namespaces:', error);
}
};

const fetchSecrets = async () => {
try {
const secretsList = await manager.list(currentNamespace);
if (secretsList) {
setSecrets(secretsList.values);
}
} catch (error) {
console.error('Error fetching secrets:', error);
} finally {
setIsLoading(false);
}
};

useEffect(() => {
fetchNamespaces();
}, []);

useEffect(() => {
fetchSecrets();
}, [currentNamespace]);

const toggleSecretVisibility = (secretId: string) => {
setVisibleSecrets(prev => {
const newSet = new Set(prev);
if (newSet.has(secretId)) {
newSet.delete(secretId);
} else {
newSet.add(secretId);
}
return newSet;
});
};

if (isLoading) {
return <div>Loading secrets...</div>;
}

return (
<div className="jp-SecretsPanel">
<div className="jp-SecretsPanel-header">
<h2>Secrets Manager</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<select
className="jp-SecretsPanel-namespace-select"
value={currentNamespace}
onChange={e => setCurrentNamespace(e.target.value)}
>
{namespaces.map(namespace => (
<option key={namespace} value={namespace}>
{namespace}
</option>
))}
</select>
</div>
</div>

<div className="jp-SecretsPanel-content">
{secrets.length === 0 ? (
<div className="jp-SecretsPanel-empty">No secrets found.</div>
) : (
<ul className="jp-SecretsPanel-list">
{secrets.map(secret => (
<li key={secret.id} className="jp-SecretsPanel-list-item">
<span className="jp-SecretsPanel-secret-name">{secret.id}</span>
<span
className={`jp-SecretsPanel-secret-value ${!visibleSecrets.has(secret.id) ? 'hidden' : ''}`}
>
{visibleSecrets.has(secret.id) ? secret.value : '••••••••'}
</span>
<button
className="jp-SecretsPanel-eye-button"
onClick={() => toggleSecretVisibility(secret.id)}
title={
visibleSecrets.has(secret.id)
? 'Hide secret'
: 'Show secret'
}
>
{visibleSecrets.has(secret.id) ? <EyeIcon /> : <EyeOffIcon />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
};

export interface ISecretsManagerWidgetOptions {
manager: ISecretsManager;
}

export class SecretsManagerWidget extends ReactWidget {
constructor(options: ISecretsManagerWidgetOptions) {
super();
this.addClass('jp-SecretsManagerWidget');
this._manager = options.manager;
}

render(): JSX.Element {
return <SecretsPanel manager={this._manager} />;
}

private _manager: ISecretsManager;
}
7 changes: 4 additions & 3 deletions src/connectors/in-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ export class InMemoryConnector implements ISecretsConnector {
const ids: string[] = [];
const values: ISecret[] = [];
this._secrets.forEach((value, key) => {
if (value.namespace === query) {
ids.push(key);
values.push(value);
if (query && value.namespace !== query) {
return;
}
ids.push(key);
values.push(value);
});
return { ids, values };
}
Expand Down
2 changes: 1 addition & 1 deletion src/connectors/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ passwords are stored as plain text in the local storage of the browser'
const secrets = JSON.parse(localStorage.getItem(this.storage) ?? '{}');
const initialValue: ISecretsList = { ids: [], values: [] };
return Object.keys(secrets)
.filter(key => secrets[key].namespace === query)
.filter(key => !query || secrets[key].namespace === query)
.reduce((acc, cur) => {
acc.ids.push(cur);
acc.values.push(secrets[cur]);
Expand Down
15 changes: 14 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
import { SecretsManager } from './manager';
import { ISecretsConnector, ISecretsManager } from './token';
import { InMemoryConnector } from './connectors';
import { SecretsManagerWidget } from './components/SecretsPanel';
import { Panel } from '@lumino/widgets';
import { lockIcon } from '@jupyterlab/ui-components';

/**
* A basic secret connector extension, that should be disabled to provide a new
Expand Down Expand Up @@ -34,7 +37,17 @@ const manager: JupyterFrontEndPlugin<ISecretsManager> = {
connector: ISecretsConnector
): ISecretsManager => {
console.log('JupyterLab extension jupyter-secrets-manager is activated!');
return new SecretsManager({ connector });
const secretsManager = new SecretsManager({ connector });
const panel = new Panel();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably build the side panel in another plugin, to be able to disable it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it too much to have the UI in another plugin? Do you have a use case in mind where we want to enable the secrets manage functionality but disable the UI?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already works to automatically populate inputs without UI, see #8 (comment).

panel.id = 'jupyter-secrets-manager:panel';
panel.title.icon = lockIcon;
const secretsManagerWidget = new SecretsManagerWidget({
manager: secretsManager
});
panel.addWidget(secretsManagerWidget);
app.shell.add(panel, 'left');

return secretsManager;
}
};

Expand Down
12 changes: 12 additions & 0 deletions src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ export class SecretsManager implements ISecretsManager {
return this._set(Private.buildSecretId(namespace, id), secret);
}

async listNamespaces(): Promise<string[]> {
if (!this._connector.list) {
return [];
}
await this._ready.promise;
const secrets = await this._connector.list();
const namespaces = Array.from(
new Set(secrets.ids.map(id => id.split(':')[0]))
);
return namespaces;
}

/**
* List the secrets for a namespace as a ISecretsList.
*/
Expand Down
4 changes: 4 additions & 0 deletions src/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export interface ISecretsManager {
* Detach all attached input for a namespace.
*/
detachAll(namespace: string): Promise<void>;
/**
* List all namespaces.
*/
listNamespaces(): Promise<string[]>;
}

/**
Expand Down
Loading
Loading