mirror of https://github.com/rancher/dashboard.git
370 lines
12 KiB
Vue
370 lines
12 KiB
Vue
<script lang="ts">
|
|
import Loading from '@shell/components/Loading.vue';
|
|
import { OIDC_CLIENT_SECRET_ANNOTATIONS } from '@shell/config/labels-annotations';
|
|
import { SECRET } from '@shell/config/types';
|
|
import { exceptionToErrorsArray } from '@shell/utils/error';
|
|
import { defineComponent } from 'vue';
|
|
import CopyToClipboardText from '@shell/components/CopyToClipboardText.vue';
|
|
import DateComponent from '@shell/components/formatter/Date.vue';
|
|
import { RcItemCard } from '@components/RcItemCard';
|
|
import ActionMenu from '@shell/components/ActionMenuShell.vue';
|
|
import { Banner } from '@components/Banner';
|
|
|
|
type SecretActionType = 'create-secret' | 'regen-secret' | 'remove-secret'
|
|
interface ClientSecretData { createdAt: string, lastUsedAt: string, lastFiveCharacters: string }
|
|
|
|
export const OIDC_CLIENT_SECRET_ACTION: { [key: string]: SecretActionType } = {
|
|
CREATE: 'create-secret',
|
|
REGEN: 'regen-secret',
|
|
REMOVE: 'remove-secret',
|
|
};
|
|
|
|
interface SecretManageData {
|
|
id: string,
|
|
header?: any,
|
|
image?: any,
|
|
createdAt: string,
|
|
lastFiveCharacters: string,
|
|
lastUsedAt: string,
|
|
displayFullSecret: boolean,
|
|
}
|
|
|
|
const OIDC_SECRETS_NAMESPACE = 'cattle-oidc-client-secrets';
|
|
|
|
export default defineComponent({
|
|
emits: ['regenerateSecret', 'removeSecret'],
|
|
|
|
components: {
|
|
CopyToClipboardText,
|
|
DateComponent,
|
|
RcItemCard,
|
|
ActionMenu,
|
|
Loading,
|
|
Banner
|
|
},
|
|
|
|
props: {
|
|
value: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
},
|
|
|
|
created() {
|
|
// this mean that we came from the create screen and we need to display that first secret generated
|
|
if (window.history?.state?.displaySecret) {
|
|
this.displayFirstSecret = true;
|
|
const state = window.history.state;
|
|
|
|
const { displaySecret, ...cleanedState } = state;
|
|
|
|
// let's clean from the browser history state the flag so that when we do a refresh we don't get the same state back (we don't want to show the secret ever again)
|
|
window.history.replaceState(cleanedState, '', window.location.href);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
this.showNoSecretsDelay = true;
|
|
}, 5000);
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
displayFirstSecret: false,
|
|
errors: [] as any[],
|
|
snapshotSecretsOnLoad: Object.assign({}, this.value?.status?.clientSecrets),
|
|
OIDC_CLIENT_SECRET_ACTION,
|
|
secretsRegenerated: [] as any[],
|
|
cardActions: [
|
|
{
|
|
enabled: true,
|
|
total: 0,
|
|
allEnabled: true,
|
|
anyEnabled: true,
|
|
available: 0,
|
|
action: 'regenerateSecret',
|
|
label: this.t('oidcclient.regenerate')
|
|
},
|
|
{
|
|
enabled: true,
|
|
total: 0,
|
|
allEnabled: true,
|
|
anyEnabled: true,
|
|
available: 0,
|
|
action: 'removeSecret',
|
|
label: this.t('generic.delete')
|
|
}
|
|
],
|
|
canFetchSecrets: this.$store.getters[`management/canList`](SECRET),
|
|
showNoSecretsDelay: false
|
|
};
|
|
},
|
|
|
|
methods: {
|
|
promptSecretsModal(actionType: SecretActionType, secret: SecretManageData) {
|
|
this.errors = [];
|
|
|
|
this.$store.dispatch('management/promptModal', {
|
|
component: 'OidcClientSecretDialog',
|
|
customClass: 'remove-modal',
|
|
modalWidth: 400,
|
|
height: 'auto',
|
|
styles: 'max-height: 100vh;',
|
|
componentProps: {
|
|
type: actionType,
|
|
actionCb: async() => {
|
|
try {
|
|
await this.performSecretAction(actionType, secret);
|
|
} catch (error: any) {
|
|
this.errors = exceptionToErrorsArray(error);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
async addNewSecret() {
|
|
this.errors = [];
|
|
|
|
try {
|
|
await this.performSecretAction(OIDC_CLIENT_SECRET_ACTION.CREATE, {});
|
|
} catch (error: any) {
|
|
this.errors = exceptionToErrorsArray(error);
|
|
}
|
|
},
|
|
async performSecretAction(actionType: SecretActionType, secret: SecretManageData | any) {
|
|
let isValidAction = false;
|
|
|
|
if (!this.value?.metadata?.annotations) {
|
|
this.value.metadata.annotations = {};
|
|
}
|
|
|
|
if (actionType === OIDC_CLIENT_SECRET_ACTION.CREATE) {
|
|
this.value.metadata.annotations[OIDC_CLIENT_SECRET_ANNOTATIONS.CREATE] = 'true';
|
|
isValidAction = true;
|
|
} else if (actionType === OIDC_CLIENT_SECRET_ACTION.REGEN) {
|
|
this.value.metadata.annotations[OIDC_CLIENT_SECRET_ANNOTATIONS.REGEN] = secret.id;
|
|
this.secretsRegenerated.push(secret);
|
|
isValidAction = true;
|
|
} else if (actionType === OIDC_CLIENT_SECRET_ACTION.REMOVE) {
|
|
this.value.metadata.annotations[OIDC_CLIENT_SECRET_ANNOTATIONS.REMOVE] = secret.id;
|
|
isValidAction = true;
|
|
}
|
|
|
|
if (isValidAction) {
|
|
return await this.value.save();
|
|
}
|
|
|
|
Promise.reject(new Error(this.t('oidcclient.errors.performSecretAction')));
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
clientSecretId: {
|
|
handler(neu) {
|
|
if (neu && this.canFetchSecrets) {
|
|
this.$store.dispatch('management/find', { type: SECRET, id: neu });
|
|
}
|
|
},
|
|
immediate: true
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
clientID(): string {
|
|
return this.value?.status?.clientID || '';
|
|
},
|
|
|
|
clientSecretId(): string {
|
|
// We can either use `${ OIDC_SECRETS_NAMESPACE }/${ this.value.status.clientID }` directly, or attempt to find the secret owned by the client
|
|
return this.value.metadata.relationships.find((r: any) => r.rel === 'owner' && r.state === 'active' && r.toType === SECRET && r.toId.startsWith(OIDC_SECRETS_NAMESPACE))?.toId;
|
|
},
|
|
|
|
clientSecret(): Record<string, any> | undefined {
|
|
return this.clientSecretId ? this.$store.getters['management/byId'](SECRET, this.clientSecretId) : undefined;
|
|
},
|
|
|
|
clientSecrets(): SecretManageData[] {
|
|
const clientSecrets: SecretManageData[] = [];
|
|
const oidcClientSecretsData: [string, ClientSecretData][] = this.value?.status?.clientSecrets ? Object.entries(this.value?.status?.clientSecrets) : [];
|
|
|
|
oidcClientSecretsData.forEach(([oidcSecretDataKey, oidcSecretData], index: number) => {
|
|
if (oidcSecretDataKey && oidcSecretData) {
|
|
const createdAtNum = parseInt(oidcSecretData.createdAt || '0');
|
|
const createdAt = createdAtNum ? new Date(createdAtNum * 1000).toISOString() : '';
|
|
const lastUsedAtNum = parseInt(oidcSecretData.lastUsedAt || '0');
|
|
const lastUsedAt = lastUsedAtNum ? new Date(lastUsedAtNum * 1000).toISOString() : '';
|
|
|
|
let displayFullSecret = false;
|
|
|
|
// this covers the first render after creation
|
|
if (this.displayFirstSecret && index === 0) {
|
|
displayFullSecret = true;
|
|
// this cover the "add secret" scenario
|
|
} else if (!this.snapshotSecretsOnLoad[oidcSecretDataKey]) {
|
|
displayFullSecret = true;
|
|
}
|
|
|
|
// this cover the "regen secret" scenario
|
|
if (this.secretsRegenerated.find((regen: SecretManageData) => regen.id === oidcSecretDataKey && regen.lastFiveCharacters !== oidcSecretData.lastFiveCharacters)) {
|
|
displayFullSecret = true;
|
|
}
|
|
|
|
// this covers the removal, then adding a new secret again... We need to compare keys so that we can check it's new and render it
|
|
if (this.snapshotSecretsOnLoad[oidcSecretDataKey] && (this.snapshotSecretsOnLoad[oidcSecretDataKey].lastFiveCharacters !== oidcSecretData.lastFiveCharacters)) {
|
|
displayFullSecret = true;
|
|
}
|
|
|
|
clientSecrets.push({
|
|
id: oidcSecretDataKey,
|
|
header: { title: { text: oidcSecretDataKey } },
|
|
image: { src: require('~shell/assets/images/key.svg') },
|
|
createdAt,
|
|
lastFiveCharacters: oidcSecretData.lastFiveCharacters,
|
|
lastUsedAt,
|
|
displayFullSecret,
|
|
});
|
|
}
|
|
});
|
|
|
|
return clientSecrets;
|
|
},
|
|
|
|
showSecrets(): boolean {
|
|
return this.value.status?.clientID;
|
|
},
|
|
|
|
showNoSecrets(): boolean {
|
|
// Avoid the no secrets message blipping up whilst we wait for the client status to update over websocket (which unlocks the details required to fetch secrets)
|
|
return !this.showSecrets && this.showNoSecretsDelay;
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<Loading v-if="!value" />
|
|
<div v-else-if="showSecrets">
|
|
<div
|
|
v-if="errors && errors.length"
|
|
>
|
|
<Banner
|
|
v-for="(err, i) in errors"
|
|
:key="i"
|
|
color="error"
|
|
:data-testid="`error-banner${i}`"
|
|
:label="err"
|
|
/>
|
|
</div>
|
|
|
|
<!-- clientID -->
|
|
<h3
|
|
class="mt-10 mb-20"
|
|
>
|
|
{{ t('oidcclient.clientId') }}:
|
|
<CopyToClipboardText
|
|
:aria-label="t('oidcclient.a11y.copyText.clientId')"
|
|
:text="clientID"
|
|
data-testid="oidc-clients-copy-clipboard-client-id"
|
|
/>
|
|
</h3>
|
|
<!-- secrets cards -->
|
|
<div
|
|
class="mb-20"
|
|
>
|
|
<h3 class="mt-20 mb-20">
|
|
{{ t('oidcclient.clientSecrets') }}
|
|
</h3>
|
|
<div class="card-grid-container">
|
|
<rc-item-card
|
|
v-for="(secret, i) in clientSecrets"
|
|
:id="secret.id"
|
|
:key="secret.id"
|
|
class="card-item"
|
|
:header="secret.header"
|
|
:image="secret.image"
|
|
:value="secret"
|
|
variant="medium"
|
|
:clickable="false"
|
|
>
|
|
<template #item-card-content>
|
|
<p v-if="secret.displayFullSecret">
|
|
<CopyToClipboardText
|
|
:aria-label="t('oidcclient.a11y.copyText.clientSecret')"
|
|
:text="clientSecret?.decodedData?.[secret.id]"
|
|
:data-testid="`oidc-client-secret-${i}-copy-full-secret`"
|
|
/>
|
|
</p>
|
|
<p v-else>
|
|
********{{ secret.lastFiveCharacters }}
|
|
</p>
|
|
</template>
|
|
<template
|
|
#item-card-footer
|
|
>
|
|
<div class="card-footer">
|
|
<p class="mr-40">
|
|
<span class="card-footer-title">{{ t('generic.created') }}</span>
|
|
<span>: <DateComponent :value="secret.createdAt" /></span>
|
|
</p>
|
|
<p>
|
|
<span class="card-footer-title">{{ t('tableHeaders.lastUsed') }}</span>
|
|
<span v-if="!secret.lastUsedAt">: {{ t('oidcclient.usedNever') }}</span>
|
|
<span v-else>: <DateComponent :value="secret.lastUsedAt" /></span>
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<template
|
|
#item-card-actions
|
|
>
|
|
<ActionMenu
|
|
:data-testid="`oidc-client-secret-${i}-action-menu`"
|
|
:resource="secret"
|
|
:custom-actions="cardActions"
|
|
@regenerateSecret="promptSecretsModal(OIDC_CLIENT_SECRET_ACTION.REGEN, secret)"
|
|
@removeSecret="promptSecretsModal(OIDC_CLIENT_SECRET_ACTION.REMOVE, secret)"
|
|
/>
|
|
</template>
|
|
</rc-item-card>
|
|
</div>
|
|
<button
|
|
class="btn role-primary mt-20"
|
|
data-testid="oidc-client-add-new-secret"
|
|
@click="addNewSecret"
|
|
>
|
|
{{ t('oidcclient.addNewSecret') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="showNoSecrets">
|
|
{{ t('oidcclient.noClientId') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.card-item {
|
|
max-width: 800px
|
|
}
|
|
.card-grid-container {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
|
|
gap: 24px;
|
|
}
|
|
|
|
.card-footer {
|
|
display: flex;
|
|
|
|
p {
|
|
color: var(--link-text-secondary);
|
|
font-weight: 400;
|
|
font-size: 14px;
|
|
line-height: 20px;
|
|
letter-spacing: 0%;
|
|
|
|
.card-footer-title {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
}
|
|
</style>
|