Commit 076430bc authored by Illya Klymov's avatar Illya Klymov

List identities on group SAML SSO page

Introduces base table, listing members of group
which use SAML SSO on settings page
parent 9ff2ee0c
......@@ -54,10 +54,15 @@ const Api = {
});
},
groupMembers(id) {
groupMembers(id, options) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
return axios.get(url, {
params: {
per_page: DEFAULT_PER_PAGE,
...options,
},
});
},
// Return groups list. Filtered by query
......
import Vue from 'vue';
import initSAML from './shared/init_saml';
import MembersApp from './saml_members/index.vue';
import createStore from './saml_members/store';
document.addEventListener('DOMContentLoaded', initSAML);
function initMembers(el) {
const { groupId } = el.dataset;
const store = createStore({
groupId: Number(groupId),
});
// eslint-disable-next-line no-new
new Vue({
el,
store,
render(createElement) {
return createElement(MembersApp);
},
});
}
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('.js-saml-members');
initMembers(el);
initSAML();
});
<script>
import { GlSkeletonLoading, GlTable, GlAvatar } from '@gitlab/ui';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
export default {
components: {
GlSkeletonLoading,
GlTable,
GlAvatar,
TablePagination,
},
computed: {
...mapState(['isInitialLoadInProgress', 'members', 'pageInfo']),
},
fields: [
{
key: 'name',
label: __('User'),
},
{
key: 'identity',
label: s__('GroupSAML|Identity'),
},
],
mounted() {
this.fetchPage();
},
methods: {
...mapActions(['fetchPage']),
change(nextPage) {
this.fetchPage(nextPage);
},
},
};
</script>
<template>
<div class="prepend-top-default">
<gl-skeleton-loading v-if="isInitialLoadInProgress" />
<gl-table v-else :items="members" :fields="$options.fields">
<template #name="{ item }">
<span class="d-flex">
<gl-avatar v-gl-tooltip :src="item.avatar_url" :size="48" />
<div class="ml-2">
<div class="font-weight-bold">
<a
class="js-user-link"
:href="item.web_url"
:data-user-id="item.id"
:data-username="item.username"
>
{{ item.name }}
</a>
</div>
<div class="cgray">@{{ item.username }}</div>
</div>
</span>
</template>
<template #identity="{ value }">
<span class="font-weight-bold">{{ value }}</span>
</template>
</gl-table>
<table-pagination :page-info="pageInfo" :change="change" />
</div>
</template>
import { __ } from '~/locale';
import Api from '~/api';
import createFlash from '~/flash';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export function fetchPage({ commit, state }, newPage) {
return Api.groupMembers(state.groupId, {
with_saml_identity: 'true',
page: newPage || state.pageInfo.page,
per_page: state.pageInfo.perPage,
})
.then(response => {
const { headers, data } = response;
const pageInfo = parseIntPagination(normalizeHeaders(headers));
commit(types.RECEIVE_SAML_MEMBERS_SUCCESS, {
members: data.map(({ group_saml_identity: identity, ...item }) => ({
...item,
identity: identity ? identity.extern_uid : null,
})),
pageInfo,
});
})
.catch(() => {
createFlash(__('An error occurred while loading group members.'));
});
}
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default initialState =>
new Vuex.Store({
actions,
mutations,
state: {
...state(),
...initialState,
},
});
// eslint-disable-next-line import/prefer-default-export
export const RECEIVE_SAML_MEMBERS_SUCCESS = 'RECEIVE_SAML_MEMBERS_SUCCESS';
import * as types from './mutation_types';
export default {
[types.RECEIVE_SAML_MEMBERS_SUCCESS](state, { members, pageInfo }) {
Object.assign(state, {
isInitialLoadInProgress: false,
members,
pageInfo,
});
},
};
export default () => ({
isInitialLoadInProgress: true,
groupId: null,
pageInfo: {
perPage: 10,
page: 1,
total: 20,
totalPages: 1,
nextPage: 0,
previousPage: 0,
},
members: [],
});
......@@ -30,3 +30,13 @@
= s_('GroupSAML|SCIM Token')
.col-lg-9
= render 'scim_token'
%section.row.border-top.mt-4
.col-lg-3.append-bottom-default
%h4.page-title
= s_('GroupSAML|Members')
.col-lg-9
.js-saml-members.prepend-top-default{ data: { group_id: @group.id } }
.animation-container
.skeleton-line-1
.skeleton-line-2
.skeleton-line-3
---
title: members list to group sso page
merge_request: 21852
author:
type: added
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlTable, GlSkeletonLoading } from '@gitlab/ui';
import MembersApp from 'ee/pages/groups/saml_providers/saml_members/index.vue';
import createInitialState from 'ee/pages/groups/saml_providers/saml_members/store/state';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SAML providers members app', () => {
let wrapper;
let fetchPageMock;
const createWrapper = (state = {}) => {
const store = new Vuex.Store({
state: {
...createInitialState(),
...state,
},
actions: {
fetchPage: fetchPageMock,
},
});
wrapper = shallowMount(MembersApp, {
store,
localVue,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
beforeEach(() => {
fetchPageMock = jest.fn();
});
describe('on mount', () => {
beforeEach(() => {
createWrapper();
});
it('dispatches loadPage', () => {
expect(fetchPageMock).toHaveBeenCalled();
});
it('renders loader', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
});
});
describe('when loaded', () => {
beforeEach(() => {
createWrapper({
isInitialLoadInProgress: false,
});
});
it('does not render loader', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
});
it('renders table', () => {
expect(wrapper.find(GlTable).exists()).toBe(true);
});
it('requests next page when pagination component performs change', () => {
const changeFn = wrapper.find(TablePagination).props('change');
changeFn(2);
return wrapper.vm.$nextTick(() => {
expect(fetchPageMock).toHaveBeenCalledWith(expect.anything(), 2, undefined);
});
});
});
});
import testAction from 'helpers/vuex_action_helper';
import * as types from 'ee/pages/groups/saml_providers/saml_members/store/mutation_types';
import { fetchPage } from 'ee/pages/groups/saml_providers/saml_members/store/actions';
import createInitialState from 'ee/pages/groups/saml_providers/saml_members/store/state';
import Api from '~/api';
import flash from '~/flash';
jest.mock('~/flash');
jest.mock('~/api', () => ({
groupMembers: jest.fn(),
}));
const state = {
...createInitialState(),
groupId: 1,
};
describe('saml_members actions', () => {
afterEach(() => {
Api.groupMembers.mockClear();
flash.mockClear();
});
describe('fetchPage', () => {
it('should commit RECEIVE_SAML_MEMBERS_SUCCESS mutation on correct data', done => {
const members = [
{ id: 1, name: 'user 1', group_saml_identity: null },
{ id: 2, name: 'user 2', group_saml_identity: { extern_uid: 'a' } },
];
const expectedMembers = [
{ id: 1, name: 'user 1', identity: null },
{ id: 2, name: 'user 2', identity: 'a' },
];
Api.groupMembers.mockReturnValue(
Promise.resolve({
headers: {
'x-per-page': '10',
'x-page': '2',
'x-total': '30',
'x-total-pages': '3',
'x-next-page': '3',
'x-prev-page': '1',
},
data: members,
}),
);
const expectedPageInfo = {
perPage: 10,
page: 2,
total: 30,
totalPages: 3,
nextPage: 3,
previousPage: 1,
};
testAction(
fetchPage,
undefined,
state,
[
{
type: types.RECEIVE_SAML_MEMBERS_SUCCESS,
payload: { members: expectedMembers, pageInfo: expectedPageInfo },
},
],
[],
done,
);
});
it('should show flash on wrong data', done => {
Api.groupMembers.mockReturnValue(Promise.reject(new Error()));
testAction(fetchPage, undefined, state, [], [], () => {
expect(flash).toHaveBeenCalledTimes(1);
done();
});
});
});
});
import * as types from 'ee/pages/groups/saml_providers/saml_members/store/mutation_types';
import mutations from 'ee/pages/groups/saml_providers/saml_members/store/mutations';
describe('saml_members mutations', () => {
describe(types.RECEIVE_SAML_MEMBERS_SUCCESS, () => {
it('clears isInitialLoadInProgress', () => {
const state = {};
mutations[types.RECEIVE_SAML_MEMBERS_SUCCESS](state, {});
expect(state.isInitialLoadInProgress).toBe(false);
});
it('sets provided members and pageInfo', () => {
const state = {};
const members = ['one', 'two'];
const pageInfo = { dummy: 'pageInfo' };
mutations[types.RECEIVE_SAML_MEMBERS_SUCCESS](state, { members, pageInfo });
expect(state.members).toBe(members);
expect(state.pageInfo).toBe(pageInfo);
});
});
});
......@@ -1754,6 +1754,9 @@ msgstr ""
msgid "An error occurred while loading filenames"
msgstr ""
msgid "An error occurred while loading group members."
msgstr ""
msgid "An error occurred while loading issues"
msgstr ""
......@@ -9306,6 +9309,9 @@ msgstr ""
msgid "GroupSAML|Generate a SCIM token to set up your System for Cross-Domain Identity Management."
msgstr ""
msgid "GroupSAML|Identity"
msgstr ""
msgid "GroupSAML|Identity provider single sign on URL"
msgstr ""
......@@ -9315,6 +9321,9 @@ msgstr ""
msgid "GroupSAML|Manage your group’s membership while adding another level of security with SAML."
msgstr ""
msgid "GroupSAML|Members"
msgstr ""
msgid "GroupSAML|Members will be forwarded here when signing in to your group. Get this from your identity provider, where it can also be called \"SSO Service Location\", \"SAML Token Issuance Endpoint\", or \"SAML 2.0/W-Federation URL\"."
msgstr ""
......@@ -20152,6 +20161,9 @@ msgstr ""
msgid "Used to help configure your identity provider"
msgstr ""
msgid "User"
msgstr ""
msgid "User %{current_user_username} has started impersonating %{username}"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment