mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
feat(core): Show Public API key value only once (no-changelog) (#10126)
This commit is contained in:
parent
de50ef7590
commit
cf70b06545
|
@ -25,6 +25,8 @@ import { UserRepository } from '@/databases/repositories/user.repository';
|
|||
import { isApiEnabled } from '@/PublicApi';
|
||||
import { EventService } from '@/eventbus/event.service';
|
||||
|
||||
export const API_KEY_PREFIX = 'n8n_api_';
|
||||
|
||||
export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => {
|
||||
if (isApiEnabled()) {
|
||||
next();
|
||||
|
@ -208,7 +210,8 @@ export class MeController {
|
|||
*/
|
||||
@Get('/api-key', { middlewares: [isApiEnabledMiddleware] })
|
||||
async getAPIKey(req: AuthenticatedRequest) {
|
||||
return { apiKey: req.user.apiKey };
|
||||
const apiKey = this.redactApiKey(req.user.apiKey);
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -242,4 +245,14 @@ export class MeController {
|
|||
|
||||
return user.settings;
|
||||
}
|
||||
|
||||
private redactApiKey(apiKey: string | null) {
|
||||
if (!apiKey) return;
|
||||
const keepLength = 5;
|
||||
return (
|
||||
API_KEY_PREFIX +
|
||||
apiKey.slice(API_KEY_PREFIX.length, API_KEY_PREFIX.length + keepLength) +
|
||||
'*'.repeat(apiKey.length - API_KEY_PREFIX.length - keepLength)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -104,7 +104,7 @@ export class User extends WithTimestamps implements IUser {
|
|||
|
||||
@Column({ type: String, nullable: true })
|
||||
@Index({ unique: true })
|
||||
apiKey?: string | null;
|
||||
apiKey: string | null;
|
||||
|
||||
@Column({ type: Boolean, default: false })
|
||||
mfaEnabled: boolean;
|
||||
|
|
|
@ -175,14 +175,14 @@ describe('Owner shell', () => {
|
|||
expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey);
|
||||
});
|
||||
|
||||
test('GET /me/api-key should fetch the api key', async () => {
|
||||
test('GET /me/api-key should fetch the api key redacted', async () => {
|
||||
const response = await authOwnerShellAgent.get('/me/api-key');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.apiKey).toEqual(ownerShell.apiKey);
|
||||
expect(response.body.data.apiKey).not.toEqual(ownerShell.apiKey);
|
||||
});
|
||||
|
||||
test('DELETE /me/api-key should fetch the api key', async () => {
|
||||
test('DELETE /me/api-key should delete the api key', async () => {
|
||||
const response = await authOwnerShellAgent.delete('/me/api-key');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
@ -327,14 +327,14 @@ describe('Member', () => {
|
|||
expect(storedMember.apiKey).toEqual(response.body.data.apiKey);
|
||||
});
|
||||
|
||||
test('GET /me/api-key should fetch the api key', async () => {
|
||||
test('GET /me/api-key should fetch the api key redacted', async () => {
|
||||
const response = await testServer.authAgentFor(member).get('/me/api-key');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.apiKey).toEqual(member.apiKey);
|
||||
expect(response.body.data.apiKey).not.toEqual(member.apiKey);
|
||||
});
|
||||
|
||||
test('DELETE /me/api-key should fetch the api key', async () => {
|
||||
test('DELETE /me/api-key should delete the api key', async () => {
|
||||
const response = await testServer.authAgentFor(member).delete('/me/api-key');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
|
|
@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken';
|
|||
import { mock, anyObject } from 'jest-mock-extended';
|
||||
import type { PublicUser } from '@/Interfaces';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { MeController } from '@/controllers/me.controller';
|
||||
import { API_KEY_PREFIX, MeController } from '@/controllers/me.controller';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import type { AuthenticatedRequest, MeRequest } from '@/requests';
|
||||
import { UserService } from '@/services/user.service';
|
||||
|
@ -223,7 +223,7 @@ describe('MeController', () => {
|
|||
describe('API Key methods', () => {
|
||||
let req: AuthenticatedRequest;
|
||||
beforeAll(() => {
|
||||
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: 'test-key' }) });
|
||||
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}test-key` }) });
|
||||
});
|
||||
|
||||
describe('createAPIKey', () => {
|
||||
|
@ -234,9 +234,9 @@ describe('MeController', () => {
|
|||
});
|
||||
|
||||
describe('getAPIKey', () => {
|
||||
it('should return the users api key', async () => {
|
||||
it('should return the users api key redacted', async () => {
|
||||
const { apiKey } = await controller.getAPIKey(req);
|
||||
expect(apiKey).toEqual(req.user.apiKey);
|
||||
expect(apiKey).not.toEqual(req.user.apiKey);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
[$style.copyText]: true,
|
||||
[$style[size]]: true,
|
||||
[$style.collapsed]: collapse,
|
||||
[$style.noHover]: disableCopy,
|
||||
'ph-no-capture': redactValue,
|
||||
}"
|
||||
data-test-id="copy-input"
|
||||
@click="copy"
|
||||
>
|
||||
<span ref="copyInputValue">{{ value }}</span>
|
||||
<div :class="$style.copyButton">
|
||||
<div v-if="!disableCopy" :class="$style.copyButton">
|
||||
<span>{{ copyButtonText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,6 +37,7 @@ type Props = {
|
|||
size?: 'medium' | 'large';
|
||||
collapse?: boolean;
|
||||
redactValue?: boolean;
|
||||
disableCopy: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -46,6 +48,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
size: 'medium',
|
||||
copyButtonText: useI18n().baseText('generic.copy'),
|
||||
toastTitle: useI18n().baseText('generic.copiedToClipboard'),
|
||||
disableCopy: false,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
|
@ -55,6 +58,8 @@ const clipboard = useClipboard();
|
|||
const { showMessage } = useToast();
|
||||
|
||||
function copy() {
|
||||
if (props.disableCopy) return;
|
||||
|
||||
emit('copy');
|
||||
void clipboard.copy(props.value ?? '');
|
||||
|
||||
|
@ -88,6 +93,10 @@ function copy() {
|
|||
}
|
||||
}
|
||||
|
||||
.noHover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.large {
|
||||
span {
|
||||
font-size: var(--font-size-s);
|
||||
|
|
|
@ -1724,6 +1724,7 @@
|
|||
"settings.api.view.copy.toast": "API Key copied to clipboard",
|
||||
"settings.api.view.apiPlayground": "API Playground",
|
||||
"settings.api.view.info": "Use your API Key to control n8n programmatically using the {apiAction}. But if you only want to trigger workflows, consider using the {webhookAction} instead.",
|
||||
"settings.api.view.copy": "Make sure to copy your API key now as you will not be able to see this again.",
|
||||
"settings.api.view.info.api": "n8n API",
|
||||
"settings.api.view.info.webhook": "webhook node",
|
||||
"settings.api.view.myKey": "My API Key",
|
||||
|
|
|
@ -43,11 +43,13 @@
|
|||
:copy-button-text="$locale.baseText('generic.clickToCopy')"
|
||||
:toast-title="$locale.baseText('settings.api.view.copy.toast')"
|
||||
:redact-value="true"
|
||||
:disable-copy="isRedactedApiKey"
|
||||
:hint="!isRedactedApiKey ? $locale.baseText('settings.api.view.copy') : ''"
|
||||
@copy="onCopy"
|
||||
/>
|
||||
</div>
|
||||
</n8n-card>
|
||||
<div :class="$style.hint">
|
||||
<div v-if="!isRedactedApiKey" :class="$style.hint">
|
||||
<n8n-text size="small">
|
||||
{{
|
||||
$locale.baseText(`settings.api.view.${swaggerUIEnabled ? 'tryapi' : 'more-details'}`)
|
||||
|
@ -146,6 +148,9 @@ export default defineComponent({
|
|||
isPublicApiEnabled(): boolean {
|
||||
return this.settingsStore.isPublicApiEnabled;
|
||||
},
|
||||
isRedactedApiKey(): boolean {
|
||||
return this.apiKey.includes('*');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onUpgrade() {
|
||||
|
|
Loading…
Reference in a new issue