1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Split UserManagement into seperate Cluster and Namespace Roles and RoleBindings (#2755)

This commit is contained in:
Sebastian Malton 2021-06-09 13:40:55 -04:00 committed by GitHub
parent 1ed7892b26
commit a1a1c240e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 2896 additions and 901 deletions

View File

@ -0,0 +1,528 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { HashSet, ObservableHashSet } from "../hash-set";
describe("ObservableHashSet<T>", () => {
it("should not throw on creation", () => {
expect(() => new ObservableHashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError();
});
it("should be initializable", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
});
it("has should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
expect(res.has({ a: 5 })).toBe(false);
});
it("forEach should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
let a = 1;
res.forEach((item) => {
expect(item.a).toEqual(a++);
});
});
it("delete should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
expect(res.delete({ a: 1 })).toBe(true);
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 5 })).toBe(false);
expect(res.delete({ a: 5 })).toBe(false);
expect(res.has({ a: 5 })).toBe(false);
});
it("toggle should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
res.toggle({ a: 1 });
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 6 })).toBe(false);
res.toggle({ a: 6 });
expect(res.has({ a: 6 })).toBe(true);
});
it("add should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 6 })).toBe(false);
res.add({ a: 6 });
expect(res.has({ a: 6 })).toBe(true);
});
it("add should treat the hash to be the same as equality", () => {
const res = new ObservableHashSet([
{ a: 1, foobar: "hello" },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
res.add({ a: 1, foobar: "goodbye" });
expect(res.has({ a: 1 })).toBe(true);
});
it("clear should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
res.clear();
expect(res.size).toBe(0);
});
it("replace should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
res.replace([{ a: 13 }]);
expect(res.size).toBe(1);
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 13 })).toBe(true);
});
it("toJSON should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.toJSON()).toStrictEqual([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
]);
});
it("values should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.values();
expect(iter.next()).toStrictEqual({
value: { a: 1 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 2 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 3 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 4 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
it("keys should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.keys();
expect(iter.next()).toStrictEqual({
value: { a: 1 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 2 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 3 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 4 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
it("entries should work as expected", () => {
const res = new ObservableHashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.entries();
expect(iter.next()).toStrictEqual({
value: [{ a: 1 }, { a: 1 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 2 }, { a: 2 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 3 }, { a: 3 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 4 }, { a: 4 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
});
describe("HashSet<T>", () => {
it("should not throw on creation", () => {
expect(() => new HashSet<{ a: number }>([], item => item.a.toString())).not.toThrowError();
});
it("should be initializable", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
});
it("has should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
expect(res.has({ a: 5 })).toBe(false);
});
it("forEach should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
let a = 1;
res.forEach((item) => {
expect(item.a).toEqual(a++);
});
});
it("delete should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
expect(res.delete({ a: 1 })).toBe(true);
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 5 })).toBe(false);
expect(res.delete({ a: 5 })).toBe(false);
expect(res.has({ a: 5 })).toBe(false);
});
it("toggle should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
res.toggle({ a: 1 });
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 6 })).toBe(false);
res.toggle({ a: 6 });
expect(res.has({ a: 6 })).toBe(true);
});
it("add should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 6 })).toBe(false);
res.add({ a: 6 });
expect(res.has({ a: 6 })).toBe(true);
});
it("add should treat the hash to be the same as equality", () => {
const res = new HashSet([
{ a: 1, foobar: "hello" },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.has({ a: 1 })).toBe(true);
res.add({ a: 1, foobar: "goodbye" });
expect(res.has({ a: 1 })).toBe(true);
});
it("clear should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
res.clear();
expect(res.size).toBe(0);
});
it("replace should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.size).toBe(4);
res.replace([{ a: 13 }]);
expect(res.size).toBe(1);
expect(res.has({ a: 1 })).toBe(false);
expect(res.has({ a: 13 })).toBe(true);
});
it("toJSON should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
expect(res.toJSON()).toStrictEqual([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
]);
});
it("values should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.values();
expect(iter.next()).toStrictEqual({
value: { a: 1 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 2 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 3 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 4 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
it("keys should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.keys();
expect(iter.next()).toStrictEqual({
value: { a: 1 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 2 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 3 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: { a: 4 },
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
it("entries should work as expected", () => {
const res = new HashSet([
{ a: 1 },
{ a: 2 },
{ a: 3 },
{ a: 4 },
], item => item.a.toString());
const iter = res.entries();
expect(iter.next()).toStrictEqual({
value: [{ a: 1 }, { a: 1 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 2 }, { a: 2 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 3 }, { a: 3 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: [{ a: 4 }, { a: 4 }],
done: false,
});
expect(iter.next()).toStrictEqual({
value: undefined,
done: true,
});
});
});

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { nFircate } from "../n-fircate";
describe("nFircate", () => {
it("should produce an empty array if no parts are provided", () => {
expect(nFircate([{ a: 1 }, { a: 2 }], "a", []).length).toBe(0);
});
it("should ignore non-matching parts", () => {
const res = nFircate([{ a: 1 }, { a: 2 }], "a", [1]);
expect(res.length).toBe(1);
expect(res[0].length).toBe(1);
});
it("should include all matching parts in each type", () => {
const res = nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2]);
expect(res.length).toBe(2);
expect(res[0].length).toBe(2);
expect(res[0][0].b).toBe("a");
expect(res[0][1].b).toBe("c");
expect(res[1].length).toBe(1);
expect(res[1][0].b).toBe("b");
});
it("should throw a type error if the same part is provided more than once", () => {
try {
nFircate([{ a: 1, b: "a" }, { a: 2, b: "b" }, { a: 1, b: "c" }], "a", [1, 2, 1]);
fail("Expected error");
} catch (error) {
expect(error).toBeInstanceOf(TypeError);
}
});
});

View File

@ -0,0 +1,260 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { action, IInterceptable, IInterceptor, IListenable, ISetWillChange, observable, ObservableMap, ObservableSet } from "mobx";
export function makeIterableIterator<T>(iterator: Iterator<T>): IterableIterator<T> {
(iterator as IterableIterator<T>)[Symbol.iterator] = () => iterator as IterableIterator<T>;
return iterator as IterableIterator<T>;
}
export class HashSet<T> implements Set<T> {
#hashmap: Map<string, T>;
constructor(initialValues: Iterable<T>, protected hasher: (item: T) => string) {
this.#hashmap = new Map<string, T>(Array.from(initialValues, value => [this.hasher(value), value]));
}
replace(other: ObservableHashSet<T> | ObservableSet<T> | Set<T> | readonly T[]): this {
if (other === null || other === undefined) {
return this;
}
if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) {
throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`);
}
this.clear();
for (const value of other) {
this.add(value);
}
return this;
}
clear(): void {
this.#hashmap.clear();
}
add(value: T): this {
this.#hashmap.set(this.hasher(value), value);
return this;
}
toggle(value: T): void {
const hash = this.hasher(value);
if (this.#hashmap.has(hash)) {
this.#hashmap.delete(hash);
} else {
this.#hashmap.set(hash, value);
}
}
delete(value: T): boolean {
return this.#hashmap.delete(this.hasher(value));
}
forEach(callbackfn: (value: T, key: T, set: Set<T>) => void, thisArg?: any): void {
this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this));
}
has(value: T): boolean {
return this.#hashmap.has(this.hasher(value));
}
get size(): number {
return this.#hashmap.size;
}
entries(): IterableIterator<[T, T]> {
let nextIndex = 0;
const keys = Array.from(this.keys());
const values = Array.from(this.values());
return makeIterableIterator<[T, T]>({
next() {
const index = nextIndex++;
return index < values.length
? { value: [keys[index], values[index]], done: false }
: { done: true, value: undefined };
}
});
}
keys(): IterableIterator<T> {
return this.values();
}
values(): IterableIterator<T> {
let nextIndex = 0;
const observableValues = Array.from(this.#hashmap.values());
return makeIterableIterator<T>({
next: () => {
return nextIndex < observableValues.length
? { value: observableValues[nextIndex++], done: false }
: { done: true, value: undefined };
}
});
}
[Symbol.iterator](): IterableIterator<T> {
return this.#hashmap.values();
}
get [Symbol.toStringTag](): string {
return "Set";
}
toJSON(): T[] {
return Array.from(this);
}
toString(): string {
return "[object Set]";
}
}
export class ObservableHashSet<T> implements Set<T>, IInterceptable<ISetWillChange>, IListenable {
#hashmap: ObservableMap<string, T>;
get interceptors_(): IInterceptor<ISetWillChange<T>>[] {
return [];
}
get changeListeners_(): Function[] {
return [];
}
constructor(initialValues: Iterable<T>, protected hasher: (item: T) => string) {
this.#hashmap = observable.map<string, T>(Array.from(initialValues, value => [this.hasher(value), value]), undefined);
}
@action
replace(other: ObservableHashSet<T> | ObservableSet<T> | Set<T> | readonly T[]): this {
if (other === null || other === undefined) {
return this;
}
if (!(Array.isArray(other) || other instanceof Set || other instanceof ObservableHashSet || other instanceof ObservableSet)) {
throw new Error(`ObservableHashSet: Cannot initialize set from ${other}`);
}
this.clear();
for (const value of other) {
this.add(value);
}
return this;
}
clear(): void {
this.#hashmap.clear();
}
add(value: T): this {
this.#hashmap.set(this.hasher(value), value);
return this;
}
@action
toggle(value: T): void {
const hash = this.hasher(value);
if (this.#hashmap.has(hash)) {
this.#hashmap.delete(hash);
} else {
this.#hashmap.set(hash, value);
}
}
delete(value: T): boolean {
return this.#hashmap.delete(this.hasher(value));
}
forEach(callbackfn: (value: T, key: T, set: Set<T>) => void, thisArg?: any): void {
this.#hashmap.forEach(value => callbackfn(value, value, thisArg ?? this));
}
has(value: T): boolean {
return this.#hashmap.has(this.hasher(value));
}
get size(): number {
return this.#hashmap.size;
}
entries(): IterableIterator<[T, T]> {
let nextIndex = 0;
const keys = Array.from(this.keys());
const values = Array.from(this.values());
return makeIterableIterator<[T, T]>({
next() {
const index = nextIndex++;
return index < values.length
? { value: [keys[index], values[index]], done: false }
: { done: true, value: undefined };
}
});
}
keys(): IterableIterator<T> {
return this.values();
}
values(): IterableIterator<T> {
let nextIndex = 0;
const observableValues = Array.from(this.#hashmap.values());
return makeIterableIterator<T>({
next: () => {
return nextIndex < observableValues.length
? { value: observableValues[nextIndex++], done: false }
: { done: true, value: undefined };
}
});
}
[Symbol.iterator](): IterableIterator<T> {
return this.#hashmap.values();
}
get [Symbol.toStringTag](): string {
return "Set";
}
toJSON(): T[] {
return Array.from(this);
}
toString(): string {
return "[object ObservableSet]";
}
}

View File

@ -29,17 +29,17 @@ export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./base64"; export * from "./base64";
export * from "./camelCase"; export * from "./camelCase";
export * from "./toJS";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./debouncePromise"; export * from "./debouncePromise";
export * from "./defineGlobal"; export * from "./defineGlobal";
export * from "./delay"; export * from "./delay";
export * from "./disposer"; export * from "./disposer";
export * from "./disposer";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./extended-map"; export * from "./extended-map";
export * from "./getRandId"; export * from "./getRandId";
export * from "./hash-set";
export * from "./n-fircate";
export * from "./openExternal"; export * from "./openExternal";
export * from "./paths"; export * from "./paths";
export * from "./reject-promise"; export * from "./reject-promise";
@ -47,6 +47,7 @@ export * from "./singleton";
export * from "./splitArray"; export * from "./splitArray";
export * from "./tar"; export * from "./tar";
export * from "./toggle-set"; export * from "./toggle-set";
export * from "./toJS";
export * from "./type-narrowing"; export * from "./type-narrowing";
import * as iter from "./iter"; import * as iter from "./iter";

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* Split an iterable into several arrays with matching fields
* @param from The iterable of items to split up
* @param field The field of each item to split over
* @param parts What each array will be filtered to
* @returns A `parts.length` tuple of `T[]` where each array has matching `field` values
*/
export function nFircate<T>(from: Iterable<T>, field: keyof T, parts: []): [];
export function nFircate<T>(from: Iterable<T>, field: keyof T, parts: [T[typeof field]]): [T[]];
export function nFircate<T>(from: Iterable<T>, field: keyof T, parts: [T[typeof field], T[typeof field]]): [T[], T[]];
export function nFircate<T>(from: Iterable<T>, field: keyof T, parts: [T[typeof field], T[typeof field], T[typeof field]]): [T[], T[], T[]];
export function nFircate<T>(from: Iterable<T>, field: keyof T, parts: T[typeof field][]): T[][] {
if (new Set(parts).size !== parts.length) {
throw new TypeError("Duplicate parts entries");
}
const res = Array.from(parts, () => [] as T[]);
for (const item of from) {
const index = parts.indexOf(item[field]);
if (index < 0) {
continue;
}
res[index].push(item);
}
return res;
}

View File

@ -86,8 +86,8 @@ export type { PersistentVolumesStore } from "../../renderer/components/+storage-
export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store"; export type { VolumeClaimStore } from "../../renderer/components/+storage-volume-claims/volume-claim.store";
export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store"; export type { StorageClassStore } from "../../renderer/components/+storage-classes/storage-class.store";
export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace.store"; export type { NamespaceStore } from "../../renderer/components/+namespaces/namespace.store";
export type { ServiceAccountsStore } from "../../renderer/components/+user-management-service-accounts/service-accounts.store"; export type { ServiceAccountsStore } from "../../renderer/components/+user-management/+service-accounts/store";
export type { RolesStore } from "../../renderer/components/+user-management-roles/roles.store"; export type { RolesStore } from "../../renderer/components/+user-management/+roles/store";
export type { RoleBindingsStore } from "../../renderer/components/+user-management-roles-bindings/role-bindings.store"; export type { RoleBindingsStore } from "../../renderer/components/+user-management/+role-bindings/store";
export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store"; export type { CRDStore } from "../../renderer/components/+custom-resources/crd.store";
export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store"; export type { CRDResourceStore } from "../../renderer/components/+custom-resources/crd-resource.store";

View File

@ -18,14 +18,39 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { RoleBinding } from "./role-binding.api";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import { KubeObject } from "../kube-object";
export class ClusterRoleBinding extends RoleBinding { export type ClusterRoleBindingSubjectKind = "Group" | "ServiceAccount" | "User";
export interface ClusterRoleBindingSubject {
kind: ClusterRoleBindingSubjectKind;
name: string;
apiGroup?: string;
namespace?: string;
}
export interface ClusterRoleBinding {
subjects?: ClusterRoleBindingSubject[];
roleRef: {
kind: string;
name: string;
apiGroup?: string;
};
}
export class ClusterRoleBinding extends KubeObject {
static kind = "ClusterRoleBinding"; static kind = "ClusterRoleBinding";
static namespaced = false; static namespaced = false;
static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings";
getSubjects() {
return this.subjects || [];
}
getSubjectNames(): string {
return this.getSubjects().map(subject => subject.name).join(", ");
}
} }
export const clusterRoleBindingApi = new KubeApi({ export const clusterRoleBindingApi = new KubeApi({

View File

@ -19,13 +19,26 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { Role } from "./role.api";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import { KubeObject } from "../kube-object";
export class ClusterRole extends Role { export interface ClusterRole {
rules: {
verbs: string[];
apiGroups: string[];
resources: string[];
resourceNames?: string[];
}[];
}
export class ClusterRole extends KubeObject {
static kind = "ClusterRole"; static kind = "ClusterRole";
static namespaced = false; static namespaced = false;
static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"; static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles";
getRules() {
return this.rules || [];
}
} }
export const clusterRoleApi = new KubeApi({ export const clusterRoleApi = new KubeApi({

View File

@ -24,15 +24,17 @@ import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
export interface IRoleBindingSubject { export type RoleBindingSubjectKind = "Group" | "ServiceAccount" | "User";
kind: string;
export interface RoleBindingSubject {
kind: RoleBindingSubjectKind;
name: string; name: string;
namespace?: string; namespace?: string;
apiGroup?: string; apiGroup?: string;
} }
export interface RoleBinding { export interface RoleBinding {
subjects?: IRoleBindingSubject[]; subjects?: RoleBindingSubject[];
roleRef: { roleRef: {
kind: string; kind: string;
name: string; name: string;

View File

@ -32,14 +32,12 @@ import { kubeWatchApi } from "../../api/kube-watch-api";
interface Props extends SelectProps { interface Props extends SelectProps {
showIcons?: boolean; showIcons?: boolean;
showClusterOption?: boolean; // show "Cluster" option on the top (default: false)
showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false)
customizeOptions?(options: SelectOption[]): SelectOption[]; customizeOptions?(options: SelectOption[]): SelectOption[];
} }
const defaultProps: Partial<Props> = { const defaultProps: Partial<Props> = {
showIcons: true, showIcons: true,
showClusterOption: false,
}; };
@observer @observer
@ -61,13 +59,11 @@ export class NamespaceSelect extends React.Component<Props> {
} }
@computed.struct get options(): SelectOption[] { @computed.struct get options(): SelectOption[] {
const { customizeOptions, showClusterOption, showAllNamespacesOption } = this.props; const { customizeOptions, showAllNamespacesOption } = this.props;
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() }));
if (showAllNamespacesOption) { if (showAllNamespacesOption) {
options.unshift({ label: "All Namespaces", value: "" }); options.unshift({ label: "All Namespaces", value: "" });
} else if (showClusterOption) {
options.unshift({ label: "Cluster", value: "" });
} }
if (customizeOptions) { if (customizeOptions) {

View File

@ -20,7 +20,7 @@
*/ */
import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction, } from "mobx"; import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction, } from "mobx";
import { autoBind, createStorage } from "../../utils"; import { autoBind, createStorage, noop } from "../../utils";
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
@ -97,13 +97,16 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
return this.selectedNamespaces; return this.selectedNamespaces;
} }
getSubscribeApis() { subscribe() {
// if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted /**
* if user has given static list of namespaces let's not start watches
* because watch adds stuff that's not wanted or will just fail
*/
if (this.context?.cluster.accessibleNamespaces.length > 0) { if (this.context?.cluster.accessibleNamespaces.length > 0) {
return []; return noop;
} }
return super.getSubscribeApis(); return super.subscribe();
} }
protected async loadItems(params: KubeObjectStoreLoadingParams) { protected async loadItems(params: KubeObjectStoreLoadingParams) {

View File

@ -1,318 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./add-role-binding-dialog.scss";
import React from "react";
import { computed, observable, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Select, SelectOption } from "../select";
import { SubTitle } from "../layout/sub-title";
import type { IRoleBindingSubject, Role, RoleBinding, ServiceAccount } from "../../api/endpoints";
import { Icon } from "../icon";
import { Input } from "../input";
import { NamespaceSelect } from "../+namespaces/namespace-select";
import { Checkbox } from "../checkbox";
import { KubeObject } from "../../api/kube-object";
import { Notifications } from "../notifications";
import { rolesStore } from "../+user-management-roles/roles.store";
import { namespaceStore } from "../+namespaces/namespace.store";
import { serviceAccountsStore } from "../+user-management-service-accounts/service-accounts.store";
import { roleBindingsStore } from "./role-bindings.store";
import { showDetails } from "../kube-object";
import type { KubeObjectStore } from "../../kube-object.store";
interface BindingSelectOption extends SelectOption {
value: string; // binding name
item?: ServiceAccount | any;
subject?: IRoleBindingSubject; // used for new user/group when users-management-api not available
}
interface Props extends Partial<DialogProps> {
}
const dialogState = observable.object({
isOpen: false,
data: null as RoleBinding,
});
@observer
export class AddRoleBindingDialog extends React.Component<Props> {
constructor(props: Props) {
super(props);
makeObservable(this);
}
static open(roleBinding?: RoleBinding) {
dialogState.isOpen = true;
dialogState.data = roleBinding;
}
static close() {
dialogState.isOpen = false;
}
get roleBinding(): RoleBinding {
return dialogState.data;
}
@observable isLoading = false;
@observable selectedRoleId = "";
@observable useRoleForBindingName = true;
@observable bindingName = ""; // new role-binding name
@observable bindContext = ""; // empty value means "cluster-wide", otherwise bind to namespace
@observable selectedAccounts = observable.array<ServiceAccount>([], { deep: false });
@computed get isEditing() {
return !!this.roleBinding;
}
@computed get selectedRole() {
return rolesStore.items.find(role => role.getId() === this.selectedRoleId);
}
@computed get selectedBindings() {
return [
...this.selectedAccounts,
];
}
close = () => {
AddRoleBindingDialog.close();
};
async loadData() {
const stores: KubeObjectStore[] = [
namespaceStore,
rolesStore,
serviceAccountsStore,
];
this.isLoading = true;
await Promise.all(stores.map(store => store.reloadAll()));
this.isLoading = false;
}
onOpen = async () => {
await this.loadData();
if (this.roleBinding) {
const { name, kind } = this.roleBinding.roleRef;
const role = rolesStore.items.find(role => role.kind === kind && role.getName() === name);
if (role) {
this.selectedRoleId = role.getId();
this.bindContext = role.getNs() || "";
}
}
};
reset = () => {
this.selectedRoleId = "";
this.bindContext = "";
this.selectedAccounts.clear();
};
onBindContextChange = (namespace: string) => {
this.bindContext = namespace;
const roleContext = this.selectedRole && this.selectedRole.getNs() || "";
if (this.bindContext && this.bindContext !== roleContext) {
this.selectedRoleId = ""; // reset previously selected role for specific context
}
};
createBindings = async () => {
const { selectedRole, bindContext: namespace, selectedBindings, bindingName, useRoleForBindingName } = this;
const subjects = selectedBindings.map((item: KubeObject | IRoleBindingSubject) => {
if (item instanceof KubeObject) {
return {
name: item.getName(),
kind: item.kind,
namespace: item.getNs(),
};
}
return item;
});
try {
let roleBinding: RoleBinding;
if (this.isEditing) {
roleBinding = await roleBindingsStore.updateSubjects({
roleBinding: this.roleBinding,
addSubjects: subjects,
});
} else {
const name = useRoleForBindingName ? selectedRole.getName() : bindingName;
roleBinding = await roleBindingsStore.create({ name, namespace }, {
subjects,
roleRef: {
name: selectedRole.getName(),
kind: selectedRole.kind,
}
});
}
showDetails(roleBinding.selfLink);
this.close();
} catch (err) {
Notifications.error(err);
}
};
@computed get roleOptions(): BindingSelectOption[] {
let roles = rolesStore.items as Role[];
if (this.bindContext) {
// show only cluster-roles or roles for selected context namespace
roles = roles.filter(role => !role.getNs() || role.getNs() === this.bindContext);
}
return roles.map(role => {
const name = role.getName();
const namespace = role.getNs();
return {
value: role.getId(),
label: name + (namespace ? ` (${namespace})` : "")
};
});
}
@computed get serviceAccountOptions(): BindingSelectOption[] {
return serviceAccountsStore.items.map(account => {
const name = account.getName();
const namespace = account.getNs();
return {
item: account,
value: name,
label: <><Icon small material="account_box"/> {name} ({namespace})</>
};
});
}
renderContents() {
const unwrapBindings = (options: BindingSelectOption[]) => options.map(option => option.item || option.subject);
return (
<>
<SubTitle title="Context"/>
<NamespaceSelect
showClusterOption
themeName="light"
isDisabled={this.isEditing}
value={this.bindContext}
onChange={({ value }) => this.onBindContextChange(value)}
/>
<SubTitle title="Role"/>
<Select
key={this.selectedRoleId}
themeName="light"
placeholder="Select role.."
isDisabled={this.isEditing}
options={this.roleOptions}
value={this.selectedRoleId}
onChange={({ value }) => this.selectedRoleId = value}
/>
{
!this.isEditing && (
<>
<Checkbox
theme="light"
label="Use same name for RoleBinding"
value={this.useRoleForBindingName}
onChange={v => this.useRoleForBindingName = v}
/>
{
!this.useRoleForBindingName && (
<Input
autoFocus
placeholder="Name"
disabled={this.isEditing}
value={this.bindingName}
onChange={v => this.bindingName = v}
/>
)
}
</>
)
}
<SubTitle title="Binding targets"/>
<Select
isMulti
themeName="light"
placeholder="Select service accounts"
autoConvertOptions={false}
options={this.serviceAccountOptions}
onChange={(opts: BindingSelectOption[]) => {
if (!opts) opts = [];
this.selectedAccounts.replace(unwrapBindings(opts));
}}
maxMenuHeight={200}
/>
</>
);
}
render() {
const { ...dialogProps } = this.props;
const { isEditing, roleBinding, selectedRole, selectedBindings } = this;
const roleBindingName = roleBinding ? roleBinding.getName() : "";
const header = (
<h5>
{roleBindingName
? <>Edit RoleBinding <span className="name">{roleBindingName}</span></>
: "Add RoleBinding"
}
</h5>
);
const disableNext = this.isLoading || !selectedRole || !selectedBindings.length;
const nextLabel = isEditing ? "Update" : "Create";
return (
<Dialog
{...dialogProps}
className="AddRoleBindingDialog"
isOpen={dialogState.isOpen}
onOpen={this.onOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
nextLabel={nextLabel} next={this.createBindings}
disabledNext={disableNext}
loading={this.isLoading}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -1,100 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import difference from "lodash/difference";
import uniqBy from "lodash/uniqBy";
import { clusterRoleBindingApi, IRoleBindingSubject, RoleBinding, roleBindingApi } from "../../api/endpoints";
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { autoBind } from "../../utils";
import { apiManager } from "../../api/api-manager";
export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
api = clusterRoleBindingApi;
constructor() {
super();
autoBind(this);
}
getSubscribeApis() {
return [clusterRoleBindingApi, roleBindingApi];
}
protected sortItems(items: RoleBinding[]) {
return super.sortItems(items, [
roleBinding => roleBinding.kind,
roleBinding => roleBinding.getName()
]);
}
protected loadItem(params: { name: string; namespace?: string }) {
if (params.namespace) return roleBindingApi.get(params);
return clusterRoleBindingApi.get(params);
}
protected async loadItems(params: KubeObjectStoreLoadingParams): Promise<RoleBinding[]> {
const items = await Promise.all([
super.loadItems({ ...params, api: clusterRoleBindingApi }),
super.loadItems({ ...params, api: roleBindingApi }),
]);
return items.flat();
}
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<RoleBinding>) {
if (params.namespace) {
return roleBindingApi.create(params, data);
} else {
return clusterRoleBindingApi.create(params, data);
}
}
async updateSubjects(params: {
roleBinding: RoleBinding;
addSubjects?: IRoleBindingSubject[];
removeSubjects?: IRoleBindingSubject[];
}) {
const { roleBinding, addSubjects, removeSubjects } = params;
const currentSubjects = roleBinding.getSubjects();
let newSubjects = currentSubjects;
if (addSubjects) {
newSubjects = uniqBy(currentSubjects.concat(addSubjects), ({ kind, name, namespace }) => {
return [kind, name, namespace].join("-");
});
} else if (removeSubjects) {
newSubjects = difference(currentSubjects, removeSubjects);
}
return this.update(roleBinding, {
roleRef: roleBinding.roleRef,
subjects: newSubjects
});
}
}
export const roleBindingsStore = new RoleBindingsStore();
apiManager.registerStore(roleBindingsStore, [
roleBindingApi,
clusterRoleBindingApi,
]);

View File

@ -1,85 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./role-bindings.scss";
import React from "react";
import { observer } from "mobx-react";
import type { RouteComponentProps } from "react-router";
import type { IRoleBindingsRouteParams } from "../+user-management/user-management.route";
import type { RoleBinding } from "../../api/endpoints";
import { roleBindingsStore } from "./role-bindings.store";
import { KubeObjectListLayout } from "../kube-object";
import { AddRoleBindingDialog } from "./add-role-binding-dialog";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum columnId {
name = "name",
namespace = "namespace",
bindings = "bindings",
age = "age",
}
interface Props extends RouteComponentProps<IRoleBindingsRouteParams> {
}
@observer
export class RoleBindings extends React.Component<Props> {
render() {
return (
<KubeObjectListLayout
isConfigurable
tableId="access_role_bindings"
className="RoleBindings"
store={roleBindingsStore}
sortingCallbacks={{
[columnId.name]: (binding: RoleBinding) => binding.getName(),
[columnId.namespace]: (binding: RoleBinding) => binding.getNs(),
[columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(),
[columnId.age]: (binding: RoleBinding) => binding.getTimeDiffFromNow(),
}}
searchFilters={[
(binding: RoleBinding) => binding.getSearchFields(),
(binding: RoleBinding) => binding.getSubjectNames(),
]}
renderHeaderTitle="Role Bindings"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={(binding: RoleBinding) => [
binding.getName(),
<KubeObjectStatusIcon key="icon" object={binding} />,
binding.getNs() || "-",
binding.getSubjectNames(),
binding.getAge(),
]}
addRemoveButtons={{
onAdd: () => AddRoleBindingDialog.open(),
addTooltip: "Create new RoleBinding",
}}
/>
);
}
}

View File

@ -1,75 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { clusterRoleApi, Role, roleApi } from "../../api/endpoints";
import { autoBind } from "../../utils";
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
import { apiManager } from "../../api/api-manager";
export class RolesStore extends KubeObjectStore<Role> {
api = clusterRoleApi;
constructor() {
super();
autoBind(this);
}
getSubscribeApis() {
return [roleApi, clusterRoleApi];
}
protected sortItems(items: Role[]) {
return super.sortItems(items, [
role => role.kind,
role => role.getName(),
]);
}
protected loadItem(params: { name: string; namespace?: string }) {
if (params.namespace) return roleApi.get(params);
return clusterRoleApi.get(params);
}
protected async loadItems(params: KubeObjectStoreLoadingParams): Promise<Role[]> {
const items = await Promise.all([
super.loadItems({ ...params, api: clusterRoleApi }),
super.loadItems({ ...params, api: roleApi }),
]);
return items.flat();
}
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<Role>) {
if (params.namespace) {
return roleApi.create(params, data);
} else {
return clusterRoleApi.create(params, data);
}
}
}
export const rolesStore = new RolesStore();
apiManager.registerStore(rolesStore, [
roleApi,
clusterRoleApi,
]);

View File

@ -0,0 +1,157 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./details.scss";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import React from "react";
import { KubeEventDetails } from "../../+events/kube-event-details";
import type { ClusterRoleBinding, ClusterRoleBindingSubject } from "../../../api/endpoints";
import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry";
import { autoBind, ObservableHashSet, prevDefault } from "../../../utils";
import { AddRemoveButtons } from "../../add-remove-buttons";
import { ConfirmDialog } from "../../confirm-dialog";
import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object";
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
import { Table, TableCell, TableHead, TableRow } from "../../table";
import { ClusterRoleBindingDialog } from "./dialog";
import { clusterRoleBindingsStore } from "./store";
import { hashClusterRoleBindingSubject } from "./hashers";
interface Props extends KubeObjectDetailsProps<ClusterRoleBinding> {
}
@observer
export class ClusterRoleBindingDetails extends React.Component<Props> {
selectedSubjects = new ObservableHashSet<ClusterRoleBindingSubject>([], hashClusterRoleBindingSubject);
constructor(props: Props) {
super(props);
autoBind(this);
}
async componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.object, () => {
this.selectedSubjects.clear();
})
]);
}
removeSelectedSubjects() {
const { object: clusterRoleBinding } = this.props;
const { selectedSubjects } = this;
ConfirmDialog.open({
ok: () => clusterRoleBindingsStore.removeSubjects(clusterRoleBinding, selectedSubjects),
labelOk: `Remove`,
message: (
<p>Remove selected bindings for <b>{clusterRoleBinding.getName()}</b>?</p>
)
});
}
render() {
const { selectedSubjects } = this;
const { object: clusterRoleBinding } = this.props;
if (!clusterRoleBinding) {
return null;
}
const { roleRef } = clusterRoleBinding;
const subjects = clusterRoleBinding.getSubjects();
return (
<div className="RoleBindingDetails">
<KubeObjectMeta object={clusterRoleBinding} />
<DrawerTitle title="Reference" />
<Table>
<TableHead>
<TableCell>Kind</TableCell>
<TableCell>Name</TableCell>
<TableCell>API Group</TableCell>
</TableHead>
<TableRow>
<TableCell>{roleRef.kind}</TableCell>
<TableCell>{roleRef.name}</TableCell>
<TableCell>{roleRef.apiGroup}</TableCell>
</TableRow>
</Table>
<DrawerTitle title="Bindings" />
{subjects.length > 0 && (
<Table selectable className="bindings box grow">
<TableHead>
<TableCell checkbox />
<TableCell className="binding">Name</TableCell>
<TableCell className="type">Type</TableCell>
</TableHead>
{
subjects.map((subject, i) => {
const { kind, name } = subject;
const isSelected = selectedSubjects.has(subject);
return (
<TableRow
key={i}
selected={isSelected}
onClick={prevDefault(() => this.selectedSubjects.toggle(subject))}
>
<TableCell checkbox isChecked={isSelected} />
<TableCell className="binding">{name}</TableCell>
<TableCell className="type">{kind}</TableCell>
</TableRow>
);
})
}
</Table>
)}
<AddRemoveButtons
onAdd={() => ClusterRoleBindingDialog.open(clusterRoleBinding)}
onRemove={selectedSubjects.size ? this.removeSelectedSubjects : null}
addTooltip={`Add bindings to ${roleRef.name}`}
removeTooltip={`Remove selected bindings from ${roleRef.name}`}
/>
</div>
);
}
}
kubeObjectDetailRegistry.add({
kind: "ClusterRoleBinding",
apiVersions: ["rbac.authorization.k8s.io/v1"],
components: {
Details: (props) => <ClusterRoleBindingDetails {...props} />
}
});
kubeObjectDetailRegistry.add({
kind: "ClusterRoleBinding",
apiVersions: ["rbac.authorization.k8s.io/v1"],
priority: 5,
components: {
Details: (props) => <KubeEventDetails {...props} />
}
});

View File

@ -0,0 +1,11 @@
.AddClusterRoleBindingDialog {
.Select + .Select {
margin-top: $margin /2;
}
.Checkbox {
margin-top: $margin;
}
.name {
color: #a0a0a0;
}
}

View File

@ -0,0 +1,286 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./dialog.scss";
import { action, computed, makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import React from "react";
import { serviceAccountsStore } from "../+service-accounts/store";
import { ClusterRole, ClusterRoleBinding, ClusterRoleBindingSubject, ServiceAccount } from "../../../api/endpoints";
import { Dialog, DialogProps } from "../../dialog";
import { EditableList } from "../../editable-list";
import { Icon } from "../../icon";
import { showDetails } from "../../kube-object";
import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../../notifications";
import { Select, SelectOption } from "../../select";
import { Wizard, WizardStep } from "../../wizard";
import { clusterRoleBindingsStore } from "./store";
import { clusterRolesStore } from "../+cluster-roles/store";
import { getRoleRefSelectOption, ServiceAccountOption } from "../select-options";
import { ObservableHashSet, nFircate } from "../../../utils";
import { Input } from "../../input";
interface Props extends Partial<DialogProps> {
}
interface DialogState {
isOpen: boolean;
data?: ClusterRoleBinding;
}
@observer
export class ClusterRoleBindingDialog extends React.Component<Props> {
static state = observable.object<DialogState>({
isOpen: false,
});
constructor(props: Props) {
super(props);
makeObservable(this);
}
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.isEditing, () => {
this.bindingName = ClusterRoleBindingDialog.state.data?.getName();
}),
]);
}
static open(roleBinding?: ClusterRoleBinding) {
ClusterRoleBindingDialog.state.isOpen = true;
ClusterRoleBindingDialog.state.data = roleBinding;
}
static close() {
ClusterRoleBindingDialog.state.isOpen = false;
}
get clusterRoleBinding(): ClusterRoleBinding {
return ClusterRoleBindingDialog.state.data;
}
@computed get isEditing() {
return !!this.clusterRoleBinding;
}
@observable selectedRoleRef: ClusterRole | undefined = undefined;
@observable bindingName = "";
selectedAccounts = new ObservableHashSet<ServiceAccount>([], sa => sa.metadata.uid);
selectedUsers = observable.set<string>([]);
selectedGroups = observable.set<string>([]);
@computed get selectedBindings(): ClusterRoleBindingSubject[] {
const serviceAccounts = Array.from(this.selectedAccounts, sa => ({
name: sa.getName(),
kind: "ServiceAccount" as const,
namespace: sa.getNs(),
}));
const users = Array.from(this.selectedUsers, user => ({
name: user,
kind: "User" as const,
}));
const groups = Array.from(this.selectedGroups, group => ({
name: group,
kind: "Group" as const,
}));
return [
...serviceAccounts,
...users,
...groups,
];
}
@computed get clusterRoleRefoptions(): SelectOption<ClusterRole>[] {
return clusterRolesStore.items.map(getRoleRefSelectOption);
}
@computed get serviceAccountOptions(): ServiceAccountOption[] {
return serviceAccountsStore.items.map(account => {
const name = account.getName();
const namespace = account.getNs();
return {
value: `${account.getName()}%${account.getNs()}`,
account,
label: <><Icon small material="account_box" /> {name} ({namespace})</>
};
});
}
@computed get selectedServiceAccountOptions(): ServiceAccountOption[] {
return this.serviceAccountOptions.filter(({ account }) => this.selectedAccounts.has(account));
}
@action
onOpen = () => {
const binding = this.clusterRoleBinding;
if (!binding) {
return this.reset();
}
this.selectedRoleRef = clusterRolesStore
.items
.find(item => item.getName() === binding.roleRef.name);
this.bindingName = this.clusterRoleBinding.getName();
const [saSubjects, uSubjects, gSubjects] = nFircate(binding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]);
const accountNames = new Set(saSubjects.map(acc => acc.name));
this.selectedAccounts.replace(
serviceAccountsStore.items
.filter(sa => accountNames.has(sa.getName()))
);
this.selectedUsers.replace(uSubjects.map(user => user.name));
this.selectedGroups.replace(gSubjects.map(group => group.name));
};
@action
reset = () => {
this.selectedRoleRef = undefined;
this.bindingName = "";
this.selectedAccounts.clear();
this.selectedUsers.clear();
this.selectedGroups.clear();
};
createBindings = async () => {
const { selectedRoleRef, selectedBindings, bindingName } = this;
try {
const { selfLink } = this.isEditing
? await clusterRoleBindingsStore.updateSubjects(this.clusterRoleBinding, selectedBindings)
: await clusterRoleBindingsStore.create({ name: bindingName }, {
subjects: selectedBindings,
roleRef: {
name: selectedRoleRef.getName(),
kind: selectedRoleRef.kind,
}
});
showDetails(selfLink);
ClusterRoleBindingDialog.close();
} catch (err) {
Notifications.error(err);
}
};
renderContents() {
return (
<>
<SubTitle title="Cluster Role Reference" />
<Select
themeName="light"
placeholder="Select cluster role ..."
isDisabled={this.isEditing}
options={this.clusterRoleRefoptions}
value={this.selectedRoleRef}
onChange={({ value }: SelectOption<ClusterRole> ) => {
if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) {
this.bindingName = value.getName();
}
this.selectedRoleRef = value;
}}
/>
<SubTitle title="Binding Name" />
<Input
placeholder="Name of ClusterRoleBinding ..."
disabled={this.isEditing}
value={this.bindingName}
onChange={val => this.bindingName = val}
/>
<SubTitle title="Binding targets" />
<b>Users</b>
<EditableList
placeholder="Bind to User Account ..."
add={(newUser) => this.selectedUsers.add(newUser)}
items={Array.from(this.selectedUsers)}
remove={({ oldItem }) => this.selectedUsers.delete(oldItem)}
/>
<b>Groups</b>
<EditableList
placeholder="Bind to User Group ..."
add={(newGroup) => this.selectedGroups.add(newGroup)}
items={Array.from(this.selectedGroups)}
remove={({ oldItem }) => this.selectedGroups.delete(oldItem)}
/>
<b>Service Accounts</b>
<Select
isMulti
themeName="light"
placeholder="Select service accounts ..."
autoConvertOptions={false}
options={this.serviceAccountOptions}
value={this.selectedServiceAccountOptions}
onChange={(selected: ServiceAccountOption[] | null) => {
if (selected) {
this.selectedAccounts.replace(selected.map(opt => opt.account));
} else {
this.selectedAccounts.clear();
}
}}
maxMenuHeight={200}
/>
</>
);
}
render() {
const { ...dialogProps } = this.props;
const [action, nextLabel] = this.isEditing ? ["Edit", "Update"] : ["Add", "Create"];
const disableNext = !this.selectedRoleRef || !this.selectedBindings.length || !this.bindingName;
return (
<Dialog
{...dialogProps}
className="AddClusterRoleBindingDialog"
isOpen={ClusterRoleBindingDialog.state.isOpen}
close={ClusterRoleBindingDialog.close}
onClose={this.reset}
onOpen={this.onOpen}
>
<Wizard
header={<h5>{action} ClusterRoleBinding</h5>}
done={ClusterRoleBindingDialog.close}
>
<WizardStep
nextLabel={nextLabel}
next={this.createBindings}
disabledNext={disableNext}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -19,6 +19,13 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export * from "./service-accounts"; import { MD5 } from "crypto-js";
export * from "./service-accounts-details"; import type { ClusterRoleBindingSubject } from "../../../api/endpoints";
export * from "./create-service-account-dialog";
export function hashClusterRoleBindingSubject(subject: ClusterRoleBindingSubject): string {
return MD5(JSON.stringify([
["kind", subject.kind],
["name", subject.name],
["apiGroup", subject.apiGroup],
])).toString();
}

View File

@ -18,7 +18,6 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export * from "./view";
export * from "./roles"; export * from "./details";
export * from "./role-details"; export * from "./dialog";
export * from "./add-role-dialog";

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { apiManager } from "../../../api/api-manager";
import { ClusterRoleBinding, clusterRoleBindingApi, ClusterRoleBindingSubject } from "../../../api/endpoints";
import { KubeObjectStore } from "../../../kube-object.store";
import { autoBind, HashSet } from "../../../utils";
import { hashClusterRoleBindingSubject } from "./hashers";
export class ClusterRoleBindingsStore extends KubeObjectStore<ClusterRoleBinding> {
api = clusterRoleBindingApi;
constructor() {
super();
autoBind(this);
}
protected sortItems(items: ClusterRoleBinding[]) {
return super.sortItems(items, [
clusterRoleBinding => clusterRoleBinding.kind,
clusterRoleBinding => clusterRoleBinding.getName()
]);
}
async updateSubjects(clusterRoleBinding: ClusterRoleBinding, subjects: ClusterRoleBindingSubject[]) {
return this.update(clusterRoleBinding, {
roleRef: clusterRoleBinding.roleRef,
subjects,
});
}
async removeSubjects(clusterRoleBinding: ClusterRoleBinding, subjectsToRemove: Iterable<ClusterRoleBindingSubject>) {
const currentSubjects = new HashSet(clusterRoleBinding.getSubjects(), hashClusterRoleBindingSubject);
for (const subject of subjectsToRemove) {
currentSubjects.delete(subject);
}
return this.updateSubjects(clusterRoleBinding, currentSubjects.toJSON());
}
}
export const clusterRoleBindingsStore = new ClusterRoleBindingsStore();
apiManager.registerStore(clusterRoleBindingsStore);

View File

@ -0,0 +1,11 @@
.ClusterRoleBindings {
.help-icon {
margin-left: $margin / 2;
}
.TableCell {
&.warning {
@include table-cell-warning;
}
}
}

View File

@ -0,0 +1,88 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./view.scss";
import { observer } from "mobx-react";
import React from "react";
import type { RouteComponentProps } from "react-router";
import type { ClusterRoleBinding } from "../../../api/endpoints";
import { KubeObjectListLayout } from "../../kube-object";
import { KubeObjectStatusIcon } from "../../kube-object-status-icon";
import type { ClusterRoleBindingsRouteParams } from "../user-management.route";
import { ClusterRoleBindingDialog } from "./dialog";
import { clusterRoleBindingsStore } from "./store";
import { clusterRolesStore } from "../+cluster-roles/store";
import { serviceAccountsStore } from "../+service-accounts/store";
enum columnId {
name = "name",
namespace = "namespace",
bindings = "bindings",
age = "age",
}
interface Props extends RouteComponentProps<ClusterRoleBindingsRouteParams> {
}
@observer
export class ClusterRoleBindings extends React.Component<Props> {
render() {
return (
<>
<KubeObjectListLayout
isConfigurable
tableId="access_cluster_role_bindings"
className="ClusterRoleBindings"
store={clusterRoleBindingsStore}
dependentStores={[clusterRolesStore, serviceAccountsStore]}
sortingCallbacks={{
[columnId.name]: (binding: ClusterRoleBinding) => binding.getName(),
[columnId.bindings]: (binding: ClusterRoleBinding) => binding.getSubjectNames(),
[columnId.age]: (binding: ClusterRoleBinding) => binding.getTimeDiffFromNow(),
}}
searchFilters={[
(binding: ClusterRoleBinding) => binding.getSearchFields(),
(binding: ClusterRoleBinding) => binding.getSubjectNames(),
]}
renderHeaderTitle="Cluster Role Bindings"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={(binding: ClusterRoleBinding) => [
binding.getName(),
<KubeObjectStatusIcon key="icon" object={binding} />,
binding.getSubjectNames(),
binding.getAge(),
]}
addRemoveButtons={{
onAdd: () => ClusterRoleBindingDialog.open(),
addTooltip: "Create new ClusterRoleBinding",
}}
/>
<ClusterRoleBindingDialog />
</>
);
}
}

View File

@ -0,0 +1,105 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./add-dialog.scss";
import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { Dialog, DialogProps } from "../../dialog";
import { Input } from "../../input";
import { showDetails } from "../../kube-object";
import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../../notifications";
import { Wizard, WizardStep } from "../../wizard";
import { clusterRolesStore } from "./store";
interface Props extends Partial<DialogProps> {
}
@observer
export class AddClusterRoleDialog extends React.Component<Props> {
static isOpen = observable.box(false);
@observable clusterRoleName = "";
constructor(props: Props) {
super(props);
makeObservable(this);
}
static open() {
AddClusterRoleDialog.isOpen.set(true);
}
static close() {
AddClusterRoleDialog.isOpen.set(false);
}
reset = () => {
this.clusterRoleName = "";
};
createRole = async () => {
try {
const role = await clusterRolesStore.create({ name: this.clusterRoleName });
showDetails(role.selfLink);
this.reset();
AddClusterRoleDialog.close();
} catch (err) {
Notifications.error(err.toString());
}
};
render() {
const { ...dialogProps } = this.props;
return (
<Dialog
{...dialogProps}
className="AddRoleDialog"
isOpen={AddClusterRoleDialog.isOpen.get()}
close={AddClusterRoleDialog.close}
>
<Wizard
header={<h5>Create ClusterRole</h5>}
done={AddClusterRoleDialog.close}
>
<WizardStep
contentClass="flex gaps column"
nextLabel="Create"
next={this.createRole}
>
<SubTitle title="ClusterRole Name" />
<Input
required autoFocus
placeholder="Name"
iconLeft="supervisor_account"
value={this.clusterRoleName}
onChange={v => this.clusterRoleName = v}
/>
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./details.scss";
import { observer } from "mobx-react";
import React from "react";
import { KubeEventDetails } from "../../+events/kube-event-details";
import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry";
import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object";
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
import type { ClusterRole } from "../../../api/endpoints";
interface Props extends KubeObjectDetailsProps<ClusterRole> {
}
@observer
export class ClusterRoleDetails extends React.Component<Props> {
render() {
const { object: clusterRole } = this.props;
if (!clusterRole) return null;
const rules = clusterRole.getRules();
return (
<div className="ClusterRoleDetails">
<KubeObjectMeta object={clusterRole}/>
<DrawerTitle title="Rules"/>
{rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => {
return (
<div className="rule" key={index}>
{resources && (
<>
<div className="name">Resources</div>
<div className="value">{resources.join(", ")}</div>
</>
)}
{verbs && (
<>
<div className="name">Verbs</div>
<div className="value">{verbs.join(", ")}</div>
</>
)}
{apiGroups && (
<>
<div className="name">Api Groups</div>
<div className="value">
{apiGroups
.map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup)
.join(", ")
}
</div>
</>
)}
{resourceNames && (
<>
<div className="name">Resource Names</div>
<div className="value">{resourceNames.join(", ")}</div>
</>
)}
</div>
);
})}
</div>
);
}
}
kubeObjectDetailRegistry.add({
kind: "ClusterRole",
apiVersions: ["rbac.authorization.k8s.io/v1"],
components: {
Details: (props) => <ClusterRoleDetails {...props}/>
}
});
kubeObjectDetailRegistry.add({
kind: "ClusterRole",
apiVersions: ["rbac.authorization.k8s.io/v1"],
priority: 5,
components: {
Details: (props) => <KubeEventDetails {...props}/>
}
});

View File

@ -18,7 +18,6 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export * from "./view";
export * from "./role-bindings"; export * from "./details";
export * from "./role-binding-details"; export * from "./add-dialog";
export * from "./add-role-binding-dialog";

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { apiManager } from "../../../api/api-manager";
import { ClusterRole, clusterRoleApi } from "../../../api/endpoints";
import { KubeObjectStore } from "../../../kube-object.store";
import { autoBind } from "../../../utils";
export class ClusterRolesStore extends KubeObjectStore<ClusterRole> {
api = clusterRoleApi;
constructor() {
super();
autoBind(this);
}
protected sortItems(items: ClusterRole[]) {
return super.sortItems(items, [
clusterRole => clusterRole.kind,
clusterRole => clusterRole.getName(),
]);
}
}
export const clusterRolesStore = new ClusterRolesStore();
apiManager.registerStore(clusterRolesStore);

View File

@ -0,0 +1,11 @@
.ClusterRoles {
.help-icon {
margin-left: $margin / 2;
}
.TableCell {
&.warning {
@include table-cell-warning;
}
}
}

View File

@ -0,0 +1,80 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./view.scss";
import { observer } from "mobx-react";
import React from "react";
import type { RouteComponentProps } from "react-router";
import type { ClusterRole } from "../../../api/endpoints";
import { KubeObjectListLayout } from "../../kube-object";
import { KubeObjectStatusIcon } from "../../kube-object-status-icon";
import type { ClusterRolesRouteParams } from "../user-management.route";
import { AddClusterRoleDialog } from "./add-dialog";
import { clusterRolesStore } from "./store";
enum columnId {
name = "name",
namespace = "namespace",
age = "age",
}
interface Props extends RouteComponentProps<ClusterRolesRouteParams> {
}
@observer
export class ClusterRoles extends React.Component<Props> {
render() {
return (
<>
<KubeObjectListLayout
isConfigurable
tableId="access_cluster_roles"
className="ClusterRoles"
store={clusterRolesStore}
sortingCallbacks={{
[columnId.name]: (clusterRole: ClusterRole) => clusterRole.getName(),
[columnId.age]: (clusterRole: ClusterRole) => clusterRole.getTimeDiffFromNow(),
}}
searchFilters={[
(clusterRole: ClusterRole) => clusterRole.getSearchFields(),
]}
renderHeaderTitle="Cluster Roles"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={(clusterRole: ClusterRole) => [
clusterRole.getName(),
<KubeObjectStatusIcon key="icon" object={clusterRole} />,
clusterRole.getAge(),
]}
addRemoveButtons={{
onAdd: () => AddClusterRoleDialog.open(),
addTooltip: "Create new ClusterRole",
}}
/>
<AddClusterRoleDialog/>
</>
);
}
}

View File

@ -0,0 +1,2 @@
.RoleBindingDetails {
}

View File

@ -19,35 +19,32 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./role-binding-details.scss"; import "./details.scss";
import React from "react"; import { reaction } from "mobx";
import { AddRemoveButtons } from "../add-remove-buttons";
import type { IRoleBindingSubject, RoleBinding } from "../../api/endpoints";
import { boundMethod, prevDefault } from "../../utils";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { ConfirmDialog } from "../confirm-dialog";
import { DrawerTitle } from "../drawer";
import { KubeEventDetails } from "../+events/kube-event-details";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { observable, reaction, makeObservable } from "mobx"; import React from "react";
import { roleBindingsStore } from "./role-bindings.store"; import { KubeEventDetails } from "../../+events/kube-event-details";
import { AddRoleBindingDialog } from "./add-role-binding-dialog"; import type { RoleBinding, RoleBindingSubject } from "../../../api/endpoints";
import type { KubeObjectDetailsProps } from "../kube-object"; import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { prevDefault, boundMethod } from "../../../utils";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { AddRemoveButtons } from "../../add-remove-buttons";
import { ConfirmDialog } from "../../confirm-dialog";
import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object";
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
import { Table, TableCell, TableHead, TableRow } from "../../table";
import { RoleBindingDialog } from "./dialog";
import { roleBindingsStore } from "./store";
import { ObservableHashSet } from "../../../../common/utils/hash-set";
import { hashRoleBindingSubject } from "./hashers";
interface Props extends KubeObjectDetailsProps<RoleBinding> { interface Props extends KubeObjectDetailsProps<RoleBinding> {
} }
@observer @observer
export class RoleBindingDetails extends React.Component<Props> { export class RoleBindingDetails extends React.Component<Props> {
@observable selectedSubjects = observable.array<IRoleBindingSubject>([], { deep: false }); selectedSubjects = new ObservableHashSet<RoleBindingSubject>([], hashRoleBindingSubject);
constructor(props: Props) {
super(props);
makeObservable(this);
}
async componentDidMount() { async componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
@ -57,24 +54,13 @@ export class RoleBindingDetails extends React.Component<Props> {
]); ]);
} }
selectSubject(subject: IRoleBindingSubject) {
const { selectedSubjects } = this;
const isSelected = selectedSubjects.includes(subject);
selectedSubjects.replace(
isSelected
? selectedSubjects.filter(sub => sub !== subject) // unselect
: selectedSubjects.concat(subject) // select
);
}
@boundMethod @boundMethod
removeSelectedSubjects() { removeSelectedSubjects() {
const { object: roleBinding } = this.props; const { object: roleBinding } = this.props;
const { selectedSubjects } = this; const { selectedSubjects } = this;
ConfirmDialog.open({ ConfirmDialog.open({
ok: () => roleBindingsStore.updateSubjects({ roleBinding, removeSubjects: selectedSubjects }), ok: () => roleBindingsStore.removeSubjects(roleBinding, selectedSubjects.toJSON()),
labelOk: `Remove`, labelOk: `Remove`,
message: ( message: (
<p>Remove selected bindings for <b>{roleBinding.getName()}</b>?</p> <p>Remove selected bindings for <b>{roleBinding.getName()}</b>?</p>
@ -115,19 +101,20 @@ export class RoleBindingDetails extends React.Component<Props> {
<Table selectable className="bindings box grow"> <Table selectable className="bindings box grow">
<TableHead> <TableHead>
<TableCell checkbox /> <TableCell checkbox />
<TableCell className="binding">Binding</TableCell> <TableCell className="binding">Name</TableCell>
<TableCell className="type">Type</TableCell> <TableCell className="type">Type</TableCell>
<TableCell className="type">Namespace</TableCell> <TableCell className="type">Namespace</TableCell>
</TableHead> </TableHead>
{ {
subjects.map((subject, i) => { subjects.map((subject, i) => {
const { kind, name, namespace } = subject; const { kind, name, namespace } = subject;
const isSelected = selectedSubjects.includes(subject); const isSelected = selectedSubjects.has(subject);
return ( return (
<TableRow <TableRow
key={i} selected={isSelected} key={i}
onClick={prevDefault(() => this.selectSubject(subject))} selected={isSelected}
onClick={prevDefault(() => this.selectedSubjects.toggle(subject))}
> >
<TableCell checkbox isChecked={isSelected} /> <TableCell checkbox isChecked={isSelected} />
<TableCell className="binding">{name}</TableCell> <TableCell className="binding">{name}</TableCell>
@ -141,9 +128,9 @@ export class RoleBindingDetails extends React.Component<Props> {
)} )}
<AddRemoveButtons <AddRemoveButtons
onAdd={() => AddRoleBindingDialog.open(roleBinding)} onAdd={() => RoleBindingDialog.open(roleBinding)}
onRemove={selectedSubjects.length ? this.removeSelectedSubjects : null} onRemove={selectedSubjects.size ? this.removeSelectedSubjects : null}
addTooltip={`Add bindings to ${roleRef.name}`} addTooltip={`Edit bindings of ${roleRef.name}`}
removeTooltip={`Remove selected bindings from ${roleRef.name}`} removeTooltip={`Remove selected bindings from ${roleRef.name}`}
/> />
</div> </div>
@ -166,20 +153,3 @@ kubeObjectDetailRegistry.add({
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />
} }
}); });
kubeObjectDetailRegistry.add({
kind: "ClusterRoleBinding",
apiVersions: ["rbac.authorization.k8s.io/v1"],
components: {
Details: (props) => <RoleBindingDetails {...props} />
}
});
kubeObjectDetailRegistry.add({
kind: "ClusterRoleBinding",
apiVersions: ["rbac.authorization.k8s.io/v1"],
priority: 5,
components: {
Details: (props) => <KubeEventDetails {...props} />
}
});

View File

@ -0,0 +1,303 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./dialog.scss";
import { computed, observable, makeObservable, action } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { rolesStore } from "../+roles/store";
import { serviceAccountsStore } from "../+service-accounts/store";
import { NamespaceSelect } from "../../+namespaces/namespace-select";
import { ClusterRole, Role, roleApi, RoleBinding, RoleBindingSubject, ServiceAccount } from "../../../api/endpoints";
import { Dialog, DialogProps } from "../../dialog";
import { EditableList } from "../../editable-list";
import { Icon } from "../../icon";
import { showDetails } from "../../kube-object";
import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../../notifications";
import { Select, SelectOption } from "../../select";
import { Wizard, WizardStep } from "../../wizard";
import { roleBindingsStore } from "./store";
import { clusterRolesStore } from "../+cluster-roles/store";
import { Input } from "../../input";
import { getRoleRefSelectOption, ServiceAccountOption } from "../select-options";
import { ObservableHashSet, nFircate } from "../../../utils";
interface Props extends Partial<DialogProps> {
}
interface DialogState {
isOpen: boolean;
data?: RoleBinding;
}
@observer
export class RoleBindingDialog extends React.Component<Props> {
static state = observable.object<DialogState>({
isOpen: false,
});
constructor(props: Props) {
super(props);
makeObservable(this);
}
static open(roleBinding?: RoleBinding) {
RoleBindingDialog.state.isOpen = true;
RoleBindingDialog.state.data = roleBinding;
}
static close() {
RoleBindingDialog.state.isOpen = false;
RoleBindingDialog.state.data = undefined;
}
get roleBinding(): RoleBinding {
return RoleBindingDialog.state.data;
}
@computed get isEditing() {
return !!this.roleBinding;
}
@observable.ref selectedRoleRef: Role | ClusterRole | undefined = undefined;
@observable bindingName = "";
@observable bindingNamespace = "";
selectedAccounts = new ObservableHashSet<ServiceAccount>([], sa => sa.metadata.uid);
selectedUsers = observable.set<string>([]);
selectedGroups = observable.set<string>([]);
@computed get selectedBindings(): RoleBindingSubject[] {
const serviceAccounts = Array.from(this.selectedAccounts, sa => ({
name: sa.getName(),
kind: "ServiceAccount" as const,
namespace: this.bindingNamespace,
}));
const users = Array.from(this.selectedUsers, user => ({
name: user,
kind: "User" as const,
namespace: this.bindingNamespace,
}));
const groups = Array.from(this.selectedGroups, group => ({
name: group,
kind: "Group" as const,
namespace: this.bindingNamespace,
}));
return [
...serviceAccounts,
...users,
...groups,
];
}
@computed get roleRefOptions(): SelectOption<Role | ClusterRole>[] {
const roles = rolesStore.items
.filter(role => role.getNs() === this.bindingNamespace)
.map(getRoleRefSelectOption);
const clusterRoles = clusterRolesStore.items
.map(getRoleRefSelectOption);
return [
...roles,
...clusterRoles,
];
}
@computed get serviceAccountOptions(): ServiceAccountOption[] {
return serviceAccountsStore.items.map(account => {
const name = account.getName();
const namespace = account.getNs();
return {
value: `${account.getName()}%${account.getNs()}`,
account,
label: <><Icon small material="account_box" /> {name} ({namespace})</>
};
});
}
@computed get selectedServiceAccountOptions(): ServiceAccountOption[] {
return this.serviceAccountOptions.filter(({ account }) => this.selectedAccounts.has(account));
}
@action
onOpen = () => {
const binding = this.roleBinding;
if (!binding) {
return this.reset();
}
this.selectedRoleRef = (binding.roleRef.kind === roleApi.kind ? rolesStore : clusterRolesStore)
.items
.find(item => item.getName() === binding.roleRef.name);
this.bindingName = binding.getName();
this.bindingNamespace = binding.getNs();
const [saSubjects, uSubjects, gSubjects] = nFircate(binding.getSubjects(), "kind", ["ServiceAccount", "User", "Group"]);
const accountNames = new Set(saSubjects.map(acc => acc.name));
this.selectedAccounts.replace(
serviceAccountsStore.items
.filter(sa => accountNames.has(sa.getName()))
);
this.selectedUsers.replace(uSubjects.map(user => user.name));
this.selectedGroups.replace(gSubjects.map(group => group.name));
};
@action
reset = () => {
this.selectedRoleRef = undefined;
this.bindingName = "";
this.bindingNamespace = "";
this.selectedAccounts.clear();
this.selectedUsers.clear();
this.selectedGroups.clear();
};
createBindings = async () => {
const { selectedRoleRef, bindingNamespace: namespace, selectedBindings } = this;
try {
const roleBinding = this.isEditing
? await roleBindingsStore.updateSubjects(this.roleBinding, selectedBindings)
: await roleBindingsStore.create({ name: this.bindingName, namespace }, {
subjects: selectedBindings,
roleRef: {
name: selectedRoleRef.getName(),
kind: selectedRoleRef.kind,
}
});
showDetails(roleBinding.selfLink);
RoleBindingDialog.close();
} catch (err) {
Notifications.error(err);
}
};
renderContents() {
return (
<>
<SubTitle title="Namespace" />
<NamespaceSelect
themeName="light"
isDisabled={this.isEditing}
value={this.bindingNamespace}
onChange={({ value }) => this.bindingNamespace = value}
/>
<SubTitle title="Role Reference" />
<Select
themeName="light"
placeholder="Select role or cluster role ..."
isDisabled={this.isEditing}
options={this.roleRefOptions}
value={this.selectedRoleRef}
onChange={({ value }) => {
if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) {
this.bindingName = value.getName();
}
this.selectedRoleRef = value;
}}
/>
<SubTitle title="Binding Name" />
<Input
disabled={this.isEditing}
value={this.bindingName}
onChange={value => this.bindingName = value}
/>
<SubTitle title="Binding targets" />
<b>Users</b>
<EditableList
placeholder="Bind to User Account ..."
add={(newUser) => this.selectedUsers.add(newUser)}
items={Array.from(this.selectedUsers)}
remove={({ oldItem }) => this.selectedUsers.delete(oldItem)}
/>
<b>Groups</b>
<EditableList
placeholder="Bind to User Group ..."
add={(newGroup) => this.selectedGroups.add(newGroup)}
items={Array.from(this.selectedGroups)}
remove={({ oldItem }) => this.selectedGroups.delete(oldItem)}
/>
<b>Service Accounts</b>
<Select
isMulti
themeName="light"
placeholder="Select service accounts ..."
autoConvertOptions={false}
options={this.serviceAccountOptions}
value={this.selectedServiceAccountOptions}
onChange={(selected: ServiceAccountOption[] | null) => {
if (selected) {
this.selectedAccounts.replace(selected.map(opt => opt.account));
} else {
this.selectedAccounts.clear();
}
}}
maxMenuHeight={200}
/>
</>
);
}
render() {
const { ...dialogProps } = this.props;
const [action, nextLabel] = this.isEditing ? ["Edit", "Update"] : ["Add", "Create"];
const disableNext = !this.selectedRoleRef || !this.selectedBindings.length || !this.bindingNamespace || !this.bindingName;
return (
<Dialog
{...dialogProps}
className="AddRoleBindingDialog"
isOpen={RoleBindingDialog.state.isOpen}
close={RoleBindingDialog.close}
onClose={this.reset}
onOpen={this.onOpen}
>
<Wizard
header={<h5>{action} RoleBinding</h5>}
done={RoleBindingDialog.close}
>
<WizardStep
nextLabel={nextLabel}
next={this.createBindings}
disabledNext={disableNext}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { MD5 } from "crypto-js";
import type { RoleBindingSubject } from "../../../api/endpoints";
export function hashRoleBindingSubject(subject: RoleBindingSubject): string {
return MD5(JSON.stringify([
["kind", subject.kind],
["name", subject.name],
["namespace", subject.namespace],
["apiGroup", subject.apiGroup],
])).toString();
}

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "./view";
export * from "./details";
export * from "./dialog";

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { apiManager } from "../../../api/api-manager";
import { RoleBinding, roleBindingApi, RoleBindingSubject } from "../../../api/endpoints";
import { KubeObjectStore } from "../../../kube-object.store";
import { HashSet } from "../../../utils";
import { hashRoleBindingSubject } from "./hashers";
export class RoleBindingsStore extends KubeObjectStore<RoleBinding> {
api = roleBindingApi;
protected sortItems(items: RoleBinding[]) {
return super.sortItems(items, [
roleBinding => roleBinding.kind,
roleBinding => roleBinding.getName()
]);
}
protected async createItem(params: { name: string; namespace: string }, data?: Partial<RoleBinding>) {
return roleBindingApi.create(params, data);
}
async updateSubjects(roleBinding: RoleBinding, subjects: RoleBindingSubject[]) {
return this.update(roleBinding, {
roleRef: roleBinding.roleRef,
subjects,
});
}
async removeSubjects(roleBinding: RoleBinding, subjectsToRemove: Iterable<RoleBindingSubject>) {
const currentSubjects = new HashSet(roleBinding.getSubjects(), hashRoleBindingSubject);
for (const subject of subjectsToRemove) {
currentSubjects.delete(subject);
}
return this.updateSubjects(roleBinding, currentSubjects.toJSON());
}
}
export const roleBindingsStore = new RoleBindingsStore();
apiManager.registerStore(roleBindingsStore);

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./view.scss";
import { observer } from "mobx-react";
import React from "react";
import type { RouteComponentProps } from "react-router";
import type { RoleBinding } from "../../../api/endpoints";
import { KubeObjectListLayout } from "../../kube-object";
import { KubeObjectStatusIcon } from "../../kube-object-status-icon";
import type { RoleBindingsRouteParams } from "../user-management.route";
import { RoleBindingDialog } from "./dialog";
import { roleBindingsStore } from "./store";
import { rolesStore } from "../+roles/store";
import { clusterRolesStore } from "../+cluster-roles/store";
import { serviceAccountsStore } from "../+service-accounts/store";
enum columnId {
name = "name",
namespace = "namespace",
bindings = "bindings",
age = "age",
}
interface Props extends RouteComponentProps<RoleBindingsRouteParams> {
}
@observer
export class RoleBindings extends React.Component<Props> {
render() {
return (
<>
<KubeObjectListLayout
isConfigurable
tableId="access_role_bindings"
className="RoleBindings"
store={roleBindingsStore}
dependentStores={[rolesStore, clusterRolesStore, serviceAccountsStore]}
sortingCallbacks={{
[columnId.name]: (binding: RoleBinding) => binding.getName(),
[columnId.namespace]: (binding: RoleBinding) => binding.getNs(),
[columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(),
[columnId.age]: (binding: RoleBinding) => binding.getTimeDiffFromNow(),
}}
searchFilters={[
(binding: RoleBinding) => binding.getSearchFields(),
(binding: RoleBinding) => binding.getSubjectNames(),
]}
renderHeaderTitle="Role Bindings"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={(binding: RoleBinding) => [
binding.getName(),
<KubeObjectStatusIcon key="icon" object={binding} />,
binding.getNs(),
binding.getSubjectNames(),
binding.getAge(),
]}
addRemoveButtons={{
onAdd: () => RoleBindingDialog.open(),
addTooltip: "Create new RoleBinding",
}}
/>
<RoleBindingDialog />
</>
);
}
}

View File

@ -0,0 +1,5 @@
.AddRoleDialog {
.AceEditor {
min-height: 200px;
}
}

View File

@ -19,29 +19,28 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./add-role-dialog.scss"; import "./add-dialog.scss";
import React from "react"; import React from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard"; import { NamespaceSelect } from "../../+namespaces/namespace-select";
import { SubTitle } from "../layout/sub-title"; import { Dialog, DialogProps } from "../../dialog";
import { Notifications } from "../notifications"; import { Input } from "../../input";
import { rolesStore } from "./roles.store"; import { showDetails } from "../../kube-object";
import { Input } from "../input"; import { SubTitle } from "../../layout/sub-title";
import { NamespaceSelect } from "../+namespaces/namespace-select"; import { Notifications } from "../../notifications";
import { showDetails } from "../kube-object"; import { Wizard, WizardStep } from "../../wizard";
import { rolesStore } from "./store";
interface Props extends Partial<DialogProps> { interface Props extends Partial<DialogProps> {
} }
const dialogState = observable.object({
isOpen: false,
});
@observer @observer
export class AddRoleDialog extends React.Component<Props> { export class AddRoleDialog extends React.Component<Props> {
static isOpen = observable.box(false);
@observable roleName = ""; @observable roleName = "";
@observable namespace = ""; @observable namespace = "";
@ -51,17 +50,13 @@ export class AddRoleDialog extends React.Component<Props> {
} }
static open() { static open() {
dialogState.isOpen = true; AddRoleDialog.isOpen.set(true);
} }
static close() { static close() {
dialogState.isOpen = false; AddRoleDialog.isOpen.set(false);
} }
close = () => {
AddRoleDialog.close();
};
reset = () => { reset = () => {
this.roleName = ""; this.roleName = "";
this.namespace = ""; this.namespace = "";
@ -73,7 +68,7 @@ export class AddRoleDialog extends React.Component<Props> {
showDetails(role.selfLink); showDetails(role.selfLink);
this.reset(); this.reset();
this.close(); AddRoleDialog.close();
} catch (err) { } catch (err) {
Notifications.error(err.toString()); Notifications.error(err.toString());
} }
@ -87,10 +82,10 @@ export class AddRoleDialog extends React.Component<Props> {
<Dialog <Dialog
{...dialogProps} {...dialogProps}
className="AddRoleDialog" className="AddRoleDialog"
isOpen={dialogState.isOpen} isOpen={AddRoleDialog.isOpen.get()}
close={this.close} close={AddRoleDialog.close}
> >
<Wizard header={header} done={this.close}> <Wizard header={header} done={AddRoleDialog.close}>
<WizardStep <WizardStep
contentClass="flex gaps column" contentClass="flex gaps column"
nextLabel="Create" nextLabel="Create"

View File

@ -0,0 +1,21 @@
.RoleDetails {
.rule {
display: grid;
grid-template-columns: min-content auto;
gap: $margin;
border: 1px solid $borderColor;
border-radius: $radius;
padding: $padding * 1.5;
> .name {
color: $textColorSecondary;
text-align: right;
white-space: nowrap;
}
&:not(:last-child) {
margin-bottom: $margin * 2;
}
}
}

View File

@ -19,16 +19,17 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./role-details.scss"; import "./details.scss";
import React from "react";
import { DrawerTitle } from "../drawer";
import { KubeEventDetails } from "../+events/kube-event-details";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { KubeObjectDetailsProps } from "../kube-object"; import React from "react";
import type { Role } from "../../api/endpoints";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeEventDetails } from "../../+events/kube-event-details";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import type { Role } from "../../../api/endpoints";
import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry";
import { DrawerTitle } from "../../drawer";
import type { KubeObjectDetailsProps } from "../../kube-object";
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<Role> { interface Props extends KubeObjectDetailsProps<Role> {
} }
@ -44,7 +45,6 @@ export class RoleDetails extends React.Component<Props> {
return ( return (
<div className="RoleDetails"> <div className="RoleDetails">
<KubeObjectMeta object={role}/> <KubeObjectMeta object={role}/>
<DrawerTitle title="Rules"/> <DrawerTitle title="Rules"/>
{rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => {
return ( return (
@ -101,19 +101,3 @@ kubeObjectDetailRegistry.add({
Details: (props) => <KubeEventDetails {...props} /> Details: (props) => <KubeEventDetails {...props} />
} }
}); });
kubeObjectDetailRegistry.add({
kind: "ClusterRole",
apiVersions: ["rbac.authorization.k8s.io/v1"],
components: {
Details: (props) => <RoleDetails {...props}/>
}
});
kubeObjectDetailRegistry.add({
kind: "ClusterRole",
apiVersions: ["rbac.authorization.k8s.io/v1"],
priority: 5,
components: {
Details: (props) => <KubeEventDetails {...props}/>
}
});

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "./view";
export * from "./details";
export * from "./add-dialog";

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { apiManager } from "../../../api/api-manager";
import { Role, roleApi } from "../../../api/endpoints";
import { KubeObjectStore } from "../../../kube-object.store";
import { autoBind } from "../../../utils";
export class RolesStore extends KubeObjectStore<Role> {
api = roleApi;
constructor() {
super();
autoBind(this);
}
protected sortItems(items: Role[]) {
return super.sortItems(items, [
role => role.kind,
role => role.getName(),
]);
}
protected async createItem(params: { name: string; namespace?: string }, data?: Partial<Role>) {
return roleApi.create(params, data);
}
}
export const rolesStore = new RolesStore();
apiManager.registerStore(rolesStore);

View File

@ -19,17 +19,17 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./roles.scss"; import "./view.scss";
import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import type { IRolesRouteParams } from "../+user-management/user-management.route"; import type { Role } from "../../../api/endpoints";
import { rolesStore } from "./roles.store"; import { KubeObjectListLayout } from "../../kube-object";
import type { Role } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../../kube-object-status-icon";
import { KubeObjectListLayout } from "../kube-object"; import type { RolesRouteParams } from "../user-management.route";
import { AddRoleDialog } from "./add-role-dialog"; import { AddRoleDialog } from "./add-dialog";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { rolesStore } from "./store";
enum columnId { enum columnId {
name = "name", name = "name",
@ -37,7 +37,7 @@ enum columnId {
age = "age", age = "age",
} }
interface Props extends RouteComponentProps<IRolesRouteParams> { interface Props extends RouteComponentProps<RolesRouteParams> {
} }
@observer @observer
@ -68,7 +68,7 @@ export class Roles extends React.Component<Props> {
renderTableContents={(role: Role) => [ renderTableContents={(role: Role) => [
role.getName(), role.getName(),
<KubeObjectStatusIcon key="icon" object={role} />, <KubeObjectStatusIcon key="icon" object={role} />,
role.getNs() || "-", role.getNs(),
role.getAge(), role.getAge(),
]} ]}
addRemoveButtons={{ addRemoveButtons={{

View File

@ -19,30 +19,29 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./create-service-account-dialog.scss"; import "./create-dialog.scss";
import React from "react"; import React from "react";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard"; import { NamespaceSelect } from "../../+namespaces/namespace-select";
import { SubTitle } from "../layout/sub-title"; import { Dialog, DialogProps } from "../../dialog";
import { serviceAccountsStore } from "./service-accounts.store"; import { Input } from "../../input";
import { Input } from "../input"; import { systemName } from "../../input/input_validators";
import { systemName } from "../input/input_validators"; import { showDetails } from "../../kube-object";
import { NamespaceSelect } from "../+namespaces/namespace-select"; import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../notifications"; import { Notifications } from "../../notifications";
import { showDetails } from "../kube-object"; import { Wizard, WizardStep } from "../../wizard";
import { serviceAccountsStore } from "./store";
interface Props extends Partial<DialogProps> { interface Props extends Partial<DialogProps> {
} }
const dialogState = observable.object({
isOpen: false,
});
@observer @observer
export class CreateServiceAccountDialog extends React.Component<Props> { export class CreateServiceAccountDialog extends React.Component<Props> {
static isOpen = observable.box(false);
@observable name = ""; @observable name = "";
@observable namespace = "default"; @observable namespace = "default";
@ -52,17 +51,13 @@ export class CreateServiceAccountDialog extends React.Component<Props> {
} }
static open() { static open() {
dialogState.isOpen = true; CreateServiceAccountDialog.isOpen.set(true);
} }
static close() { static close() {
dialogState.isOpen = false; CreateServiceAccountDialog.isOpen.set(false);
} }
close = () => {
CreateServiceAccountDialog.close();
};
createAccount = async () => { createAccount = async () => {
const { name, namespace } = this; const { name, namespace } = this;
@ -71,7 +66,7 @@ export class CreateServiceAccountDialog extends React.Component<Props> {
this.name = ""; this.name = "";
showDetails(serviceAccount.selfLink); showDetails(serviceAccount.selfLink);
this.close(); CreateServiceAccountDialog.close();
} catch (err) { } catch (err) {
Notifications.error(err); Notifications.error(err);
} }
@ -86,10 +81,10 @@ export class CreateServiceAccountDialog extends React.Component<Props> {
<Dialog <Dialog
{...dialogProps} {...dialogProps}
className="CreateServiceAccountDialog" className="CreateServiceAccountDialog"
isOpen={dialogState.isOpen} isOpen={CreateServiceAccountDialog.isOpen.get()}
close={this.close} close={CreateServiceAccountDialog.close}
> >
<Wizard header={header} done={this.close}> <Wizard header={header} done={CreateServiceAccountDialog.close}>
<WizardStep nextLabel="Create" next={this.createAccount}> <WizardStep nextLabel="Create" next={this.createAccount}>
<SubTitle title="Account Name" /> <SubTitle title="Account Name" />
<Input <Input

View File

@ -19,22 +19,23 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./service-accounts-details.scss"; import "./details.scss";
import React from "react";
import { autorun, observable, makeObservable } from "mobx"; import { autorun, observable, makeObservable } from "mobx";
import { Spinner } from "../spinner";
import { ServiceAccountsSecret } from "./service-accounts-secret";
import { DrawerItem, DrawerTitle } from "../drawer";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { secretsStore } from "../+config-secrets/secrets.store"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Secret, ServiceAccount } from "../../api/endpoints";
import { KubeEventDetails } from "../+events/kube-event-details"; import { secretsStore } from "../../+config-secrets/secrets.store";
import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { KubeEventDetails } from "../../+events/kube-event-details";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { Secret, ServiceAccount } from "../../../api/endpoints";
import { Icon } from "../icon"; import { kubeObjectDetailRegistry } from "../../../api/kube-object-detail-registry";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { DrawerItem, DrawerTitle } from "../../drawer";
import { Icon } from "../../icon";
import { getDetailsUrl, KubeObjectDetailsProps } from "../../kube-object";
import { KubeObjectMeta } from "../../kube-object/kube-object-meta";
import { Spinner } from "../../spinner";
import { ServiceAccountsSecret } from "./secret";
interface Props extends KubeObjectDetailsProps<ServiceAccount> { interface Props extends KubeObjectDetailsProps<ServiceAccount> {
} }

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "./view";
export * from "./details";
export * from "./create-dialog";

View File

@ -19,13 +19,14 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./service-accounts-secret.scss"; import "./secret.scss";
import React from "react";
import moment from "moment"; import moment from "moment";
import { Icon } from "../icon"; import React from "react";
import type { Secret } from "../../api/endpoints/secret.api";
import { prevDefault } from "../../utils"; import type { Secret } from "../../../api/endpoints/secret.api";
import { prevDefault } from "../../../utils";
import { Icon } from "../../icon";
interface Props { interface Props {
secret: Secret; secret: Secret;

View File

@ -19,10 +19,10 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { autoBind } from "../../utils"; import { apiManager } from "../../../api/api-manager";
import { ServiceAccount, serviceAccountsApi } from "../../api/endpoints"; import { ServiceAccount, serviceAccountsApi } from "../../../api/endpoints";
import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectStore } from "../../../kube-object.store";
import { apiManager } from "../../api/api-manager"; import { autoBind } from "../../../utils";
export class ServiceAccountsStore extends KubeObjectStore<ServiceAccount> { export class ServiceAccountsStore extends KubeObjectStore<ServiceAccount> {
api = serviceAccountsApi; api = serviceAccountsApi;

View File

@ -19,22 +19,22 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./service-accounts.scss"; import "./view.scss";
import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { ServiceAccount } from "../../api/endpoints/service-accounts.api"; import React from "react";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import type { KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import type { ServiceAccountsRouteParams } from "../user-management.route";
import { MenuItem } from "../menu"; import { kubeObjectMenuRegistry } from "../../../../extensions/registries/kube-object-menu-registry";
import { openServiceAccountKubeConfig } from "../kubeconfig-dialog"; import type { ServiceAccount } from "../../../api/endpoints/service-accounts.api";
import { Icon } from "../icon"; import { Icon } from "../../icon";
import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectListLayout } from "../../kube-object";
import type { IServiceAccountsRouteParams } from "../+user-management"; import { KubeObjectStatusIcon } from "../../kube-object-status-icon";
import { serviceAccountsStore } from "./service-accounts.store"; import type { KubeObjectMenuProps } from "../../kube-object/kube-object-menu";
import { CreateServiceAccountDialog } from "./create-service-account-dialog"; import { openServiceAccountKubeConfig } from "../../kubeconfig-dialog";
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { MenuItem } from "../../menu";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { CreateServiceAccountDialog } from "./create-dialog";
import { serviceAccountsStore } from "./store";
enum columnId { enum columnId {
name = "name", name = "name",
@ -42,7 +42,7 @@ enum columnId {
age = "age", age = "age",
} }
interface Props extends RouteComponentProps<IServiceAccountsRouteParams> { interface Props extends RouteComponentProps<ServiceAccountsRouteParams> {
} }
@observer @observer

View File

@ -0,0 +1,49 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import type { ServiceAccount } from "../../api/endpoints";
import type { KubeObject } from "../../api/kube-object";
import { Icon } from "../icon";
import type { SelectOption } from "../select";
import { TooltipPosition } from "../tooltip";
export type ServiceAccountOption = SelectOption<string> & { account: ServiceAccount };
export function getRoleRefSelectOption<T extends KubeObject>(item: T): SelectOption<T> {
return {
value: item,
label: (
<>
<Icon
small
material={item.kind === "Role" ? "person" : "people"}
tooltip={{
preferredPositions: TooltipPosition.LEFT,
children: item.kind
}}
/>
{" "}
{item.getName()}
</>
),
};
}

View File

@ -19,45 +19,62 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { RouteProps } from "react-router";
import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl";
import type { RouteProps } from "react-router";
// Routes // Routes
export const serviceAccountsRoute: RouteProps = { export const serviceAccountsRoute: RouteProps = {
path: "/service-accounts" path: "/service-accounts"
}; };
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const rolesRoute: RouteProps = { export const rolesRoute: RouteProps = {
path: "/roles" path: "/roles"
}; };
export const clusterRolesRoute: RouteProps = {
path: "/cluster-roles"
};
export const roleBindingsRoute: RouteProps = { export const roleBindingsRoute: RouteProps = {
path: "/role-bindings" path: "/role-bindings"
}; };
export const podSecurityPoliciesRoute: RouteProps = { export const clusterRoleBindingsRoute: RouteProps = {
path: "/pod-security-policies" path: "/cluster-role-bindings"
}; };
export const usersManagementRoute: RouteProps = { export const usersManagementRoute: RouteProps = {
path: [ path: [
serviceAccountsRoute, serviceAccountsRoute,
podSecurityPoliciesRoute,
roleBindingsRoute, roleBindingsRoute,
clusterRoleBindingsRoute,
rolesRoute, rolesRoute,
podSecurityPoliciesRoute clusterRolesRoute,
].map(route => route.path.toString()) ].map(route => route.path.toString())
}; };
// Route params // Route params
export interface IServiceAccountsRouteParams { export interface ServiceAccountsRouteParams {
} }
export interface IRoleBindingsRouteParams { export interface RoleBindingsRouteParams {
} }
export interface IRolesRouteParams { export interface ClusterRoleBindingsRouteParams {
}
export interface RolesRouteParams {
}
export interface ClusterRolesRouteParams {
} }
// URL-builders // URL-builders
export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(params); export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(params);
export const serviceAccountsURL = buildURL<IServiceAccountsRouteParams>(serviceAccountsRoute.path); export const serviceAccountsURL = buildURL<ServiceAccountsRouteParams>(serviceAccountsRoute.path);
export const roleBindingsURL = buildURL<IRoleBindingsRouteParams>(roleBindingsRoute.path);
export const rolesURL = buildURL<IRoleBindingsRouteParams>(rolesRoute.path);
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);
export const roleBindingsURL = buildURL<RoleBindingsRouteParams>(roleBindingsRoute.path);
export const clusterRoleBindingsURL = buildURL<ClusterRoleBindingsRouteParams>(clusterRoleBindingsRoute.path);
export const rolesURL = buildURL<RoleBindingsRouteParams>(rolesRoute.path);
export const clusterRolesURL = buildURL<ClusterRoleBindingsRouteParams>(clusterRolesRoute.path);

View File

@ -20,15 +20,32 @@
*/ */
import "./user-management.scss"; import "./user-management.scss";
import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import React from "react";
import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts";
import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
import { PodSecurityPolicies } from "../+pod-security-policies"; import { PodSecurityPolicies } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
import { ClusterRoles } from "./+cluster-roles";
import { ClusterRoleBindings } from "./+cluster-role-bindings";
import { Roles } from "./+roles";
import { RoleBindings } from "./+role-bindings";
import { ServiceAccounts } from "./+service-accounts";
import {
clusterRoleBindingsRoute,
clusterRoleBindingsURL,
clusterRolesRoute,
clusterRolesURL,
podSecurityPoliciesRoute,
podSecurityPoliciesURL,
roleBindingsRoute,
roleBindingsURL,
rolesRoute,
rolesURL,
serviceAccountsRoute,
serviceAccountsURL,
} from "./user-management.route";
@observer @observer
export class UserManagement extends React.Component { export class UserManagement extends React.Component {
@ -44,18 +61,16 @@ export class UserManagement extends React.Component {
}); });
} }
if (isAllowedResource("rolebindings") || isAllowedResource("clusterrolebindings")) { if (isAllowedResource("clusterroles")) {
// TODO: seperate out these two pages
tabRoutes.push({ tabRoutes.push({
title: "Role Bindings", title: "Cluster Roles",
component: RoleBindings, component: ClusterRoles,
url: roleBindingsURL(), url: clusterRolesURL(),
routePath: roleBindingsRoute.path.toString(), routePath: clusterRolesRoute.path.toString(),
}); });
} }
if (isAllowedResource("roles") || isAllowedResource("clusterroles")) { if (isAllowedResource("roles")) {
// TODO: seperate out these two pages
tabRoutes.push({ tabRoutes.push({
title: "Roles", title: "Roles",
component: Roles, component: Roles,
@ -64,6 +79,24 @@ export class UserManagement extends React.Component {
}); });
} }
if (isAllowedResource("clusterrolebindings")) {
tabRoutes.push({
title: "Cluster Role Bindings",
component: ClusterRoleBindings,
url: clusterRoleBindingsURL(),
routePath: clusterRoleBindingsRoute.path.toString(),
});
}
if (isAllowedResource("rolebindings")) {
tabRoutes.push({
title: "Role Bindings",
component: RoleBindings,
url: roleBindingsURL(),
routePath: roleBindingsRoute.path.toString(),
});
}
if (isAllowedResource("podsecuritypolicies")) { if (isAllowedResource("podsecuritypolicies")) {
tabRoutes.push({ tabRoutes.push({
title: "Pod Security Policies", title: "Pod Security Policies",

View File

@ -41,7 +41,6 @@ import { Events } from "./+events/events";
import { eventRoute } from "./+events"; import { eventRoute } from "./+events";
import { Apps, appsRoute } from "./+apps"; import { Apps, appsRoute } from "./+apps";
import { KubeObjectDetails } from "./kube-object/kube-object-details"; import { KubeObjectDetails } from "./kube-object/kube-object-details";
import { AddRoleBindingDialog } from "./+user-management-roles-bindings";
import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog";
import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog";
import { CustomResources } from "./+custom-resources/custom-resources"; import { CustomResources } from "./+custom-resources/custom-resources";
@ -201,7 +200,6 @@ export class App extends React.Component {
<ConfirmDialog/> <ConfirmDialog/>
<KubeObjectDetails/> <KubeObjectDetails/>
<KubeConfigDialog/> <KubeConfigDialog/>
<AddRoleBindingDialog/>
<DeploymentScaleDialog/> <DeploymentScaleDialog/>
<StatefulSetScaleDialog/> <StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/> <ReplicaSetScaleDialog/>

View File

@ -23,7 +23,7 @@
.el-contents { .el-contents {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: $padding * 2; margin: $padding 0px;
.el-value-remove { .el-value-remove {
.Icon { .Icon {
@ -35,7 +35,9 @@
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
padding: $padding $padding * 2; padding: $padding $padding * 2;
margin-bottom: 1px; margin-bottom: $padding / 4;
backdrop-filter: brightness(0.75);
border-radius: var(--border-radius);
:last-child { :last-child {
margin-bottom: unset; margin-bottom: unset;

View File

@ -21,11 +21,11 @@
import "./editable-list.scss"; import "./editable-list.scss";
import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Input } from "../input"; import { Input } from "../input";
import { observable, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
export interface Props<T> { export interface Props<T> {
@ -47,20 +47,14 @@ const defaultProps: Partial<Props<any>> = {
@observer @observer
export class EditableList<T> extends React.Component<Props<T>> { export class EditableList<T> extends React.Component<Props<T>> {
static defaultProps = defaultProps as Props<any>; static defaultProps = defaultProps as Props<any>;
@observable currentNewItem = "";
constructor(props: Props<T>) {
super(props);
makeObservable(this);
}
@boundMethod @boundMethod
onSubmit(val: string) { onSubmit(val: string, evt: React.KeyboardEvent) {
const { add } = this.props; const { add } = this.props;
if (val) { if (val) {
evt.preventDefault();
add(val); add(val);
this.currentNewItem = "";
} }
} }
@ -71,17 +65,15 @@ export class EditableList<T> extends React.Component<Props<T>> {
<div className="EditableList"> <div className="EditableList">
<div className="el-header"> <div className="el-header">
<Input <Input
theme="round-black" theme="round"
value={this.currentNewItem}
onSubmit={this.onSubmit} onSubmit={this.onSubmit}
placeholder={placeholder} placeholder={placeholder}
onChange={val => this.currentNewItem = val}
/> />
</div> </div>
<div className="el-contents"> <div className="el-contents">
{ {
items.map((item, index) => ( items.map((item, index) => (
<div key={`${item}${index}`} className="el-item Badge"> <div key={`${item}${index}`} className="el-item">
<div>{renderItem(item, index)}</div> <div>{renderItem(item, index)}</div>
<div className="el-value-remove"> <div className="el-value-remove">
<Icon material="delete_outline" onClick={() => remove(({ index, oldItem: item }))} /> <Icon material="delete_outline" onClick={() => remove(({ index, oldItem: item }))} />

View File

@ -110,23 +110,17 @@
//- Themes //- Themes
&.theme { &.theme {
&.round-black { &.round {
&.invalid.dirty { &.invalid.dirty {
label { label {
border-color: $colorSoftError; border-color: $colorSoftError;
} }
} }
label { label {
background: var(--inputControlBackground); border-radius: $radius;
border: 1px solid var(--inputControlBorder); border: 1px solid $halfGray;
border-radius: 5px; color: inherit;
padding: $padding; padding: $padding / 4 * 3 $padding / 4 * 3;
color: var(--textColorTertiary);
&:hover {
border-color: var(--inputControlHoverBorder);
}
&:focus-within { &:focus-within {
border-color: $colorInfo; border-color: $colorInfo;
@ -136,6 +130,18 @@
display: none; display: none;
} }
} }
&.black {
label {
background: var(--inputControlBackground);
border-color: var(--inputControlBorder);
color: var(--textColorTertiary);
padding: $padding;
&:hover {
border-color: var(--inputControlHoverBorder);
}
}
}
} }
} }
} }

View File

@ -41,7 +41,7 @@ type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>; type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSubmit"> & { export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSubmit"> & {
theme?: "round-black"; theme?: "round-black" | "round";
className?: string; className?: string;
value?: T; value?: T;
autoSelectOnFocus?: boolean autoSelectOnFocus?: boolean
@ -55,7 +55,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[]; validators?: InputValidator | InputValidator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void; onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: T): void; onSubmit?(value: T, evt: React.KeyboardEvent<InputElement>): void;
}; };
interface State { interface State {
@ -90,7 +90,7 @@ export class Input extends React.Component<InputProps, State> {
return this.state.valid; return this.state.valid;
} }
setValue(value: string) { setValue(value = "") {
if (value !== this.getValue()) { if (value !== this.getValue()) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
@ -236,16 +236,15 @@ export class Input extends React.Component<InputProps, State> {
} }
@boundMethod @boundMethod
onChange(evt: React.ChangeEvent<any>) { onChange(evt: React.ChangeEvent<InputElement>) {
if (this.props.onChange) { this.props.onChange?.(evt.currentTarget.value, evt);
this.props.onChange(evt.currentTarget.value, evt);
}
this.validate(); this.validate();
this.autoFitHeight(); this.autoFitHeight();
// mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing // mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing
if (!this.state.dirty) this.setState({ dirtyOnBlur: true }); if (!this.state.dirty) {
this.setState({ dirtyOnBlur: true });
}
// re-render component when used as uncontrolled input // re-render component when used as uncontrolled input
// when used @defaultValue instead of @value changing real input.value doesn't call render() // when used @defaultValue instead of @value changing real input.value doesn't call render()
@ -255,17 +254,19 @@ export class Input extends React.Component<InputProps, State> {
} }
@boundMethod @boundMethod
onKeyDown(evt: React.KeyboardEvent<any>) { onKeyDown(evt: React.KeyboardEvent<InputElement>) {
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (this.props.onKeyDown) { this.props.onKeyDown?.(evt);
this.props.onKeyDown(evt);
}
switch (evt.key) { switch (evt.key) {
case "Enter": case "Enter":
if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) {
this.props.onSubmit(this.getValue()); this.props.onSubmit(this.getValue(), evt);
if (this.isUncontrolled) {
this.setValue();
}
} }
break; break;
} }
@ -303,6 +304,20 @@ export class Input extends React.Component<InputProps, State> {
} }
} }
get themeSelection(): Record<string, boolean> {
const { theme } = this.props;
if (!theme) {
return {};
}
return {
theme: true,
round: true,
black: theme === "round-black",
};
}
@boundMethod @boundMethod
bindRef(elem: InputElement) { bindRef(elem: InputElement) {
this.input = elem; this.input = elem;
@ -318,7 +333,7 @@ export class Input extends React.Component<InputProps, State> {
const { focused, dirty, valid, validating, errors } = this.state; const { focused, dirty, valid, validating, errors } = this.state;
const className = cssNames("Input", this.props.className, { const className = cssNames("Input", this.props.className, {
[`theme ${theme}`]: theme, ...this.themeSelection,
focused, focused,
disabled, disabled,
invalid: !valid, invalid: !valid,

View File

@ -26,10 +26,11 @@ import "./select.scss";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { computed, makeObservable } from "mobx"; import { computed, makeObservable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { boundMethod, cssNames } from "../../utils";
import ReactSelect, { ActionMeta, components, OptionTypeBase, Props as ReactSelectProps, Styles } from "react-select"; import ReactSelect, { ActionMeta, components, OptionTypeBase, Props as ReactSelectProps, Styles } from "react-select";
import Creatable, { CreatableProps } from "react-select/creatable"; import Creatable, { CreatableProps } from "react-select/creatable";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { boundMethod, cssNames } from "../../utils";
const { Menu } = components; const { Menu } = components;
@ -65,8 +66,10 @@ export class Select extends React.Component<SelectProps> {
makeObservable(this); makeObservable(this);
} }
@computed get theme() { @computed get themeClass() {
return this.props.themeName || ThemeStore.getInstance().activeTheme.type; const themeName = this.props.themeName || ThemeStore.getInstance().activeTheme.type;
return `theme-${themeName}`;
} }
private styles: Styles<OptionTypeBase, boolean> = { private styles: Styles<OptionTypeBase, boolean> = {
@ -128,7 +131,6 @@ export class Select extends React.Component<SelectProps> {
className, menuClass, isCreatable, autoConvertOptions, className, menuClass, isCreatable, autoConvertOptions,
value, options, components = {}, ...props value, options, components = {}, ...props
} = this.props; } = this.props;
const themeClass = `theme-${this.theme}`;
const WrappedMenu = components.Menu ?? Menu; const WrappedMenu = components.Menu ?? Menu;
const selectProps: Partial<SelectProps> = { const selectProps: Partial<SelectProps> = {
@ -138,14 +140,14 @@ export class Select extends React.Component<SelectProps> {
options: autoConvertOptions ? this.options : options, options: autoConvertOptions ? this.options : options,
onChange: this.onChange, onChange: this.onChange,
onKeyDown: this.onKeyDown, onKeyDown: this.onKeyDown,
className: cssNames("Select", themeClass, className), className: cssNames("Select", this.themeClass, className),
classNamePrefix: "Select", classNamePrefix: "Select",
components: { components: {
...components, ...components,
Menu: props => ( Menu: props => (
<WrappedMenu <WrappedMenu
{...props} {...props}
className={cssNames(menuClass, themeClass, props.className)} className={cssNames(menuClass, this.themeClass, props.className)}
/> />
), ),
} }

View File

@ -22,7 +22,7 @@
import type { ClusterContext } from "./components/context"; import type { ClusterContext } from "./components/context";
import { action, computed, makeObservable, observable, reaction, when } from "mobx"; import { action, computed, makeObservable, observable, reaction, when } from "mobx";
import { autoBind, bifurcateArray, noop, rejectPromiseBy } from "./utils"; import { autoBind, noop, rejectPromiseBy } from "./utils";
import { KubeObject, KubeStatus } from "./api/kube-object"; import { KubeObject, KubeStatus } from "./api/kube-object";
import type { IKubeWatchEvent } from "./api/kube-watch-api"; import type { IKubeWatchEvent } from "./api/kube-watch-api";
import { ItemStore } from "./item.store"; import { ItemStore } from "./item.store";
@ -309,51 +309,31 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
}); });
} }
getSubscribeApis(): KubeApi[] { subscribe() {
return [this.api];
}
subscribe(apis = this.getSubscribeApis()) {
const abortController = new AbortController(); const abortController = new AbortController();
const [clusterScopedApis, namespaceScopedApis] = bifurcateArray(apis, api => api.isNamespaced);
for (const api of namespaceScopedApis) { if (this.api.isNamespaced) {
const store = apiManager.getStore(api); Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])])
// This waits for the context and namespaces to be ready or fails fast if the disposer is called
Promise.race([rejectPromiseBy(abortController.signal), Promise.all([store.contextReady, store.namespacesReady])])
.then(() => { .then(() => {
if ( if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) {
store.context.cluster.isGlobalWatchEnabled return this.watchNamespace("", abortController);
&& store.loadedNamespaces.length === 0
) {
return store.watchNamespace(api, "", abortController);
} }
for (const namespace of this.loadedNamespaces) { for (const namespace of this.loadedNamespaces) {
store.watchNamespace(api, namespace, abortController); this.watchNamespace(namespace, abortController);
} }
}) })
.catch(noop); // ignore DOMExceptions .catch(noop); // ignore DOMExceptions
} else {
this.watchNamespace("", abortController);
} }
for (const api of clusterScopedApis) { return () => abortController.abort();
/**
* if the api is cluster scoped then we will never assign to `loadedNamespaces`
* and thus `store.namespacesReady` will never resolve. Futhermore, we don't care
* about watching namespaces.
*/
apiManager.getStore(api).watchNamespace(api, "", abortController);
} }
return () => { private watchNamespace(namespace: string, abortController: AbortController) {
abortController.abort();
};
}
private watchNamespace(api: KubeApi<T>, namespace: string, abortController: AbortController) {
let timedRetry: NodeJS.Timeout; let timedRetry: NodeJS.Timeout;
const watch = () => api.watch({ const watch = () => this.api.watch({
namespace, namespace,
abortController, abortController,
callback callback