Compare commits

...

1 Commits

Author SHA1 Message Date
LP B
64b91d5258 feat(app): introduce help and support view
feat(app): help and support secondary view layout proposal

feat(app): redirect to help page on first login only

refactor(app): make UI layout more in line with mockups

feat(app/help): redirect to the appropriate view on help exit
2021-07-27 19:49:37 +02:00
21 changed files with 316 additions and 37 deletions

View File

@@ -139,6 +139,7 @@ func (service *Service) CreateUser(user *portainer.User) error {
id, _ := bucket.NextSequence()
user.ID = portainer.UserID(id)
user.Username = strings.ToLower(user.Username)
user.HasAuthenticatedOnce = false
data, err := internal.MarshalObject(user)
if err != nil {

View File

@@ -131,7 +131,16 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
}
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
return handler.persistAndWriteToken(w, composeTokenData(user))
tokenData := composeTokenData(user)
user.HasAuthenticatedOnce = true
err := handler.DataStore.User().UpdateUser(user.ID, user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
}
return handler.persistAndWriteToken(w, tokenData)
}
func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portainer.User, expiryTime *time.Time) *httperror.HandlerError {
@@ -139,6 +148,14 @@ func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portaine
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
}
user.HasAuthenticatedOnce = true
err = handler.DataStore.User().UpdateUser(user.ID, user)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
}
return response.JSON(w, &authenticateResponse{JWT: token})
}
@@ -210,8 +227,9 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
func composeTokenData(user *portainer.User) *portainer.TokenData {
return &portainer.TokenData{
ID: user.ID,
Username: user.Username,
Role: user.Role,
ID: user.ID,
Username: user.Username,
Role: user.Role,
HasAuthenticatedOnce: user.HasAuthenticatedOnce,
}
}

View File

@@ -19,9 +19,10 @@ type Service struct {
}
type claims struct {
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
UserID int `json:"id"`
Username string `json:"username"`
Role int `json:"role"`
HasAuthenticatedOnce bool `json:"hasAuthenticatedOnce"`
jwt.StandardClaims
}
@@ -94,9 +95,10 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTim
expireToken = expiryTime.Unix()
}
cl := claims{
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
UserID: int(data.ID),
Username: data.Username,
Role: int(data.Role),
HasAuthenticatedOnce: bool(data.HasAuthenticatedOnce),
StandardClaims: jwt.StandardClaims{
ExpiresAt: expireToken,
},

View File

@@ -14,9 +14,10 @@ func TestGenerateSignedToken(t *testing.T) {
assert.NoError(t, err, "failed to create a copy of service")
token := &portainer.TokenData{
Username: "Joe",
ID: 1,
Role: 1,
Username: "Joe",
ID: 1,
Role: 1,
HasAuthenticatedOnce: true,
}
expirtationTime := time.Now().Add(1 * time.Hour)
@@ -34,5 +35,6 @@ func TestGenerateSignedToken(t *testing.T) {
assert.Equal(t, token.Username, tokenClaims.Username)
assert.Equal(t, int(token.ID), tokenClaims.UserID)
assert.Equal(t, int(token.Role), tokenClaims.Role)
assert.Equal(t, bool(token.HasAuthenticatedOnce), tokenClaims.HasAuthenticatedOnce)
assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt)
}

View File

@@ -927,9 +927,10 @@ type (
// TokenData represents the data embedded in a JWT token
TokenData struct {
ID UserID
Username string
Role UserRole
ID UserID
Username string
Role UserRole
HasAuthenticatedOnce bool
}
// TunnelDetails represents information associated to a tunnel
@@ -953,7 +954,8 @@ type (
Username string `json:"Username" example:"bob"`
Password string `json:"Password,omitempty" example:"passwd"`
// User role (1 for administrator account and 2 for regular account)
Role UserRole `json:"Role" example:"1"`
Role UserRole `json:"Role" example:"1"`
HasAuthenticatedOnce bool `json:"HasAuthenticatedOnce" example:"true"`
// Deprecated fields
// Deprecated in DBVersion == 25

View File

@@ -93,11 +93,6 @@ a[ng-click] {
margin-left: 5px;
}
.portainer-disabled-link {
color: gray;
pointer-events: none;
}
.portainer-disabled-datatable {
color: gray;
}
@@ -126,6 +121,10 @@ a[ng-click] {
font-size: 1.3em;
}
.color-portainer-black {
color: #333333;
}
.fa.green-icon {
color: #23ae89;
}
@@ -151,6 +150,10 @@ a[ng-click] {
color: #f0ad4e;
}
.text-blue {
color: #337ab7;
}
.widget .widget-body table tbody .image-tag {
font-size: 90% !important;
margin-right: 5px;

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -163,6 +163,20 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([
},
};
const helpAndSupport = {
name: 'portainer.help',
url: '/help',
views: {
'content@': {
component: 'helpAndSupportView',
},
},
params: {
firstLogin: false,
initFirstEndpoint: false,
},
};
var endpointCreation = {
name: 'portainer.endpoints.new',
url: '/new',
@@ -407,6 +421,7 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([
$stateRegistryProvider.register(group);
$stateRegistryProvider.register(groupAccess);
$stateRegistryProvider.register(groupCreation);
$stateRegistryProvider.register(helpAndSupport);
$stateRegistryProvider.register(home);
$stateRegistryProvider.register(init);
$stateRegistryProvider.register(initEndpoint);

View File

@@ -88,6 +88,7 @@ angular.module('portainer.app').factory('Authentication', [
user.username = tokenPayload.username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
user.firstLogin = !(tokenPayload.hasAuthenticatedOnce ? tokenPayload.hasAuthenticatedOnce : false);
}
function isAdmin() {

View File

@@ -120,8 +120,13 @@ class AuthenticationController {
try {
const endpoints = await this.EndpointService.endpoints(0, 1);
const isAdmin = this.Authentication.isAdmin();
const firstLogin = this.Authentication.getUserDetails().firstLogin;
if (endpoints.value.length === 0 && isAdmin) {
const shouldInitFirstEndpoint = isAdmin && endpoints.value.length === 0;
if (firstLogin) {
return this.$state.go('portainer.help', { firstLogin: true, initFirstEndpoint: shouldInitFirstEndpoint });
} else if (shouldInitFirstEndpoint) {
return this.$state.go('portainer.init.endpoint');
} else {
return this.$state.go('portainer.home');
@@ -150,8 +155,8 @@ class AuthenticationController {
async postLoginSteps() {
await this.StateManager.initialize();
await this.checkForEndpointsAsync();
await this.checkForLatestVersionAsync();
await this.checkForEndpointsAsync();
}
/**
* END POST LOGIN STEPS SECTION

View File

@@ -0,0 +1,14 @@
import angular from 'angular';
import './por-help-image-link.css';
export const helpImageLink = {
templateUrl: './por-help-image-link.html',
bindings: {
data: '<',
copyLink: '<',
onLinkClicked: '<',
},
};
angular.module('portainer.app').component('porHelpImageLink', helpImageLink);

View File

@@ -0,0 +1,21 @@
.portainer-image-link {
position: relative;
padding: 0;
}
.portainer-image-link img {
display: block;
max-width: 100%;
box-shadow: 0 2px 4px 0 rgba(34, 36, 38, 0.12), 0 2px 10px 0 rgba(34, 36, 38, 0.15);
}
.portainer-image-link i {
position: absolute;
bottom: 1%;
right: 1%;
}
.portainer-disabled-link {
color: gray;
pointer-events: none;
}

View File

@@ -0,0 +1,17 @@
<div class="col-lg-2 col-md-3 col-sm-4 col-xs-6" style="text-align: center;">
<div class="portainer-image-link">
<a id="{{ $ctrl.data.linkId }}" ng-href="{{ $ctrl.data.redirectUrl }}" target="_blank" ng-click="$ctrl.onLinkClicked($ctrl.data.matomoAction)">
<img ng-src="{{ $ctrl.data.image }}" alt="{{ $ctrl.data.imageAltText }}" />
<i class="fa fa-external-link-alt color-portainer-black"></i>
</a>
</div>
<div style="margin-top: 10px;">
<label>{{ $ctrl.data.description }}</label>
<div>
<span id="copy{{ $ctrl.data.linkId }}" class="btn btn-sm text-blue" ng-click="$ctrl.copyLink($ctrl.data.redirectUrl, $ctrl.data.linkId)">
<i class="fa fa-copy space-right" aria-hidden="true"></i>Copy link</span
>
<span id="copy{{ $ctrl.data.linkId }}Notification" style="margin-left: 7px; display: none; color: #23ae89;"> <i class="fa fa-check" aria-hidden="true"></i> copied </span>
</div>
</div>
</div>

View File

@@ -0,0 +1,101 @@
const MatomoCategories = Object.freeze({
ADMIN: 'Admin',
STANDARD: 'Non-admin',
GETTING_STARTED: 'Getting Started Page',
});
// TO REMOVE
void MatomoCategories;
const MatomoActions = Object.freeze({
ADMIN: 'admin',
ENGINEER: 'platform',
DEVELOPER: 'dev',
YOUTUBE: 'portainertube',
ENTER: 'getting-started-help&support',
SKIP: 'getting-started-skip',
DONE: 'getting-started-done',
});
const links = {
admin: {
linkId: 'admin',
image: require('@/assets/images/help-links/admin.png'),
imageAltText: 'Portainer admin setup',
redirectUrl: 'https://portainer.io/inapp/ceadmin',
description: 'Admin - Setting up Portainer',
matomoAction: MatomoActions.ADMIN,
},
engineer: {
linkId: 'engineer',
image: require('@/assets/images/help-links/platform_engineer.png'),
imageAltText: 'Platform engineer',
redirectUrl: 'https://portainer.io/inapp/ceplatform',
description: 'A Platform Engineer Experience',
matomoAction: MatomoActions.ENGINEER,
},
developer: {
linkId: 'developer',
image: require('@/assets/images/help-links/developer.png'),
imageAltText: 'Developer',
redirectUrl: 'https://portainer.io/inapp/cedev',
description: 'A Developer Experience',
matomoAction: MatomoActions.DEVELOPER,
},
youtube: {
linkId: 'youtube',
image: require('@/assets/images/help-links/youtube.png'),
imageAltText: 'Youtube',
redirectUrl: 'https://www.youtube.com/c/portainerio',
description: 'Portainer.io Youtube Channel',
matomoAction: MatomoActions.YOUTUBE,
},
};
class HelpAndSupportController {
/* @ngInject */
constructor($async, $state, clipboard, Authentication) {
this.$async = $async;
this.$state = $state;
this.clipboard = clipboard;
this.Authentication = Authentication;
this.copyLink = this.copyLink.bind(this);
this.onLinkClicked = this.onLinkClicked.bind(this);
this.links = links;
this.state = {
firstLogin: false,
linkClicked: false,
};
}
copyLink(link, linkId) {
this.clipboard.copyText(link);
$(`#copy${linkId}Notification`).show().fadeOut(2500);
}
onLinkClicked(matomoAction) {
void matomoAction;
this.state.linkClicked = true;
// send matomo event
}
closeHelpView() {
// send matomo event
const initFirstEndpoint = this.$transition$.params().initFirstEndpoint;
if (this.state.firstLogin && this.isAdmin && initFirstEndpoint) {
return this.$state.go('portainer.init.endpoint');
}
return this.$state.go('portainer.home');
}
$onInit() {
this.isAdmin = this.Authentication.isAdmin();
this.state.firstLogin = this.$transition$.params().firstLogin;
// send matomo event
}
}
export default HelpAndSupportController;

View File

@@ -0,0 +1,53 @@
<rd-header>
<rd-header-title title-text="Help & Support"></rd-header-title>
<rd-header-content>Video walkthroughs</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.form">
<h2 class="col-sm-12 form-section-title color-portainer-black">
Getting started
</h2>
<div class="form-group">
<span class="col-sm-12 text-muted">
Select an area of interest for a video walkthrough
</span>
</div>
<h4 class="col-sm-12 form-section-title color-portainer-black" ng-if="$ctrl.isAdmin">
Setting up Portainer
</h4>
<div class="form-group" ng-if="$ctrl.isAdmin">
<por-help-image-link data="$ctrl.links.admin" copy-link="$ctrl.copyLink" on-link-clicked="($ctrl.onLinkClicked)"> </por-help-image-link>
</div>
<h4 class="col-sm-12 form-section-title color-portainer-black">
Using Portainer
</h4>
<div class="form-group">
<por-help-image-link data="$ctrl.links.engineer" copy-link="$ctrl.copyLink" on-link-clicked="($ctrl.onLinkClicked)"> </por-help-image-link>
<por-help-image-link data="$ctrl.links.developer" copy-link="$ctrl.copyLink" on-link-clicked="($ctrl.onLinkClicked)"> </por-help-image-link>
</div>
<h4 class="col-sm-12 form-section-title color-portainer-black">
Learn more...
</h4>
<div class="form-group">
<por-help-image-link data="$ctrl.links.youtube" copy-link="$ctrl.copyLink" on-link-clicked="($ctrl.onLinkClicked)"> </por-help-image-link>
</div>
<div class="form-group" ng-if="$ctrl.state.firstLogin">
<div class="col-lg-12 col-md-12 col-xs-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.closeHelpView()">
<span>{{ $ctrl.state.linkClicked ? 'Done' : 'Skip' }}</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,12 @@
import angular from 'angular';
import controller from './help-and-support.controller';
export const helpAndSupport = {
templateUrl: './help-and-support.html',
controller,
bindings: {
$transition$: '<',
},
};
angular.module('portainer.app').component('helpAndSupportView', helpAndSupport);

View File

@@ -49,10 +49,17 @@ angular.module('portainer.app').controller('InitAdminController', [
return EndpointService.endpoints(0, 100);
})
.then(function success(data) {
if (data.value.length === 0) {
$state.go('portainer.init.endpoint');
const firstLogin = Authentication.getUserDetails().firstLogin;
const initFirstEndpoint = data.value.length === 0;
if (firstLogin) {
return $state.go('portainer.help', { firstLogin: true, initFirstEndpoint: initFirstEndpoint });
}
if (initFirstEndpoint) {
return $state.go('portainer.init.endpoint');
} else {
$state.go('portainer.home');
return $state.go('portainer.home');
}
})
.catch(function error(err) {

View File

@@ -194,16 +194,21 @@
</div>
</li>
</ul>
<div class="sidebar-footer-content">
<div class="update-notification" ng-if="applicationState.application.versionStatus.UpdateAvailable">
<a target="_blank" href="https://github.com/portainer/portainer/releases/tag/{{ applicationState.application.versionStatus.LatestVersion }}" style="color: #091e5d;">
<i class="fa-lg fas fa-cloud-download-alt" style="margin-right: 2px;"></i> A new version is available
</a>
</div>
<div>
<img src="~@/assets/images/logo_small.png" class="img-responsive logo" alt="Portainer" />
<span class="version">{{ uiVersion }}</span>
</div>
</div>
<div class="sidebar-footer-content">
<ul class="sidebar">
<li class="sidebar-list">
<a ui-sref="portainer.help" ui-sref-active="active" style="text-indent: 0px;">Help & support</a>
</li>
</ul>
<div class="update-notification" ng-if="applicationState.application.versionStatus.UpdateAvailable">
<a target="_blank" href="https://github.com/portainer/portainer/releases/tag/{{ applicationState.application.versionStatus.LatestVersion }}" style="color: #091e5d;">
<i class="fa-lg fas fa-cloud-download-alt" style="margin-right: 2px;"></i> A new version is available
</a>
</div>
<div>
<img src="~@/assets/images/logo_small.png" class="img-responsive logo" alt="Portainer" />
<span class="version">{{ uiVersion }}</span>
</div>
</div>
</div>