fix: idps (#777)

* fix: update client secret, skip passwordsteps only if login not if linking

* fix: global policy for register

* fix: scope handling

* fix: back after error

* fix: change org id scope to primary domain

* fix: check if primarydomain empty

* fix: local sh

* fix: disable buttons on org login policy
This commit is contained in:
Fabi 2020-09-28 09:29:41 +02:00 committed by GitHub
parent 3e1204524e
commit 83b0ac1fdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 196 additions and 55 deletions

View File

@ -2,18 +2,19 @@
[timestamp]="idpResult?.viewTimestamp" [selection]="selection">
<ng-template appHasRole [appHasRole]="['iam.write']" actions>
<button (click)="deactivateSelectedIdps()" matTooltip="{{'IDP.DEACTIVATE' | translate}}" class="icon-button"
mat-icon-button *ngIf="selection.hasValue()" [disabled]="disabled">
mat-icon-button *ngIf="selection.hasValue() && serviceType!=PolicyComponentServiceType.MGMT" [disabled]="disabled">
<mat-icon svgIcon="mdi_account_cancel"></mat-icon>
</button>
<button (click)="reactivateSelectedIdps()" matTooltip="{{'IDP.ACTIVATE' | translate}}" class="icon-button"
mat-icon-button *ngIf="selection.hasValue()" [disabled]="disabled">
mat-icon-button *ngIf="selection.hasValue() && serviceType!=PolicyComponentServiceType.MGMT" [disabled]="disabled">
<mat-icon svgIcon="mdi_account_check_outline"></mat-icon>
</button>
<button color="warn" (click)="removeSelectedIdps()" matTooltip="{{'IDP.DELETE' | translate}}"
class="icon-button" mat-icon-button *ngIf="selection.hasValue()" [disabled]="disabled">
class="icon-button" mat-icon-button *ngIf="selection.hasValue() && serviceType!=PolicyComponentServiceType.MGMT" [disabled]="disabled">
<i class="las la-trash"></i>
</button>
<a class="add-button" [routerLink]="createRouterLink" color="primary" mat-raised-button [disabled]="disabled">
<a class="add-button" [routerLink]="createRouterLink" color="primary" mat-raised-button [disabled]="disabled"
*ngIf="serviceType!=PolicyComponentServiceType.MGMT">
<mat-icon class="icon">add</mat-icon>{{ 'ACTIONS.NEW' | translate }}
</a>
</ng-template>

View File

@ -1,10 +1,10 @@
<app-detail-layout [backRouterLink]="backroutes" [title]="'ORG.POLICY.LOGIN_POLICY.TITLECREATE' | translate"
[description]="'ORG.POLICY.LOGIN_POLICY.DESCRIPTIONCREATE' | translate">
<ng-container *ngIf="(['policy.delete'] | hasRole | async) && serviceType == PolicyComponentServiceType.MGMT">
<button matTooltip="{{'ORG.POLICY.DELETE' | translate}}" color="warn" (click)="deletePolicy()"
<!--<button matTooltip="{{'ORG.POLICY.DELETE' | translate}}" color="warn" (click)="deletePolicy()"
mat-stroked-button>
{{'ORG.POLICY.DELETE' | translate}}
</button>
</button>-->
</ng-container>
<div class="content" *ngIf="loginData">
@ -12,19 +12,20 @@
<span class="left-desc">{{'ORG.POLICY.DATA.ALLOWUSERNAMEPASSWORD' | translate}}</span>
<span class="fill-space"></span>
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl
[(ngModel)]="loginData.allowUsernamePassword">
[(ngModel)]="loginData.allowUsernamePassword" [disabled]="serviceType==PolicyComponentServiceType.MGMT">
</mat-slide-toggle>
</div>
<div class="row">
<span class="left-desc">{{'ORG.POLICY.DATA.ALLOWREGISTER' | translate}}</span>
<span class="fill-space"></span>
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl [(ngModel)]="loginData.allowRegister">
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl [(ngModel)]="loginData.allowRegister"
[disabled]="serviceType==PolicyComponentServiceType.MGMT">
</mat-slide-toggle>
</div>
<div class="row">
<span class="left-desc">{{'ORG.POLICY.DATA.ALLOWEXTERNALIDP' | translate}}</span>
<span class="fill-space"></span>
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl
<mat-slide-toggle color="primary" name="hasNumber" ngDefaultControl [disabled]="serviceType==PolicyComponentServiceType.MGMT"
[(ngModel)]="loginData.allowExternalIdp">
</mat-slide-toggle>
</div>
@ -34,18 +35,18 @@
<div class="idps">
<div class="idp" *ngFor="let idp of idps">
<mat-icon (click)="removeIdp(idp)" class="rm">remove_circle</mat-icon>
<mat-icon *ngIf="serviceType!=PolicyComponentServiceType.MGMT" (click)="removeIdp(idp)" class="rm">remove_circle</mat-icon>
<span>{{idp.name}}</span>
<span class="meta">{{ 'IDP.TYPE' | translate }}: {{ 'IDP.TYPES.'+idp.type | translate }}</span>
<span class="meta">{{ 'IDP.ID' | translate }}: {{idp.idpConfigId}}</span>
</div>
<div class="new-idp" (click)="openDialog()">
<div *ngIf="serviceType!=PolicyComponentServiceType.MGMT" class="new-idp" (click)="openDialog()">
<mat-icon>add</mat-icon>
</div>
</div>
<div class="btn-container">
<button (click)="savePolicy()" color="primary" type="submit"
<button (click)="savePolicy()" color="primary" type="submit" [disabled]="serviceType==PolicyComponentServiceType.MGMT"
mat-raised-button>{{ 'ACTIONS.SAVE' | translate }}</button>
</div>

View File

@ -23,4 +23,6 @@ type AuthRequestRepository interface {
VerifyMfaOTP(ctx context.Context, agentID, authRequestID, code, userAgentID string, info *model.BrowserInfo) error
LinkExternalUsers(ctx context.Context, authReqID, userAgentID string) error
AutoRegisterExternalUser(ctx context.Context, user *user_model.User, externalIDP *user_model.ExternalIDP, member *org_model.OrgMember, authReqID, userAgentID, resourceOwner string) error
ResetLinkingUsers(ctx context.Context, authReqID, userAgentID string) error
GetOrgByPrimaryDomain(primaryDomain string) (*org_model.OrgView, error)
}

View File

@ -74,6 +74,7 @@ type userEventProvider interface {
type orgViewProvider interface {
OrgByID(string) (*org_view_model.OrgView, error)
OrgByPrimaryDomain(string) (*org_view_model.OrgView, error)
}
func (repo *AuthRequestRepo) Health(ctx context.Context) error {
@ -231,6 +232,16 @@ func (repo *AuthRequestRepo) LinkExternalUsers(ctx context.Context, authReqID, u
return repo.AuthRequests.UpdateAuthRequest(ctx, request)
}
func (repo *AuthRequestRepo) ResetLinkingUsers(ctx context.Context, authReqID, userAgentID string) error {
request, err := repo.getAuthRequest(ctx, authReqID, userAgentID)
if err != nil {
return err
}
request.LinkingUsers = nil
request.SelectedIDPConfigID = ""
return repo.AuthRequests.UpdateAuthRequest(ctx, request)
}
func (repo *AuthRequestRepo) AutoRegisterExternalUser(ctx context.Context, registerUser *user_model.User, externalIDP *user_model.ExternalIDP, orgMember *org_model.OrgMember, authReqID, userAgentID, resourceOwner string) error {
request, err := repo.getAuthRequest(ctx, authReqID, userAgentID)
if err != nil {
@ -317,7 +328,14 @@ func (repo *AuthRequestRepo) getLoginPolicyAndIDPProviders(ctx context.Context,
func (repo *AuthRequestRepo) fillLoginPolicy(ctx context.Context, request *model.AuthRequest) error {
orgID := request.UserOrgID
if orgID == "" {
orgID = request.GetScopeOrgID()
primaryDomain := request.GetScopeOrgPrimaryDomain()
if primaryDomain != "" {
org, err := repo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
return err
}
orgID = org.ID
}
}
if orgID == "" {
orgID = repo.IAMID
@ -335,7 +353,16 @@ func (repo *AuthRequestRepo) fillLoginPolicy(ctx context.Context, request *model
}
func (repo *AuthRequestRepo) checkLoginName(ctx context.Context, request *model.AuthRequest, loginName string) (err error) {
orgID := request.GetScopeOrgID()
primaryDomain := request.GetScopeOrgPrimaryDomain()
orgID := ""
if primaryDomain != "" {
org, err := repo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
return err
}
orgID = org.ID
}
user := new(user_view_model.UserView)
if orgID != "" {
user, err = repo.View.UserByLoginNameAndResourceOwner(loginName, orgID)
@ -356,6 +383,14 @@ func (repo *AuthRequestRepo) checkLoginName(ctx context.Context, request *model.
return nil
}
func (repo AuthRequestRepo) GetOrgByPrimaryDomain(primaryDomain string) (*org_model.OrgView, error) {
org, err := repo.OrgViewProvider.OrgByPrimaryDomain(primaryDomain)
if err != nil {
return nil, err
}
return org_view_model.OrgToModel(org), nil
}
func (repo AuthRequestRepo) checkLoginPolicyWithResourceOwner(ctx context.Context, request *model.AuthRequest, user *user_view_model.UserView) error {
loginPolicy, idpProviders, err := repo.getLoginPolicyAndIDPProviders(ctx, user.ResourceOwner)
if err != nil {
@ -386,10 +421,15 @@ func (repo *AuthRequestRepo) checkSelectedExternalIDP(request *model.AuthRequest
}
func (repo *AuthRequestRepo) checkExternalUserLogin(request *model.AuthRequest, idpConfigID, externalUserID string) (err error) {
orgID := request.GetScopeOrgID()
primaryDomain := request.GetScopeOrgPrimaryDomain()
externalIDP := new(user_view_model.ExternalIDPView)
if orgID != "" {
externalIDP, err = repo.View.ExternalIDPByExternalUserIDAndIDPConfigIDAndResourceOwner(externalUserID, idpConfigID, orgID)
org := new(org_model.OrgView)
if primaryDomain != "" {
org, err = repo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
return err
}
externalIDP, err = repo.View.ExternalIDPByExternalUserIDAndIDPConfigIDAndResourceOwner(externalUserID, idpConfigID, org.ID)
} else {
externalIDP, err = repo.View.ExternalIDPByExternalUserIDAndIDPConfigID(externalUserID, idpConfigID)
}
@ -435,7 +475,7 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *model.AuthR
return nil, err
}
if request.SelectedIDPConfigID == "" {
if request.SelectedIDPConfigID == "" || (request.SelectedIDPConfigID != "" && request.LinkingUsers != nil && len(request.LinkingUsers) > 0) {
if user.InitRequired {
return append(steps, &model.InitUserStep{PasswordSet: user.PasswordSet}), nil
}

View File

@ -139,12 +139,22 @@ func (m *mockViewOrg) OrgByID(string) (*org_view_model.OrgView, error) {
}, nil
}
func (m *mockViewOrg) OrgByPrimaryDomain(string) (*org_view_model.OrgView, error) {
return &org_view_model.OrgView{
State: int32(m.State),
}, nil
}
type mockViewErrOrg struct{}
func (m *mockViewErrOrg) OrgByID(string) (*org_view_model.OrgView, error) {
return nil, errors.ThrowInternal(nil, "id", "internal error")
}
func (m *mockViewErrOrg) OrgByPrimaryDomain(string) (*org_view_model.OrgView, error) {
return nil, errors.ThrowInternal(nil, "id", "internal error")
}
func TestAuthRequestRepo_nextSteps(t *testing.T) {
type fields struct {
UserEvents *user_event.UserEventstore
@ -582,7 +592,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
nil,
},
{
"linking users, link users step",
"linking users, password step",
fields{
userSessionViewProvider: &mockViewUserSession{
MfaSoftwareVerification: time.Now().UTC().Add(-5 * time.Minute),
@ -596,6 +606,32 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) {
orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
MfaSoftwareCheckLifeTime: 18 * time.Hour,
},
args{
&model.AuthRequest{
UserID: "UserID",
SelectedIDPConfigID: "IDPConfigID",
LinkingUsers: []*model.ExternalUser{{IDPConfigID: "IDPConfigID", ExternalUserID: "UserID", DisplayName: "DisplayName"}},
}, false},
[]model.NextStep{&model.PasswordStep{}},
nil,
},
{
"linking users, linking step",
fields{
userSessionViewProvider: &mockViewUserSession{
PasswordVerification: time.Now().UTC().Add(-5 * time.Minute),
MfaSoftwareVerification: time.Now().UTC().Add(-5 * time.Minute),
},
userViewProvider: &mockViewUser{
PasswordSet: true,
IsEmailVerified: true,
MfaMaxSetUp: int32(model.MfaLevelSoftware),
},
userEventProvider: &mockEventUser{},
orgViewProvider: &mockViewOrg{State: org_model.OrgStateActive},
MfaSoftwareCheckLifeTime: 18 * time.Hour,
PasswordCheckLifeTime: 10 * 24 * time.Hour,
},
args{
&model.AuthRequest{
UserID: "UserID",

View File

@ -46,6 +46,17 @@ func (o *Org) Reduce(event *es_models.Event) (err error) {
return err
}
err = org.AppendEvent(event)
case model.OrgDomainPrimarySet:
domain := new(org_model.OrgDomainView)
err = domain.SetData(event)
if err != nil {
return err
}
org, err = o.view.OrgByID(event.AggregateID)
if err != nil {
return err
}
org.Domain = domain.Domain
default:
return o.view.ProcessedOrgSequence(event.Sequence)
}

View File

@ -15,6 +15,10 @@ func (v *View) OrgByID(orgID string) (*org_model.OrgView, error) {
return org_view.OrgByID(v.Db, orgTable, orgID)
}
func (v *View) OrgByPrimaryDomain(primaryDomain string) (*org_model.OrgView, error) {
return org_view.OrgByPrimaryDomain(v.Db, orgTable, primaryDomain)
}
func (v *View) SearchOrgs(req *model.OrgSearchRequest) ([]*org_model.OrgView, uint64, error) {
return org_view.SearchOrgs(v.Db, orgTable, req)
}

View File

@ -126,12 +126,12 @@ func (a *AuthRequest) SetUserInfo(userID, loginName, displayName, userOrgID stri
a.UserOrgID = userOrgID
}
func (a *AuthRequest) GetScopeOrgID() string {
func (a *AuthRequest) GetScopeOrgPrimaryDomain() string {
switch request := a.Request.(type) {
case *AuthRequestOIDC:
for _, scope := range request.Scopes {
if strings.HasPrefix(scope, OrgIDScope) {
strings.TrimPrefix(scope, OrgIDScope)
if strings.HasPrefix(scope, OrgDomainPrimaryScope) {
return strings.TrimPrefix(scope, OrgDomainPrimaryScope)
}
}
}

View File

@ -19,7 +19,7 @@ const (
)
const (
OrgIDScope = "urn:zitadel:organisation:id:"
OrgDomainPrimaryScope = "urn:zitadel:org:domain:primary:"
)
type AuthRequestOIDC struct {

View File

@ -27,7 +27,7 @@ func (c *OIDCIDPConfig) Changes(changed *OIDCIDPConfig) map[string]interface{} {
if c.ClientID != changed.ClientID {
changes["clientId"] = changed.ClientID
}
if c.ClientSecret != nil && c.ClientSecret != changed.ClientSecret {
if changed.ClientSecret != nil && c.ClientSecret != changed.ClientSecret {
changes["clientSecret"] = changed.ClientSecret
}
if c.Issuer != changed.Issuer {

View File

@ -18,6 +18,16 @@ func OrgByID(db *gorm.DB, table, orgID string) (*model.OrgView, error) {
return org, err
}
func OrgByPrimaryDomain(db *gorm.DB, table, primaryDomain string) (*model.OrgView, error) {
org := new(model.OrgView)
query := repository.PrepareGetByKey(table, model.OrgSearchKey(org_model.OrgSearchKeyOrgDomain), primaryDomain)
err := query(db, org)
if caos_errs.IsNotFound(err) {
return nil, caos_errs.ThrowNotFound(nil, "VIEW-GEwea", "Errors.Org.NotFound")
}
return org, err
}
func SearchOrgs(db *gorm.DB, table string, req *org_model.OrgSearchRequest) ([]*model.OrgView, uint64, error) {
orgs := make([]*model.OrgView, 0)
query := repository.PrepareSearchQuery(table, model.OrgSearchRequest{Limit: req.Limit, Offset: req.Offset, Queries: req.Queries})

View File

@ -33,6 +33,7 @@ type externalIDPCallbackData struct {
type externalNotFoundOptionFormData struct {
Link bool `schema:"link"`
AutoRegister bool `schema:"autoregister"`
ResetLinking bool `schema:"resetlinking"`
}
type externalNotFoundOptionData struct {
@ -139,35 +140,52 @@ func (l *Login) handleExternalNotFoundOptionCheck(w http.ResponseWriter, r *http
data := new(externalNotFoundOptionFormData)
authReq, err := l.getAuthRequestAndParseData(r, data)
if err != nil {
l.renderError(w, r, authReq, err)
l.renderExternalNotFoundOption(w, r, authReq, err)
return
}
if data.Link {
l.renderLogin(w, r, authReq, nil)
return
} else if data.ResetLinking {
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
err = l.authRepo.ResetLinkingUsers(r.Context(), authReq.ID, userAgentID)
if err != nil {
l.renderExternalNotFoundOption(w, r, authReq, err)
}
l.handleLogin(w, r)
return
}
l.handleAutoRegister(w, r, authReq)
}
func (l *Login) handleAutoRegister(w http.ResponseWriter, r *http.Request, authReq *model.AuthRequest) {
orgIamPolicy, err := l.getOrgIamPolicy(r, authReq.GetScopeOrgID())
if err != nil {
l.renderExternalNotFoundOption(w, r, authReq, err)
return
}
iam, err := l.authRepo.GetIAM(r.Context())
if err != nil {
l.renderExternalNotFoundOption(w, r, authReq, err)
return
}
resourceOwner := iam.GlobalOrgID
member := &org_model.OrgMember{
ObjectRoot: models.ObjectRoot{AggregateID: iam.GlobalOrgID},
Roles: []string{orgProjectCreatorRole},
}
if authReq.GetScopeOrgID() != iam.GlobalOrgID && authReq.GetScopeOrgID() != "" {
if authReq.GetScopeOrgPrimaryDomain() != "" {
primaryDomain := authReq.GetScopeOrgPrimaryDomain()
org, err := l.authRepo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
l.renderExternalNotFoundOption(w, r, authReq, err)
}
if org.ID != iam.GlobalOrgID {
member = nil
resourceOwner = authReq.GetScopeOrgID()
resourceOwner = org.ID
}
}
orgIamPolicy, err := l.getOrgIamPolicy(r, resourceOwner)
if err != nil {
l.renderExternalNotFoundOption(w, r, authReq, err)
return
}
idpConfig, err := l.authRepo.GetIDPConfigByID(r.Context(), authReq.SelectedIDPConfigID)
@ -216,7 +234,6 @@ func (l *Login) mapTokenToLoginUser(tokens *oidc.Tokens, idpConfig *iam_model.ID
}
return externalUser
}
func (l *Login) mapExternalUserToLoginUser(orgIamPolicy *org_model.OrgIAMPolicy, linkingUser *model.ExternalUser, idpConfig *iam_model.IDPConfigView) (*usr_model.User, *usr_model.ExternalIDP) {
username := linkingUser.PreferredUsername
switch idpConfig.OIDCUsernameMapping {

View File

@ -71,11 +71,6 @@ func (l *Login) handleExternalRegisterCallback(w http.ResponseWriter, r *http.Re
}
func (l *Login) handleExternalUserRegister(w http.ResponseWriter, r *http.Request, authReq *model.AuthRequest, idpConfig *iam_model.IDPConfigView, userAgentID string, tokens *oidc.Tokens) {
orgIamPolicy, err := l.getOrgIamPolicy(r, authReq.GetScopeOrgID())
if err != nil {
l.renderRegisterOption(w, r, authReq, err)
return
}
iam, err := l.authRepo.GetIAM(r.Context())
if err != nil {
l.renderRegisterOption(w, r, authReq, err)
@ -86,11 +81,24 @@ func (l *Login) handleExternalUserRegister(w http.ResponseWriter, r *http.Reques
ObjectRoot: models.ObjectRoot{AggregateID: iam.GlobalOrgID},
Roles: []string{orgProjectCreatorRole},
}
if authReq.GetScopeOrgID() != iam.GlobalOrgID && authReq.GetScopeOrgID() != "" {
member = nil
resourceOwner = authReq.GetScopeOrgID()
}
if authReq.GetScopeOrgPrimaryDomain() != "" {
primaryDomain := authReq.GetScopeOrgPrimaryDomain()
org, err := l.authRepo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
l.renderRegisterOption(w, r, authReq, err)
return
}
if org.ID != iam.GlobalOrgID {
member = nil
resourceOwner = org.ID
}
}
orgIamPolicy, err := l.getOrgIamPolicy(r, resourceOwner)
if err != nil {
l.renderRegisterOption(w, r, authReq, err)
return
}
user, externalIDP := l.mapTokenToLoginUserAndExternalIDP(orgIamPolicy, tokens, idpConfig)
_, err = l.authRepo.RegisterExternalUser(setContext(r.Context(), resourceOwner), user, externalIDP, member, resourceOwner)
if err != nil {

View File

@ -71,9 +71,17 @@ func (l *Login) handleRegisterCheck(w http.ResponseWriter, r *http.Request) {
ObjectRoot: models.ObjectRoot{AggregateID: iam.GlobalOrgID},
Roles: []string{orgProjectCreatorRole},
}
if authRequest.GetScopeOrgID() != "" && authRequest.GetScopeOrgID() != iam.GlobalOrgID {
if authRequest.GetScopeOrgPrimaryDomain() != "" {
primaryDomain := authRequest.GetScopeOrgPrimaryDomain()
org, err := l.authRepo.GetOrgByPrimaryDomain(primaryDomain)
if err != nil {
l.renderRegisterOption(w, r, authRequest, err)
return
}
if org.ID != iam.GlobalOrgID {
member = nil
resourceOwner = authRequest.GetScopeOrgID()
resourceOwner = org.ID
}
}
user, err := l.authRepo.Register(setContext(r.Context(), resourceOwner), data.toUserModel(), member, resourceOwner)
if err != nil {

View File

@ -292,7 +292,14 @@ func (l *Login) getOrgID(authReq *model.AuthRequest) string {
if authReq.Request == nil {
return ""
}
return authReq.GetScopeOrgID()
primaryDomain := authReq.GetScopeOrgPrimaryDomain()
if primaryDomain != "" {
org, _ := l.authRepo.GetOrgByPrimaryDomain(primaryDomain)
if org != nil {
return org.ID
}
}
return ""
}
func getRequestID(authReq *model.AuthRequest, r *http.Request) string {

View File

@ -1,9 +1,7 @@
{{ define "error-message" }}
{{if .ErrMessage }}
<div class="field">
<div class="error">
<div class="error">
{{ if .ErrType }}{{ .ErrType }} - {{end}}{{ .ErrMessage }}
</div>
</div>
{{end}}
{{ end }}

View File

@ -15,9 +15,7 @@
<div class="actions">
<button class="secondary right" name="link" value="true" formnovalidate>{{t "ExternalNotFoundOption.Link"}}</button>
<button class="secondary right" name="autoregister" value="true" formnovalidate>{{t "ExternalNotFoundOption.AutoRegister"}}</button>
<a class="button secondary" href="{{ loginUrl .AuthReqID }}">
{{t "Actions.Back"}}
</a>
<button class="secondary right" name="resetlinking" value="true" formnovalidate>{{t "Actions.Back"}}</button>
</div>
{{template "error-message" .}}