Merge branch 'master' into rugvip/depr
This commit is contained in:
@@ -46,8 +46,7 @@
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
"swagger-client": "3.16.1",
|
||||
"swagger-ui-react": "^4.0.0-rc.3"
|
||||
"swagger-ui-react": "^4.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
@@ -63,7 +62,7 @@
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/swagger-ui-react": "^3.23.3",
|
||||
"@types/swagger-ui-react": "^4.1.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.35.0"
|
||||
},
|
||||
|
||||
@@ -148,7 +148,7 @@ export const OpenApiDefinition = ({ definition }: OpenApiDefinitionProps) => {
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<SwaggerUI spec={def} deepLinking />
|
||||
<SwaggerUI spec={def} url="" deepLinking />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,7 +91,7 @@ export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
|
||||
|
||||
const frontendConfigs = await schema.process(
|
||||
[{ data: config.get() as JsonObject, context: 'app' }],
|
||||
{ visibility: ['frontend'] },
|
||||
{ visibility: ['frontend'], withDeprecatedKeys: true },
|
||||
);
|
||||
appConfigs.push(...frontendConfigs);
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,23 +36,23 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
) {}
|
||||
|
||||
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
|
||||
const authorizeResponse = (
|
||||
const authorizeDecision = (
|
||||
await this.permissionApi.authorize(
|
||||
[{ permission: catalogEntityReadPermission }],
|
||||
{ token: request?.authorizationToken },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (authorizeResponse.result === AuthorizeResult.DENY) {
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
entities: [],
|
||||
pageInfo: { hasNextPage: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (authorizeResponse.result === AuthorizeResult.CONDITIONAL) {
|
||||
if (authorizeDecision.result === AuthorizeResult.CONDITIONAL) {
|
||||
const permissionFilter: EntityFilter = this.transformConditions(
|
||||
authorizeResponse.conditions,
|
||||
authorizeDecision.conditions,
|
||||
);
|
||||
return this.entitiesCatalog.entities({
|
||||
...request,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class AuthorizedRefreshService implements RefreshService {
|
||||
) {}
|
||||
|
||||
async refresh(options: RefreshOptions) {
|
||||
const authorizeResponse = (
|
||||
const authorizeDecision = (
|
||||
await this.permissionApi.authorize(
|
||||
[
|
||||
{
|
||||
@@ -39,7 +39,7 @@ export class AuthorizedRefreshService implements RefreshService {
|
||||
{ token: options.authorizationToken },
|
||||
)
|
||||
)[0];
|
||||
if (authorizeResponse.result !== AuthorizeResult.ALLOW) {
|
||||
if (authorizeDecision.result !== AuthorizeResult.ALLOW) {
|
||||
throw new NotAllowedError();
|
||||
}
|
||||
await this.service.refresh(options);
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('NextEntitiesCatalog', () => {
|
||||
target_entity_ref: stringifyEntityRef(entity),
|
||||
});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
async function addEntityToSearch(knex: Knex, entity: Entity) {
|
||||
@@ -468,4 +470,69 @@ describe('NextEntitiesCatalog', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
it.each(databases.eachSupportedId())(
|
||||
'also clears parent hashes',
|
||||
async databaseId => {
|
||||
const { knex } = await createDatabase(databaseId);
|
||||
|
||||
const grandparent: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'grandparent' },
|
||||
spec: {},
|
||||
};
|
||||
const parent1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'parent1' },
|
||||
spec: {},
|
||||
};
|
||||
const parent2: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'parent2' },
|
||||
spec: {},
|
||||
};
|
||||
const root: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'root' },
|
||||
spec: {},
|
||||
};
|
||||
const unrelated: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'unrelated' },
|
||||
spec: {},
|
||||
};
|
||||
|
||||
await addEntity(knex, grandparent, [{ source: 's' }]);
|
||||
await addEntity(knex, parent1, [{ entity: grandparent }]);
|
||||
await addEntity(knex, parent2, [{ entity: grandparent }]);
|
||||
const uid = await addEntity(knex, root, [
|
||||
{ entity: parent1 },
|
||||
{ entity: parent2 },
|
||||
]);
|
||||
await addEntity(knex, unrelated, []);
|
||||
await knex('refresh_state').update({ result_hash: 'not-changed' });
|
||||
|
||||
const catalog = new NextEntitiesCatalog(knex);
|
||||
await catalog.removeEntityByUid(uid);
|
||||
|
||||
await expect(
|
||||
knex
|
||||
.from('refresh_state')
|
||||
.select('entity_ref', 'result_hash')
|
||||
.orderBy('entity_ref'),
|
||||
).resolves.toEqual([
|
||||
{ entity_ref: 'k:default/grandparent', result_hash: 'not-changed' },
|
||||
{ entity_ref: 'k:default/parent1', result_hash: 'child-was-deleted' },
|
||||
{ entity_ref: 'k:default/parent2', result_hash: 'child-was-deleted' },
|
||||
{ entity_ref: 'k:default/unrelated', result_hash: 'not-changed' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,6 +204,29 @@ export class NextEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
|
||||
async removeEntityByUid(uid: string): Promise<void> {
|
||||
// Clear the hashed state of the immediate parents of the deleted entity.
|
||||
// This makes sure that when they get reprocessed, their output is written
|
||||
// down again. The reason for wanting to do this, is that if the user
|
||||
// deletes entities that ARE still emitted by the parent, the parent
|
||||
// processing will still generate the same output hash as always, which
|
||||
// means it'll never try to write down the children again (it assumes that
|
||||
// they already exist). This means that without the code below, the database
|
||||
// never "heals" from accidental deletes.
|
||||
await this.database<DbRefreshStateRow>('refresh_state')
|
||||
.update({
|
||||
result_hash: 'child-was-deleted',
|
||||
})
|
||||
.whereIn('entity_ref', function parents(builder) {
|
||||
return builder
|
||||
.from<DbRefreshStateRow>('refresh_state')
|
||||
.innerJoin<DbRefreshStateReferencesRow>('refresh_state_references', {
|
||||
'refresh_state_references.target_entity_ref':
|
||||
'refresh_state.entity_ref',
|
||||
})
|
||||
.where('refresh_state.entity_id', '=', uid)
|
||||
.select('refresh_state_references.source_entity_ref');
|
||||
});
|
||||
|
||||
await this.database<DbRefreshStateRow>('refresh_state')
|
||||
.where('entity_id', uid)
|
||||
.delete();
|
||||
|
||||
@@ -31,6 +31,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
itemText: {
|
||||
width: '100%',
|
||||
wordBreak: 'break-all',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,21 +79,27 @@ To expose the plugin to your users, you can integrate the `cost-insights` route
|
||||
export const AppSidebar = () => (
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SidebarSearch />
|
||||
<SidebarGroup icon={<SearchIcon />} to="/search">
|
||||
<SidebarSearch />
|
||||
</SidebarGroup>
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="./" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
<SidebarGroup label="Menu" icon={<MenuIcon />}>
|
||||
<SidebarItem icon={HomeIcon} to="./" text="Home" />
|
||||
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
|
||||
<SidebarItem icon={LibraryBooks} to="/docs" text="Docs" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
|
||||
+ <SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
|
||||
</SidebarGroup>
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarSettings />
|
||||
<SidebarGroup icon={<UserSettingsSignInAvatar />} to="/settings">
|
||||
<SidebarSettings />
|
||||
</SidebarGroup>
|
||||
</Sidebar>
|
||||
);
|
||||
```
|
||||
|
||||
@@ -27,6 +27,7 @@ export const EntityGroupProfileCard: ({
|
||||
export const EntityMembersListCard: (_props: {
|
||||
entity?: GroupEntity | undefined;
|
||||
memberDisplayTitle?: string | undefined;
|
||||
pageSize?: number | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityOwnershipCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -67,6 +68,7 @@ export const GroupProfileCard: ({
|
||||
export const MembersListCard: (_props: {
|
||||
entity?: GroupEntity;
|
||||
memberDisplayTitle?: string;
|
||||
pageSize?: number;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "orgPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
|
||||
@@ -112,9 +112,10 @@ export const MembersListCard = (_props: {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: GroupEntity;
|
||||
memberDisplayTitle?: string;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
const { entity: groupEntity } = useEntity<GroupEntity>();
|
||||
let { memberDisplayTitle } = _props;
|
||||
let { memberDisplayTitle, pageSize } = _props;
|
||||
const {
|
||||
metadata: { name: groupName, namespace: grpNamespace },
|
||||
spec: { profile },
|
||||
@@ -129,7 +130,7 @@ export const MembersListCard = (_props: {
|
||||
const pageChange = (_: React.ChangeEvent<unknown>, pageIndex: number) => {
|
||||
setPage(pageIndex);
|
||||
};
|
||||
const pageSize = 50;
|
||||
pageSize = pageSize ? pageSize : 50;
|
||||
memberDisplayTitle = memberDisplayTitle ? memberDisplayTitle : 'Members';
|
||||
|
||||
const {
|
||||
@@ -192,7 +193,7 @@ export const MembersListCard = (_props: {
|
||||
) : (
|
||||
<Box p={2}>
|
||||
<Typography>
|
||||
This group has no ${memberDisplayTitle.toLocaleLowerCase()}.
|
||||
This group has no {memberDisplayTitle.toLocaleLowerCase()}.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -103,22 +103,24 @@ describe('createRouter', () => {
|
||||
it('calls the permission policy', async () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission2',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
@@ -141,10 +143,12 @@ describe('createRouter', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves identity from the Authorization header', async () => {
|
||||
@@ -152,15 +156,17 @@ describe('createRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(policy.handle).toHaveBeenCalledWith(
|
||||
@@ -172,9 +178,9 @@ describe('createRouter', () => {
|
||||
},
|
||||
{ id: 'test-user', token: 'test-token' },
|
||||
);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [{ id: '123', result: AuthorizeResult.ALLOW }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('conditional policy result', () => {
|
||||
@@ -188,27 +194,31 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('makes separate batched requests to multiple plugin backends', async () => {
|
||||
@@ -241,44 +251,46 @@ describe('createRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
@@ -319,12 +331,14 @@ describe('createRouter', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.DENY },
|
||||
{ id: '456', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.DENY },
|
||||
{ id: '456', result: AuthorizeResult.DENY },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves definitive results unchanged', async () => {
|
||||
@@ -363,60 +377,62 @@ describe('createRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
{
|
||||
id: '567',
|
||||
permission: {
|
||||
name: 'test.permission.5',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '567',
|
||||
permission: {
|
||||
name: 'test.permission.5',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:5',
|
||||
},
|
||||
resourceRef: 'resource:5',
|
||||
},
|
||||
{
|
||||
id: '678',
|
||||
permission: {
|
||||
name: 'test.permission.6',
|
||||
attributes: {},
|
||||
{
|
||||
id: '678',
|
||||
permission: {
|
||||
name: 'test.permission.6',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
@@ -457,14 +473,16 @@ describe('createRouter', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{ id: '456', result: AuthorizeResult.ALLOW },
|
||||
{ id: '567', result: AuthorizeResult.ALLOW },
|
||||
{ id: '678', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{ id: '456', result: AuthorizeResult.ALLOW },
|
||||
{ id: '567', result: AuthorizeResult.ALLOW },
|
||||
{ id: '678', result: AuthorizeResult.DENY },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves conditional results without resourceRefs unchanged', async () => {
|
||||
@@ -494,43 +512,45 @@ describe('createRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
@@ -559,18 +579,20 @@ describe('createRouter', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{
|
||||
id: '456',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{
|
||||
id: '456',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[ApplyConditionsResponseEntry['result'], string]>([
|
||||
@@ -600,26 +622,28 @@ describe('createRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'test/resource',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'test/resource',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
resourceRef: 'test/resource',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
{
|
||||
id: '234',
|
||||
resourceRef: 'test/resource',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'test-plugin',
|
||||
@@ -641,16 +665,18 @@ describe('createRouter', () => {
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
result,
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
result,
|
||||
},
|
||||
]);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
result,
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
result,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -660,9 +686,14 @@ describe('createRouter', () => {
|
||||
'',
|
||||
{},
|
||||
[{ permission: { name: 'test.permission', attributes: {} } }],
|
||||
[{ id: '123' }],
|
||||
[{ id: '123', permission: { name: 'test.permission' } }],
|
||||
[{ id: '123', permission: { attributes: { invalid: 'attribute' } } }],
|
||||
{ items: [{ permission: { name: 'test.permission', attributes: {} } }] },
|
||||
{ items: [{ id: '123' }] },
|
||||
{ items: [{ id: '123', permission: { name: 'test.permission' } }] },
|
||||
{
|
||||
items: [
|
||||
{ id: '123', permission: { attributes: { invalid: 'attribute' } } },
|
||||
],
|
||||
},
|
||||
])('returns a 400 error for invalid request %#', async requestBody => {
|
||||
const response = await request(app).post('/authorize').send(requestBody);
|
||||
|
||||
@@ -686,16 +717,18 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual(
|
||||
|
||||
@@ -29,9 +29,11 @@ import {
|
||||
} from '@backstage/plugin-auth-backend';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
AuthorizeResponse,
|
||||
AuthorizeRequest,
|
||||
AuthorizeDecision,
|
||||
AuthorizeQuery,
|
||||
Identified,
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
ApplyConditionsRequestEntry,
|
||||
@@ -42,26 +44,28 @@ import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
import { memoize } from 'lodash';
|
||||
import DataLoader from 'dataloader';
|
||||
|
||||
const requestSchema: z.ZodSchema<Identified<AuthorizeRequest>[]> = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
resourceRef: z.string().optional(),
|
||||
permission: z.object({
|
||||
name: z.string(),
|
||||
resourceType: z.string().optional(),
|
||||
attributes: z.object({
|
||||
action: z
|
||||
.union([
|
||||
z.literal('create'),
|
||||
z.literal('read'),
|
||||
z.literal('update'),
|
||||
z.literal('delete'),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
const querySchema: z.ZodSchema<Identified<AuthorizeQuery>> = z.object({
|
||||
id: z.string(),
|
||||
resourceRef: z.string().optional(),
|
||||
permission: z.object({
|
||||
name: z.string(),
|
||||
resourceType: z.string().optional(),
|
||||
attributes: z.object({
|
||||
action: z
|
||||
.union([
|
||||
z.literal('create'),
|
||||
z.literal('read'),
|
||||
z.literal('update'),
|
||||
z.literal('delete'),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const requestSchema: z.ZodSchema<AuthorizeRequest> = z.object({
|
||||
items: z.array(querySchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* Options required when constructing a new {@link express#Router} using
|
||||
@@ -77,12 +81,12 @@ export interface RouterOptions {
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
requests: Identified<AuthorizeRequest>[],
|
||||
requests: Identified<AuthorizeQuery>[],
|
||||
user: BackstageIdentityResponse | undefined,
|
||||
policy: PermissionPolicy,
|
||||
permissionIntegrationClient: PermissionIntegrationClient,
|
||||
authHeader?: string,
|
||||
): Promise<Identified<AuthorizeResponse>[]> => {
|
||||
): Promise<Identified<AuthorizeDecision>[]> => {
|
||||
const applyConditionsLoaderFor = memoize((pluginId: string) => {
|
||||
return new DataLoader<
|
||||
ApplyConditionsRequestEntry,
|
||||
@@ -150,8 +154,8 @@ export async function createRouter(
|
||||
router.post(
|
||||
'/authorize',
|
||||
async (
|
||||
req: Request<Identified<AuthorizeRequest>[]>,
|
||||
res: Response<Identified<AuthorizeResponse>[]>,
|
||||
req: Request<AuthorizeRequest>,
|
||||
res: Response<AuthorizeResponse>,
|
||||
) => {
|
||||
const token = IdentityClient.getBearerToken(req.header('authorization'));
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
@@ -164,15 +168,15 @@ export async function createRouter(
|
||||
|
||||
const body = parseResult.data;
|
||||
|
||||
res.json(
|
||||
await handleRequest(
|
||||
body,
|
||||
res.json({
|
||||
items: await handleRequest(
|
||||
body.items,
|
||||
user,
|
||||
policy,
|
||||
permissionIntegrationClient,
|
||||
req.header('authorization'),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -6,25 +6,35 @@
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
// @public
|
||||
export type AuthorizeRequest = {
|
||||
export type AuthorizeDecision =
|
||||
| {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
}
|
||||
| {
|
||||
result: AuthorizeResult.CONDITIONAL;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthorizeQuery = {
|
||||
permission: Permission;
|
||||
resourceRef?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthorizeRequest = {
|
||||
items: Identified<AuthorizeQuery>[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthorizeRequestOptions = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthorizeResponse =
|
||||
| {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
}
|
||||
| {
|
||||
result: AuthorizeResult.CONDITIONAL;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
export type AuthorizeResponse = {
|
||||
items: Identified<AuthorizeDecision>[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export enum AuthorizeResult {
|
||||
@@ -71,18 +81,18 @@ export type PermissionAttributes = {
|
||||
export interface PermissionAuthorizer {
|
||||
// (undocumented)
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]>;
|
||||
): Promise<AuthorizeDecision[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class PermissionClient implements PermissionAuthorizer {
|
||||
constructor(options: { discovery: DiscoveryApi; config: Config });
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]>;
|
||||
): Promise<AuthorizeDecision[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RestContext, rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { PermissionClient } from './PermissionClient';
|
||||
import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api';
|
||||
import { AuthorizeQuery, AuthorizeResult, Identified } from './types/api';
|
||||
import { DiscoveryApi } from './types/discovery';
|
||||
import { Permission } from './types/permission';
|
||||
|
||||
@@ -42,7 +42,7 @@ const mockPermission: Permission = {
|
||||
resourceType: 'test-resource',
|
||||
};
|
||||
|
||||
const mockAuthorizeRequest = {
|
||||
const mockAuthorizeQuery = {
|
||||
permission: mockPermission,
|
||||
resourceRef: 'foo',
|
||||
};
|
||||
@@ -54,12 +54,12 @@ describe('PermissionClient', () => {
|
||||
|
||||
describe('authorize', () => {
|
||||
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
|
||||
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
|
||||
const responses = req.body.items.map((a: Identified<AuthorizeQuery>) => ({
|
||||
id: a.id,
|
||||
result: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
|
||||
return res(json(responses));
|
||||
return res(json({ items: responses }));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -71,38 +71,41 @@ describe('PermissionClient', () => {
|
||||
});
|
||||
|
||||
it('should fetch entities from correct endpoint', async () => {
|
||||
await client.authorize([mockAuthorizeRequest]);
|
||||
await client.authorize([mockAuthorizeQuery]);
|
||||
expect(mockAuthorizeHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include a request body', async () => {
|
||||
await client.authorize([mockAuthorizeRequest]);
|
||||
await client.authorize([mockAuthorizeQuery]);
|
||||
|
||||
const request = mockAuthorizeHandler.mock.calls[0][0];
|
||||
expect(request.body[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
permission: mockPermission,
|
||||
resourceRef: 'foo',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(request.body).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
permission: mockPermission,
|
||||
resourceRef: 'foo',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the response from the fetch request', async () => {
|
||||
const response = await client.authorize([mockAuthorizeRequest]);
|
||||
const response = await client.authorize([mockAuthorizeQuery]);
|
||||
expect(response[0]).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include authorization headers if no token is supplied', async () => {
|
||||
await client.authorize([mockAuthorizeRequest]);
|
||||
await client.authorize([mockAuthorizeQuery]);
|
||||
|
||||
const request = mockAuthorizeHandler.mock.calls[0][0];
|
||||
expect(request.headers.has('authorization')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should include correctly-constructed authorization header if token is supplied', async () => {
|
||||
await client.authorize([mockAuthorizeRequest], { token });
|
||||
await client.authorize([mockAuthorizeQuery], { token });
|
||||
|
||||
const request = mockAuthorizeHandler.mock.calls[0][0];
|
||||
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
|
||||
@@ -115,53 +118,59 @@ describe('PermissionClient', () => {
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.authorize([mockAuthorizeRequest], { token }),
|
||||
client.authorize([mockAuthorizeQuery], { token }),
|
||||
).rejects.toThrowError(/request failed with 401/i);
|
||||
});
|
||||
|
||||
it('should reject responses with missing ids', async () => {
|
||||
mockAuthorizeHandler.mockImplementationOnce(
|
||||
(_req, res, { json }: RestContext) => {
|
||||
return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }]));
|
||||
return res(
|
||||
json({
|
||||
items: [{ id: 'wrong-id', result: AuthorizeResult.ALLOW }],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.authorize([mockAuthorizeRequest], { token }),
|
||||
client.authorize([mockAuthorizeQuery], { token }),
|
||||
).rejects.toThrowError(/Unexpected authorization response/i);
|
||||
});
|
||||
|
||||
it('should reject invalid responses', async () => {
|
||||
mockAuthorizeHandler.mockImplementationOnce(
|
||||
(req, res, { json }: RestContext) => {
|
||||
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
|
||||
id: a.id,
|
||||
outcome: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
const responses = req.body.items.map(
|
||||
(a: Identified<AuthorizeQuery>) => ({
|
||||
id: a.id,
|
||||
outcome: AuthorizeResult.ALLOW,
|
||||
}),
|
||||
);
|
||||
|
||||
return res(json(responses));
|
||||
return res(json({ items: responses }));
|
||||
},
|
||||
);
|
||||
await expect(
|
||||
client.authorize([mockAuthorizeRequest], { token }),
|
||||
client.authorize([mockAuthorizeQuery], { token }),
|
||||
).rejects.toThrowError(/invalid input/i);
|
||||
});
|
||||
|
||||
it('should allow all when permission.enabled is false', async () => {
|
||||
mockAuthorizeHandler.mockImplementationOnce(
|
||||
(req, res, { json }: RestContext) => {
|
||||
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
|
||||
const responses = req.body.map((a: Identified<AuthorizeQuery>) => ({
|
||||
id: a.id,
|
||||
outcome: AuthorizeResult.DENY,
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
|
||||
return res(json(responses));
|
||||
return res(json({ items: responses }));
|
||||
},
|
||||
);
|
||||
const disabled = new PermissionClient({
|
||||
discovery,
|
||||
config: new ConfigReader({ permission: { enabled: false } }),
|
||||
});
|
||||
const response = await disabled.authorize([mockAuthorizeRequest]);
|
||||
const response = await disabled.authorize([mockAuthorizeQuery]);
|
||||
expect(response[0]).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
);
|
||||
@@ -171,7 +180,7 @@ describe('PermissionClient', () => {
|
||||
it('should allow all when permission.enabled is not configured', async () => {
|
||||
mockAuthorizeHandler.mockImplementationOnce(
|
||||
(req, res, { json }: RestContext) => {
|
||||
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
|
||||
const responses = req.body.map((a: Identified<AuthorizeQuery>) => ({
|
||||
id: a.id,
|
||||
outcome: AuthorizeResult.DENY,
|
||||
}));
|
||||
@@ -183,7 +192,7 @@ describe('PermissionClient', () => {
|
||||
discovery,
|
||||
config: new ConfigReader({}),
|
||||
});
|
||||
const response = await disabled.authorize([mockAuthorizeRequest]);
|
||||
const response = await disabled.authorize([mockAuthorizeQuery]);
|
||||
expect(response[0]).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
);
|
||||
|
||||
@@ -21,11 +21,13 @@ import * as uuid from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
AuthorizeQuery,
|
||||
AuthorizeDecision,
|
||||
Identified,
|
||||
PermissionCriteria,
|
||||
PermissionCondition,
|
||||
AuthorizeResponse,
|
||||
AuthorizeRequest,
|
||||
} from './types/api';
|
||||
import { DiscoveryApi } from './types/discovery';
|
||||
import {
|
||||
@@ -46,22 +48,24 @@ const permissionCriteriaSchema: z.ZodSchema<
|
||||
.or(z.object({ not: permissionCriteriaSchema })),
|
||||
);
|
||||
|
||||
const responseSchema = z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
result: z
|
||||
.literal(AuthorizeResult.ALLOW)
|
||||
.or(z.literal(AuthorizeResult.DENY)),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
const responseSchema = z.object({
|
||||
items: z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
result: z.literal(AuthorizeResult.CONDITIONAL),
|
||||
conditions: permissionCriteriaSchema,
|
||||
}),
|
||||
),
|
||||
);
|
||||
result: z
|
||||
.literal(AuthorizeResult.ALLOW)
|
||||
.or(z.literal(AuthorizeResult.DENY)),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
result: z.literal(AuthorizeResult.CONDITIONAL),
|
||||
conditions: permissionCriteriaSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* An isomorphic client for requesting authorization for Backstage permissions.
|
||||
@@ -94,29 +98,29 @@ export class PermissionClient implements PermissionAuthorizer {
|
||||
* @public
|
||||
*/
|
||||
async authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]> {
|
||||
): Promise<AuthorizeDecision[]> {
|
||||
// TODO(permissions): it would be great to provide some kind of typing guarantee that
|
||||
// conditional responses will only ever be returned for requests containing a resourceType
|
||||
// but no resourceRef. That way clients who aren't prepared to handle filtering according
|
||||
// to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response.
|
||||
|
||||
if (!this.enabled) {
|
||||
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
}
|
||||
|
||||
const identifiedRequests: Identified<AuthorizeRequest>[] = requests.map(
|
||||
request => ({
|
||||
const request: AuthorizeRequest = {
|
||||
items: queries.map(query => ({
|
||||
id: uuid.v4(),
|
||||
...request,
|
||||
}),
|
||||
);
|
||||
...query,
|
||||
})),
|
||||
};
|
||||
|
||||
const permissionApi = await this.discovery.getBaseUrl('permission');
|
||||
const response = await fetch(`${permissionApi}/authorize`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(identifiedRequests),
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
...this.getAuthorizationHeader(options?.token),
|
||||
'content-type': 'application/json',
|
||||
@@ -126,28 +130,30 @@ export class PermissionClient implements PermissionAuthorizer {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
const identifiedResponses = await response.json();
|
||||
this.assertValidResponses(identifiedRequests, identifiedResponses);
|
||||
const responseBody = await response.json();
|
||||
this.assertValidResponse(request, responseBody);
|
||||
|
||||
const responsesById = identifiedResponses.reduce((acc, r) => {
|
||||
const responsesById = responseBody.items.reduce((acc, r) => {
|
||||
acc[r.id] = r;
|
||||
return acc;
|
||||
}, {} as Record<string, Identified<AuthorizeResponse>>);
|
||||
}, {} as Record<string, Identified<AuthorizeDecision>>);
|
||||
|
||||
return identifiedRequests.map(request => responsesById[request.id]);
|
||||
return request.items.map(query => responsesById[query.id]);
|
||||
}
|
||||
|
||||
private getAuthorizationHeader(token?: string): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
private assertValidResponses(
|
||||
requests: Identified<AuthorizeRequest>[],
|
||||
private assertValidResponse(
|
||||
request: AuthorizeRequest,
|
||||
json: any,
|
||||
): asserts json is Identified<AuthorizeResponse>[] {
|
||||
): asserts json is AuthorizeResponse {
|
||||
const authorizedResponses = responseSchema.parse(json);
|
||||
const responseIds = authorizedResponses.map(r => r.id);
|
||||
const hasAllRequestIds = requests.every(r => responseIds.includes(r.id));
|
||||
const responseIds = authorizedResponses.items.map(r => r.id);
|
||||
const hasAllRequestIds = request.items.every(r =>
|
||||
responseIds.includes(r.id),
|
||||
);
|
||||
if (!hasAllRequestIds) {
|
||||
throw new Error(
|
||||
'Unexpected authorization response from permission-backend',
|
||||
|
||||
@@ -43,12 +43,20 @@ export enum AuthorizeResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* An authorization request for {@link PermissionClient#authorize}.
|
||||
* An individual authorization request for {@link PermissionClient#authorize}.
|
||||
* @public
|
||||
*/
|
||||
export type AuthorizeQuery = {
|
||||
permission: Permission;
|
||||
resourceRef?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A batch of authorization requests from {@link PermissionClient#authorize}.
|
||||
* @public
|
||||
*/
|
||||
export type AuthorizeRequest = {
|
||||
permission: Permission;
|
||||
resourceRef?: string;
|
||||
items: Identified<AuthorizeQuery>[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,12 +83,20 @@ export type PermissionCriteria<TQuery> =
|
||||
| TQuery;
|
||||
|
||||
/**
|
||||
* An authorization response from {@link PermissionClient#authorize}.
|
||||
* An individual authorization response from {@link PermissionClient#authorize}.
|
||||
* @public
|
||||
*/
|
||||
export type AuthorizeResponse =
|
||||
export type AuthorizeDecision =
|
||||
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
|
||||
| {
|
||||
result: AuthorizeResult.CONDITIONAL;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A batch of authorization responses from {@link PermissionClient#authorize}.
|
||||
* @public
|
||||
*/
|
||||
export type AuthorizeResponse = {
|
||||
items: Identified<AuthorizeDecision>[];
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
export { AuthorizeResult } from './api';
|
||||
export type {
|
||||
AuthorizeQuery,
|
||||
AuthorizeRequest,
|
||||
AuthorizeDecision,
|
||||
AuthorizeResponse,
|
||||
Identified,
|
||||
PermissionCondition,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AuthorizeRequest, AuthorizeResponse } from './api';
|
||||
import { AuthorizeQuery, AuthorizeDecision } from './api';
|
||||
|
||||
/**
|
||||
* The attributes related to a given permission; these should be generic and widely applicable to
|
||||
@@ -48,9 +48,9 @@ export type Permission = {
|
||||
*/
|
||||
export interface PermissionAuthorizer {
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]>;
|
||||
): Promise<AuthorizeDecision[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeDecision } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeQuery } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeResponse } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
|
||||
import { Config } from '@backstage/config';
|
||||
@@ -129,7 +129,7 @@ export const makeCreatePermissionRule: <TResource, TQuery>() => <
|
||||
export interface PermissionPolicy {
|
||||
// (undocumented)
|
||||
handle(
|
||||
request: PolicyAuthorizeRequest,
|
||||
request: PolicyAuthorizeQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision>;
|
||||
}
|
||||
@@ -147,7 +147,7 @@ export type PermissionRule<
|
||||
};
|
||||
|
||||
// @public
|
||||
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
|
||||
export type PolicyAuthorizeQuery = Omit<AuthorizeQuery, 'resourceRef'>;
|
||||
|
||||
// @public
|
||||
export type PolicyDecision =
|
||||
@@ -158,9 +158,9 @@ export type PolicyDecision =
|
||||
export class ServerPermissionClient implements PermissionAuthorizer {
|
||||
// (undocumented)
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]>;
|
||||
): Promise<AuthorizeDecision[]>;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ServerPermissionClient } from './ServerPermissionClient';
|
||||
import {
|
||||
Permission,
|
||||
Identified,
|
||||
AuthorizeRequest,
|
||||
AuthorizeQuery,
|
||||
AuthorizeResult,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
@@ -32,12 +32,12 @@ import { RestContext, rest } from 'msw';
|
||||
|
||||
const server = setupServer();
|
||||
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
|
||||
const responses = req.body.map((r: Identified<AuthorizeRequest>) => ({
|
||||
const responses = req.body.items.map((r: Identified<AuthorizeQuery>) => ({
|
||||
id: r.id,
|
||||
result: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
|
||||
return res(json(responses));
|
||||
return res(json({ items: responses }));
|
||||
});
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AuthorizeRequest,
|
||||
AuthorizeQuery,
|
||||
AuthorizeRequestOptions,
|
||||
AuthorizeResponse,
|
||||
AuthorizeDecision,
|
||||
AuthorizeResult,
|
||||
PermissionClient,
|
||||
PermissionAuthorizer,
|
||||
@@ -78,9 +78,9 @@ export class ServerPermissionClient implements PermissionAuthorizer {
|
||||
}
|
||||
|
||||
async authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
queries: AuthorizeQuery[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<AuthorizeResponse[]> {
|
||||
): Promise<AuthorizeDecision[]> {
|
||||
// Check if permissions are enabled before validating the server token. That
|
||||
// way when permissions are disabled, the noop token manager can be used
|
||||
// without fouling up the logic inside the ServerPermissionClient, because
|
||||
@@ -89,9 +89,9 @@ export class ServerPermissionClient implements PermissionAuthorizer {
|
||||
!this.permissionEnabled ||
|
||||
(await this.isValidServerToken(options?.token))
|
||||
) {
|
||||
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
|
||||
}
|
||||
return this.permissionClient.authorize(requests, options);
|
||||
return this.permissionClient.authorize(queries, options);
|
||||
}
|
||||
|
||||
private async isValidServerToken(
|
||||
|
||||
@@ -18,6 +18,6 @@ export type {
|
||||
ConditionalPolicyDecision,
|
||||
DefinitivePolicyDecision,
|
||||
PermissionPolicy,
|
||||
PolicyAuthorizeRequest,
|
||||
PolicyAuthorizeQuery,
|
||||
PolicyDecision,
|
||||
} from './types';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthorizeRequest,
|
||||
AuthorizeQuery,
|
||||
AuthorizeResult,
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
@@ -27,13 +27,13 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This differs from {@link @backstage/permission-common#AuthorizeRequest} in that `resourceRef`
|
||||
* This differs from {@link @backstage/permission-common#AuthorizeQuery} in that `resourceRef`
|
||||
* should never be provided. This forces policies to be written in a way that's compatible with
|
||||
* filtering collections of resources at data load time.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
|
||||
export type PolicyAuthorizeQuery = Omit<AuthorizeQuery, 'resourceRef'>;
|
||||
|
||||
/**
|
||||
* A definitive result to an authorization request, returned by the {@link PermissionPolicy}.
|
||||
@@ -57,7 +57,7 @@ export type DefinitivePolicyDecision = {
|
||||
* conditions hold when evaluated. The conditions will be evaluated by the corresponding plugin
|
||||
* which knows about the referenced permission rules.
|
||||
*
|
||||
* Similar to {@link @backstage/permission-common#AuthorizeResult}, but with the plugin and resource
|
||||
* Similar to {@link @backstage/permission-common#AuthorizeDecision}, but with the plugin and resource
|
||||
* identifiers needed to evaluate the returned conditions.
|
||||
* @public
|
||||
*/
|
||||
@@ -95,7 +95,7 @@ export type PolicyDecision =
|
||||
*/
|
||||
export interface PermissionPolicy {
|
||||
handle(
|
||||
request: PolicyAuthorizeRequest,
|
||||
request: PolicyAuthorizeQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision>;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { PermissionCriteria } from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
* A conditional rule that can be provided in an
|
||||
* {@link @backstage/permission-common#AuthorizeResult} response to an authorization request.
|
||||
* {@link @backstage/permission-common#AuthorizeDecision} response to an authorization request.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
```ts
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AuthorizeRequest } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeResponse } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeDecision } from '@backstage/plugin-permission-common';
|
||||
import { AuthorizeQuery } from '@backstage/plugin-permission-common';
|
||||
import { ComponentProps } from 'react';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
@@ -24,7 +24,7 @@ export type AsyncPermissionResult = {
|
||||
// @public
|
||||
export class IdentityPermissionApi implements PermissionApi {
|
||||
// (undocumented)
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
|
||||
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
|
||||
// (undocumented)
|
||||
static create(options: {
|
||||
config: Config;
|
||||
@@ -35,7 +35,7 @@ export class IdentityPermissionApi implements PermissionApi {
|
||||
|
||||
// @public
|
||||
export type PermissionApi = {
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
|
||||
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { PermissionApi } from './PermissionApi';
|
||||
import {
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
AuthorizeQuery,
|
||||
AuthorizeDecision,
|
||||
PermissionClient,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { Config } from '@backstage/config';
|
||||
@@ -44,7 +44,7 @@ export class IdentityPermissionApi implements PermissionApi {
|
||||
return new IdentityPermissionApi(permissionClient, identity);
|
||||
}
|
||||
|
||||
async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> {
|
||||
async authorize(request: AuthorizeQuery): Promise<AuthorizeDecision> {
|
||||
const response = await this.permissionClient.authorize(
|
||||
[request],
|
||||
await this.identityApi.getCredentials(),
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
AuthorizeQuery,
|
||||
AuthorizeDecision,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
|
||||
* @public
|
||||
*/
|
||||
export type PermissionApi = {
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
|
||||
authorize(request: AuthorizeQuery): Promise<AuthorizeDecision>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,7 @@ import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
card: {
|
||||
width: 400,
|
||||
maxWidth: 400,
|
||||
},
|
||||
header: {
|
||||
marginBottom: theme.spacing(1),
|
||||
|
||||
@@ -227,6 +227,13 @@ export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => {
|
||||
.md-typeset h1, .md-typeset h2, .md-typeset h3 { font-weight: bold; }
|
||||
.md-nav { font-size: 1rem; }
|
||||
.md-grid { max-width: 90vw; margin: 0 }
|
||||
.md-typeset blockquote {
|
||||
color: ${theme.palette.textSubtle};
|
||||
border-left: 0.2rem solid ${theme.palette.textVerySubtle};
|
||||
}
|
||||
.md-typeset hr {
|
||||
border-bottom: 0.05rem dotted ${theme.palette.textVerySubtle};
|
||||
}
|
||||
.md-typeset table:not([class]) {
|
||||
font-size: 1rem;
|
||||
border: 1px solid ${theme.palette.text.primary};
|
||||
@@ -353,6 +360,8 @@ export const useTechDocsReaderDom = (entityRef: EntityName): Element | null => {
|
||||
theme.palette.primary.main,
|
||||
theme.palette.success.main,
|
||||
theme.palette.text.primary,
|
||||
theme.palette.textSubtle,
|
||||
theme.palette.textVerySubtle,
|
||||
theme.typography.fontFamily,
|
||||
isPinned,
|
||||
],
|
||||
|
||||
@@ -13,17 +13,21 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { InfoCard, SidebarPinStateContext } from '@backstage/core-components';
|
||||
import { List } from '@material-ui/core';
|
||||
import { InfoCard } from '@backstage/core-components';
|
||||
import React, { useContext } from 'react';
|
||||
import { UserSettingsPinToggle } from './UserSettingsPinToggle';
|
||||
import { UserSettingsThemeToggle } from './UserSettingsThemeToggle';
|
||||
|
||||
export const UserSettingsAppearanceCard = () => (
|
||||
<InfoCard title="Appearance" variant="gridItem">
|
||||
<List dense>
|
||||
<UserSettingsThemeToggle />
|
||||
<UserSettingsPinToggle />
|
||||
</List>
|
||||
</InfoCard>
|
||||
);
|
||||
export const UserSettingsAppearanceCard = () => {
|
||||
const { isMobile } = useContext(SidebarPinStateContext);
|
||||
|
||||
return (
|
||||
<InfoCard title="Appearance" variant="gridItem">
|
||||
<List dense>
|
||||
<UserSettingsThemeToggle />
|
||||
{!isMobile && <UserSettingsPinToggle />}
|
||||
</List>
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,10 +21,10 @@ import { UserSettingsAppearanceCard } from './UserSettingsAppearanceCard';
|
||||
export const UserSettingsGeneral = () => {
|
||||
return (
|
||||
<Grid container direction="row" spacing={3}>
|
||||
<Grid item sm={12} md={6}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UserSettingsProfileCard />
|
||||
</Grid>
|
||||
<Grid item sm={12} md={6}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UserSettingsAppearanceCard />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -26,7 +26,11 @@ describe('<UserSettingsPinToggle />', () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<SidebarPinStateContext.Provider
|
||||
value={{ isPinned: false, toggleSidebarPinState: mockToggleFn }}
|
||||
value={{
|
||||
isPinned: false,
|
||||
isMobile: false,
|
||||
toggleSidebarPinState: mockToggleFn,
|
||||
}}
|
||||
>
|
||||
<UserSettingsPinToggle />
|
||||
</SidebarPinStateContext.Provider>,
|
||||
|
||||
@@ -14,21 +14,27 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
SidebarPinStateContext,
|
||||
TabbedLayout,
|
||||
} from '@backstage/core-components';
|
||||
import React, { useContext } from 'react';
|
||||
import { UserSettingsAuthProviders } from './AuthProviders';
|
||||
import { UserSettingsFeatureFlags } from './FeatureFlags';
|
||||
import { UserSettingsGeneral } from './General';
|
||||
import { Header, Page, TabbedLayout } from '@backstage/core-components';
|
||||
|
||||
type Props = {
|
||||
providerSettings?: JSX.Element;
|
||||
};
|
||||
|
||||
export const SettingsPage = ({ providerSettings }: Props) => {
|
||||
const { isMobile } = useContext(SidebarPinStateContext);
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
<Header title="Settings" />
|
||||
|
||||
{!isMobile && <Header title="Settings" />}
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="general" title="General">
|
||||
<UserSettingsGeneral />
|
||||
|
||||
Reference in New Issue
Block a user